Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
33 commits
Select commit Hold shift + click to select a range
ed643d0
feat: [E2E] bootstrap writes deploy manifest for test discovery (#177)
sadiq1971 Apr 1, 2026
fa8dea2
feat: [E2E] Layer 1 — stack interfaces, types, compose override, Make…
sadiq1971 Apr 1, 2026
e8e23c2
used existing types
sadiq1971 Apr 6, 2026
2d8fb9c
used existing types
sadiq1971 Apr 6, 2026
00cc4a2
added per db dsn
sadiq1971 Apr 6, 2026
e79889a
feat: docker discovry added
sadiq1971 Apr 6, 2026
b163542
feat: [E2E] Layer 2 — shim implementations
sadiq1971 Apr 6, 2026
a9df74c
resolved gemni comments
sadiq1971 Apr 6, 2026
214bfef
removed unused db
sadiq1971 Apr 7, 2026
4854f06
feat: [E2E] Layers 4/5/6 — System, DSL, and Presets
sadiq1971 Apr 7, 2026
189e253
feat: [E2E] add IndexerSystem and APISystem subset views
sadiq1971 Apr 8, 2026
0078058
fix: address Gemini PR #193 review comments
sadiq1971 Apr 8, 2026
9e49dc7
merged main
sadiq1971 Apr 8, 2026
14a0e2d
lint fixed
sadiq1971 Apr 8, 2026
31e930c
api server to act as ethclient
sadiq1971 Apr 8, 2026
0505f87
fix lint
sadiq1971 Apr 8, 2026
dc9f255
optimized
sadiq1971 Apr 8, 2026
0930433
Merge branch 'main' into feat/e2e-layer2-shim
sadiq1971 Apr 9, 2026
7c295a1
Merge branch 'feat/e2e-layer2-shim' into feat/e2e-layer4-5-6-system-d…
sadiq1971 Apr 9, 2026
1d52eee
optimized implementation
sadiq1971 Apr 9, 2026
86d1290
merged base
sadiq1971 Apr 9, 2026
6538be0
merged base
sadiq1971 Apr 9, 2026
06c72a2
lint fixed
sadiq1971 Apr 9, 2026
91fe6a2
reolved gemini comments
sadiq1971 Apr 9, 2026
91f0e9a
feat: [E2E] Layer 2 — token client, error-safe NewCanton, HTTPError, …
sadiq1971 Apr 9, 2026
eb34f44
Merge branch 'feat/e2e-layer2-shim' into feat/e2e-layer4-5-6-system-d…
sadiq1971 Apr 9, 2026
54793e7
feat: [E2E] Layers 4/5/6 — Canton integration, token ops, WaitForAPIB…
sadiq1971 Apr 9, 2026
fe03317
resolved sigh hash bug
sadiq1971 Apr 13, 2026
a151f49
removed unused function
sadiq1971 Apr 13, 2026
3a33b48
fix lint
sadiq1971 Apr 13, 2026
fb5100e
merged main
sadiq1971 Apr 13, 2026
0927570
addressed pr comment
sadiq1971 Apr 13, 2026
d926bde
addressed pr comment
sadiq1971 Apr 13, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
226 changes: 226 additions & 0 deletions tests/e2e/devstack/dsl/dsl.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,226 @@
//go:build e2e

// Package dsl provides high-level test operations built on top of the stack
// service interfaces. Methods accept *testing.T and call t.Fatal on error so
// tests read as plain imperative steps without error-handling boilerplate.
package dsl

import (
"context"
"encoding/hex"
"math/big"
"strings"
"testing"
"time"

"github.com/ethereum/go-ethereum/common"

"github.com/chainsafe/canton-middleware/pkg/keys"
"github.com/chainsafe/canton-middleware/pkg/user"
"github.com/chainsafe/canton-middleware/tests/e2e/devstack/stack"
"github.com/chainsafe/canton-middleware/tests/e2e/devstack/util"
)

// DSL exposes high-level operations over the service interfaces. Accessed via
// System.DSL.
type DSL struct {
apiServer stack.APIServer
canton stack.Canton
relayer stack.Relayer
indexer stack.Indexer
postgres stack.APIDatabase
anvil stack.Anvil
}

const (
decimalBase = 10
waitForAPIBalanceTimeout = 60 * time.Second
)

// New wires a DSL to the given service interfaces. canton, relayer, and indexer
// may be nil when the system under test does not include those services; calling
// DSL methods that require them will produce a descriptive t.Fatal message.
func New(
api stack.APIServer,
canton stack.Canton,
relayer stack.Relayer,
indexer stack.Indexer,
postgres stack.APIDatabase,
anvil stack.Anvil,
) *DSL {
return &DSL{
apiServer: api,
canton: canton,
relayer: relayer,
indexer: indexer,
postgres: postgres,
anvil: anvil,
}
}

// RegisterUser whitelists the account's EVM address and registers it as a
// custodial web3 user via POST /register. Returns the RegisterResponse.
func (d *DSL) RegisterUser(ctx context.Context, t *testing.T, account stack.Account) *user.RegisterResponse {
t.Helper()

if err := d.postgres.WhitelistAddress(ctx, account.Address.Hex()); err != nil {
t.Fatalf("whitelist %s: %v", account.Address.Hex(), err)
}

msg := "register"
sig, err := util.SignEIP191(account.PrivateKey, msg)
if err != nil {
t.Fatalf("sign register message: %v", err)
}

resp, err := d.apiServer.Register(ctx, &user.RegisterRequest{
Signature: sig,
Message: msg,
})
if err != nil {
t.Fatalf("register %s: %v", account.Address.Hex(), err)
}
return resp
}

// RegisterExternalUser whitelists account's EVM address and completes the
// two-step external (non-custodial) registration flow:
// 1. Generates a fresh secp256k1 Canton keypair.
// 2. Calls POST /register/prepare-topology to get the topology hash.
// 3. Signs the topology hash with the Canton key (DER, SHA-256).
// 4. Calls POST /register with key_mode=external.
//
// Returns the RegisterResponse and the Canton keypair (needed to sign transfers).
func (d *DSL) RegisterExternalUser(ctx context.Context, t *testing.T, account stack.Account) (*user.RegisterResponse, *keys.CantonKeyPair) {
t.Helper()

if err := d.postgres.WhitelistAddress(ctx, account.Address.Hex()); err != nil {
t.Fatalf("whitelist %s: %v", account.Address.Hex(), err)
}

kp, err := keys.GenerateCantonKeyPair()
if err != nil {
t.Fatalf("generate canton keypair: %v", err)
}

msg := "register"
sig, err := util.SignEIP191(account.PrivateKey, msg)
if err != nil {
t.Fatalf("sign register message: %v", err)
}

topoResp, err := d.apiServer.PrepareTopology(ctx, &user.RegisterRequest{
Signature: sig,
Message: msg,
CantonPublicKey: kp.PublicKeyHex(),
})
if err != nil {
t.Fatalf("prepare-topology %s: %v", account.Address.Hex(), err)
}

// TopologyHash is "0x" + hex(multiHash). Sign raw bytes (SignDER SHA-256s internally).
hashBytes, err := hex.DecodeString(strings.TrimPrefix(topoResp.TopologyHash, "0x"))
if err != nil {
t.Fatalf("decode topology hash: %v", err)
}
derSig, err := kp.SignDER(hashBytes)
if err != nil {
t.Fatalf("sign topology hash: %v", err)
}
topologySig := "0x" + hex.EncodeToString(derSig)

resp, err := d.apiServer.RegisterExternal(ctx, &user.RegisterRequest{
Signature: sig,
Message: msg,
KeyMode: user.KeyModeExternal,
CantonPublicKey: kp.PublicKeyHex(),
RegistrationToken: topoResp.RegistrationToken,
TopologySignature: topologySig,
})
if err != nil {
t.Fatalf("register-external %s: %v", account.Address.Hex(), err)
}
return resp, kp
}

// MintDEMO mints amount of DEMO tokens to recipientParty via the Canton
// ledger (IssuerMint DAML choice). Requires a Canton shim with token client.
func (d *DSL) MintDEMO(ctx context.Context, t *testing.T, recipientParty, amount string) {
t.Helper()
if d.canton == nil {
t.Fatal("MintDEMO not available: Canton shim not initialized")
return
}
if err := d.canton.MintToken(ctx, recipientParty, "DEMO", amount); err != nil {
t.Fatalf("mint DEMO to %s: %v", recipientParty, err)
}
}

// Deposit approves the bridge and submits a depositToCanton transaction on
// behalf of account. Returns the deposit transaction hash.
func (d *DSL) Deposit(ctx context.Context, t *testing.T, account stack.Account, amount *big.Int) common.Hash {
t.Helper()
hash, err := d.anvil.ApproveAndDeposit(ctx, &account, amount)
if err != nil {
t.Fatalf("deposit for %s: %v", account.Address.Hex(), err)
}
return hash
}

// WaitForAPIBalance polls the api-server's /eth JSON-RPC facade until the
// ERC-20 balance of ownerAddr for tok is >= minTokens (human-readable token
// amount, e.g. "50"). This is the preferred balance check for api-server tests
// — no indexer needed. Pass sys.Tokens.DEMO or sys.Tokens.PROMPT as tok.
func (d *DSL) WaitForAPIBalance(ctx context.Context, t *testing.T, tok stack.Token, ownerAddr common.Address, minTokens string) {
t.Helper()
// Scale minTokens by 10^tok.Decimals.
exp := new(big.Int).Exp(big.NewInt(decimalBase), big.NewInt(int64(tok.Decimals)), nil)
minF, ok := new(big.Float).SetString(minTokens)
if !ok {
t.Fatalf("WaitForAPIBalance: invalid amount %q", minTokens)
}
minF.Mul(minF, new(big.Float).SetInt(exp))
minWei, _ := minF.Int(nil)

deadline := time.Now().Add(waitForAPIBalanceTimeout)
ticker := time.NewTicker(pollInterval)
defer ticker.Stop()
var lastErr error
var lastBal *big.Int
for time.Now().Before(deadline) {
bal, err := d.apiServer.ERC20Balance(ctx, tok.Address, ownerAddr)
if err != nil {
lastErr = err
} else {
lastBal = bal
if bal.Cmp(minWei) >= 0 {
return
}
}
select {
case <-ctx.Done():
t.Fatal("context canceled waiting for API balance")
case <-ticker.C:
}
}
if lastErr != nil {
t.Fatalf("WaitForAPIBalance: timed out waiting for %s %s balance >= %s (owner %s): last error: %v",
minTokens, tok.Symbol, minWei, ownerAddr.Hex(), lastErr)
}
if lastBal != nil {
t.Fatalf("WaitForAPIBalance: timed out waiting for %s %s balance >= %s (owner %s): last seen balance: %s",
minTokens, tok.Symbol, minWei, ownerAddr.Hex(), lastBal.String())
}
t.Fatalf("WaitForAPIBalance: timed out waiting for %s %s balance >= %s (owner %s)",
minTokens, tok.Symbol, minWei, ownerAddr.Hex())
Comment thread
sadiq1971 marked this conversation as resolved.
}

// ERC20Balance returns the on-chain ERC-20 balance of account for tokenAddr.
func (d *DSL) ERC20Balance(ctx context.Context, t *testing.T, tokenAddr common.Address, account stack.Account) *big.Int {
t.Helper()
bal, err := d.anvil.ERC20Balance(ctx, tokenAddr, account.Address)
if err != nil {
t.Fatalf("erc20 balance for %s: %v", account.Address.Hex(), err)
}
return bal
}
149 changes: 149 additions & 0 deletions tests/e2e/devstack/dsl/helpers.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,149 @@
//go:build e2e

package dsl

import (
"context"
"math/big"
"strings"
"testing"
"time"

"github.com/chainsafe/canton-middleware/pkg/indexer"
"github.com/chainsafe/canton-middleware/pkg/relayer"
)
Comment thread
sadiq1971 marked this conversation as resolved.

const (
pollInterval = 500 * time.Millisecond
relayerReadyTimeout = 60 * time.Second
cantonBalanceTimeout = 60 * time.Second
relayerTransferTimeout = 120 * time.Second
indexerEventTimeout = 60 * time.Second
)

// WaitForRelayerReady polls until the relayer reports ready or the 60s timeout
// is reached.
func (d *DSL) WaitForRelayerReady(ctx context.Context, t *testing.T) {
t.Helper()
if d.relayer == nil {
t.Fatal("WaitForRelayerReady not available: Relayer shim not initialized (use NewFullStack)")
return
}
deadline := time.Now().Add(relayerReadyTimeout)
ticker := time.NewTicker(pollInterval)
defer ticker.Stop()
for time.Now().Before(deadline) {
if d.relayer.IsReady(ctx) {
return
}
select {
case <-ctx.Done():
t.Fatal("context canceled waiting for relayer ready")
case <-ticker.C:
}
}
t.Fatal("timeout waiting for relayer to be ready")
}

// WaitForCantonBalance polls the indexer until partyID holds at least
// minAmount for the token identified by (admin, id), or the 60s timeout
// is reached.
func (d *DSL) WaitForCantonBalance(ctx context.Context, t *testing.T, partyID, admin, id, minAmount string) {
t.Helper()
if d.indexer == nil {
t.Fatal("WaitForCantonBalance not available: Indexer shim not initialized (use NewFullStack)")
return
}
deadline := time.Now().Add(cantonBalanceTimeout)
ticker := time.NewTicker(pollInterval)
defer ticker.Stop()
var lastBalance string
for time.Now().Before(deadline) {
bal, err := d.indexer.GetBalance(ctx, partyID, admin, id)
if err == nil && bal != nil {
lastBalance = bal.Amount
if amountGTE(bal.Amount, minAmount) {
return
}
}
select {
case <-ctx.Done():
t.Fatal("context canceled waiting for Canton balance")
case <-ticker.C:
}
}
t.Fatalf("timeout waiting for Canton balance: party=%s admin=%s id=%s min=%s last=%s",
partyID, admin, id, minAmount, lastBalance)
}

// WaitForRelayerTransfer polls until the relayer has a completed transfer
// matching sourceTxHash, or the 120s timeout is reached.
func (d *DSL) WaitForRelayerTransfer(ctx context.Context, t *testing.T, sourceTxHash string) {
t.Helper()
if d.relayer == nil {
t.Fatal("WaitForRelayerTransfer not available: Relayer shim not initialized (use NewFullStack)")
return
}
deadline := time.Now().Add(relayerTransferTimeout)
ticker := time.NewTicker(pollInterval)
defer ticker.Stop()
var lastStatus relayer.TransferStatus
for time.Now().Before(deadline) {
transfers, err := d.relayer.ListTransfers(ctx)
if err == nil {
for _, tr := range transfers {
if strings.EqualFold(tr.SourceTxHash, sourceTxHash) {
lastStatus = tr.Status
if tr.Status == relayer.TransferStatusCompleted {
return
}
}
}
}
select {
case <-ctx.Done():
t.Fatal("context canceled waiting for relayer transfer")
case <-ticker.C:
}
}
t.Fatalf("timeout waiting for relayer transfer: sourceTxHash=%s lastStatus=%s", sourceTxHash, lastStatus)
}

// WaitForIndexerEvent polls until the indexer has an event with the given
// contractID, or the 60s timeout is reached.
func (d *DSL) WaitForIndexerEvent(ctx context.Context, t *testing.T, contractID string) *indexer.ParsedEvent {
t.Helper()
if d.indexer == nil {
t.Fatal("WaitForIndexerEvent not available: Indexer shim not initialized (use NewFullStack)")
return nil // unreachable; t.Fatal calls runtime.Goexit
}
deadline := time.Now().Add(indexerEventTimeout)
ticker := time.NewTicker(pollInterval)
defer ticker.Stop()
var lastErr error
for time.Now().Before(deadline) {
ev, err := d.indexer.GetEvent(ctx, contractID)
if err == nil && ev != nil {
return ev
}
lastErr = err
select {
case <-ctx.Done():
t.Fatalf("context canceled waiting for indexer event: contractID=%s", contractID)
case <-ticker.C:
}
}
t.Fatalf("timeout waiting for indexer event: contractID=%s lastErr=%v", contractID, lastErr)
return nil // unreachable; t.Fatalf calls runtime.Goexit
}

// amountGTE returns true when amount >= min, comparing both as decimal numbers.
// String comparison is intentionally avoided: "20" > "100" lexicographically.
func amountGTE(amount, min string) bool {
a, ok1 := new(big.Float).SetString(amount)
m, ok2 := new(big.Float).SetString(min)
if !ok1 || !ok2 {
return false
}
return a.Cmp(m) >= 0
}
Loading
Loading