Skip to content

Commit cebe089

Browse files
authored
feat: [E2E] Layers 4/5/6 — System, DSL, and Presets (#193)
* feat: [E2E] bootstrap writes deploy manifest for test discovery (#177) - Add `e2e-deploy` named volume to bootstrap container so the manifest survives until tests read it - Write `/tmp/e2e-deploy.json` in docker-bootstrap.sh with contract addresses and issuer party IDs - Add E2E test architecture design doc * feat: [E2E] Layer 1 — stack interfaces, types, compose override, Makefile targets (#178) - Add tests/e2e/devstack/stack/interfaces.go: Anvil, Canton, APIServer, Relayer, Indexer, Postgres interfaces (build tag: e2e) - Add tests/e2e/devstack/stack/types.go: ServiceManifest, Account, AnvilAccount0/1, all request/response/row types, indexer page types - Add tests/e2e/docker-compose.e2e.yaml: thin include wrapper over root docker-compose.yaml; single entry point for E2E test runs - Extend Makefile with test-e2e, test-e2e-api, test-e2e-bridge, test-e2e-indexer targets (stub: "not yet implemented") * used existing types * used existing types * added per db dsn * feat: docker discovry added * feat: [E2E] Layer 2 — shim implementations * resolved gemni comments * removed unused db * feat: [E2E] Layers 4/5/6 — System, DSL, and Presets Implements issue #181: wires all shims into a composed System, adds high-level DSL operations (RegisterUser, Deposit, ERC20Balance, WaitFor*), and provides DoMain/NewFullStack entry points for test packages. * feat: [E2E] add IndexerSystem and APISystem subset views Introduces NewIndexerStack and NewAPIStack preset constructors so that test packages only initialise the shims they actually use. DoMain now stores only the manifest; each New*Stack builds its own system with the correct t.Cleanup lifecycle. * fix: address Gemini PR #193 review comments - helpers.go: fix lexicographic string comparison in WaitForCantonBalance; parse amounts as big.Float and compare with Cmp - system.go: remove *testing.T from New/NewAPISystem; add closeFunc field and Close() method on System and APISystem for explicit resource management - presets.go: use signal.NotifyContext for SIGINT/SIGTERM handling in DoMain; Stop with fresh context to avoid cancellation race; register t.Cleanup for Close() in NewFullStack and NewAPIStack * lint fixed * api server to act as ethclient * fix lint * optimized * optimized implementation * lint fixed * reolved gemini comments * feat: [E2E] Layer 2 — token client, error-safe NewCanton, HTTPError, util refactor * feat: [E2E] Layers 4/5/6 — Canton integration, token ops, WaitForAPIBalance, TestAccounts * resolved sigh hash bug * removed unused function * fix lint * addressed pr comment * addressed pr comment
1 parent 23f80c9 commit cebe089

5 files changed

Lines changed: 828 additions & 0 deletions

File tree

tests/e2e/devstack/dsl/dsl.go

Lines changed: 226 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,226 @@
1+
//go:build e2e
2+
3+
// Package dsl provides high-level test operations built on top of the stack
4+
// service interfaces. Methods accept *testing.T and call t.Fatal on error so
5+
// tests read as plain imperative steps without error-handling boilerplate.
6+
package dsl
7+
8+
import (
9+
"context"
10+
"encoding/hex"
11+
"math/big"
12+
"strings"
13+
"testing"
14+
"time"
15+
16+
"github.com/ethereum/go-ethereum/common"
17+
18+
"github.com/chainsafe/canton-middleware/pkg/keys"
19+
"github.com/chainsafe/canton-middleware/pkg/user"
20+
"github.com/chainsafe/canton-middleware/tests/e2e/devstack/stack"
21+
"github.com/chainsafe/canton-middleware/tests/e2e/devstack/util"
22+
)
23+
24+
// DSL exposes high-level operations over the service interfaces. Accessed via
25+
// System.DSL.
26+
type DSL struct {
27+
apiServer stack.APIServer
28+
canton stack.Canton
29+
relayer stack.Relayer
30+
indexer stack.Indexer
31+
postgres stack.APIDatabase
32+
anvil stack.Anvil
33+
}
34+
35+
const (
36+
decimalBase = 10
37+
waitForAPIBalanceTimeout = 60 * time.Second
38+
)
39+
40+
// New wires a DSL to the given service interfaces. canton, relayer, and indexer
41+
// may be nil when the system under test does not include those services; calling
42+
// DSL methods that require them will produce a descriptive t.Fatal message.
43+
func New(
44+
api stack.APIServer,
45+
canton stack.Canton,
46+
relayer stack.Relayer,
47+
indexer stack.Indexer,
48+
postgres stack.APIDatabase,
49+
anvil stack.Anvil,
50+
) *DSL {
51+
return &DSL{
52+
apiServer: api,
53+
canton: canton,
54+
relayer: relayer,
55+
indexer: indexer,
56+
postgres: postgres,
57+
anvil: anvil,
58+
}
59+
}
60+
61+
// RegisterUser whitelists the account's EVM address and registers it as a
62+
// custodial web3 user via POST /register. Returns the RegisterResponse.
63+
func (d *DSL) RegisterUser(ctx context.Context, t *testing.T, account stack.Account) *user.RegisterResponse {
64+
t.Helper()
65+
66+
if err := d.postgres.WhitelistAddress(ctx, account.Address.Hex()); err != nil {
67+
t.Fatalf("whitelist %s: %v", account.Address.Hex(), err)
68+
}
69+
70+
msg := "register"
71+
sig, err := util.SignEIP191(account.PrivateKey, msg)
72+
if err != nil {
73+
t.Fatalf("sign register message: %v", err)
74+
}
75+
76+
resp, err := d.apiServer.Register(ctx, &user.RegisterRequest{
77+
Signature: sig,
78+
Message: msg,
79+
})
80+
if err != nil {
81+
t.Fatalf("register %s: %v", account.Address.Hex(), err)
82+
}
83+
return resp
84+
}
85+
86+
// RegisterExternalUser whitelists account's EVM address and completes the
87+
// two-step external (non-custodial) registration flow:
88+
// 1. Generates a fresh secp256k1 Canton keypair.
89+
// 2. Calls POST /register/prepare-topology to get the topology hash.
90+
// 3. Signs the topology hash with the Canton key (DER, SHA-256).
91+
// 4. Calls POST /register with key_mode=external.
92+
//
93+
// Returns the RegisterResponse and the Canton keypair (needed to sign transfers).
94+
func (d *DSL) RegisterExternalUser(ctx context.Context, t *testing.T, account stack.Account) (*user.RegisterResponse, *keys.CantonKeyPair) {
95+
t.Helper()
96+
97+
if err := d.postgres.WhitelistAddress(ctx, account.Address.Hex()); err != nil {
98+
t.Fatalf("whitelist %s: %v", account.Address.Hex(), err)
99+
}
100+
101+
kp, err := keys.GenerateCantonKeyPair()
102+
if err != nil {
103+
t.Fatalf("generate canton keypair: %v", err)
104+
}
105+
106+
msg := "register"
107+
sig, err := util.SignEIP191(account.PrivateKey, msg)
108+
if err != nil {
109+
t.Fatalf("sign register message: %v", err)
110+
}
111+
112+
topoResp, err := d.apiServer.PrepareTopology(ctx, &user.RegisterRequest{
113+
Signature: sig,
114+
Message: msg,
115+
CantonPublicKey: kp.PublicKeyHex(),
116+
})
117+
if err != nil {
118+
t.Fatalf("prepare-topology %s: %v", account.Address.Hex(), err)
119+
}
120+
121+
// TopologyHash is "0x" + hex(multiHash). Sign raw bytes (SignDER SHA-256s internally).
122+
hashBytes, err := hex.DecodeString(strings.TrimPrefix(topoResp.TopologyHash, "0x"))
123+
if err != nil {
124+
t.Fatalf("decode topology hash: %v", err)
125+
}
126+
derSig, err := kp.SignDER(hashBytes)
127+
if err != nil {
128+
t.Fatalf("sign topology hash: %v", err)
129+
}
130+
topologySig := "0x" + hex.EncodeToString(derSig)
131+
132+
resp, err := d.apiServer.RegisterExternal(ctx, &user.RegisterRequest{
133+
Signature: sig,
134+
Message: msg,
135+
KeyMode: user.KeyModeExternal,
136+
CantonPublicKey: kp.PublicKeyHex(),
137+
RegistrationToken: topoResp.RegistrationToken,
138+
TopologySignature: topologySig,
139+
})
140+
if err != nil {
141+
t.Fatalf("register-external %s: %v", account.Address.Hex(), err)
142+
}
143+
return resp, kp
144+
}
145+
146+
// MintDEMO mints amount of DEMO tokens to recipientParty via the Canton
147+
// ledger (IssuerMint DAML choice). Requires a Canton shim with token client.
148+
func (d *DSL) MintDEMO(ctx context.Context, t *testing.T, recipientParty, amount string) {
149+
t.Helper()
150+
if d.canton == nil {
151+
t.Fatal("MintDEMO not available: Canton shim not initialized")
152+
return
153+
}
154+
if err := d.canton.MintToken(ctx, recipientParty, "DEMO", amount); err != nil {
155+
t.Fatalf("mint DEMO to %s: %v", recipientParty, err)
156+
}
157+
}
158+
159+
// Deposit approves the bridge and submits a depositToCanton transaction on
160+
// behalf of account. Returns the deposit transaction hash.
161+
func (d *DSL) Deposit(ctx context.Context, t *testing.T, account stack.Account, amount *big.Int) common.Hash {
162+
t.Helper()
163+
hash, err := d.anvil.ApproveAndDeposit(ctx, &account, amount)
164+
if err != nil {
165+
t.Fatalf("deposit for %s: %v", account.Address.Hex(), err)
166+
}
167+
return hash
168+
}
169+
170+
// WaitForAPIBalance polls the api-server's /eth JSON-RPC facade until the
171+
// ERC-20 balance of ownerAddr for tok is >= minTokens (human-readable token
172+
// amount, e.g. "50"). This is the preferred balance check for api-server tests
173+
// — no indexer needed. Pass sys.Tokens.DEMO or sys.Tokens.PROMPT as tok.
174+
func (d *DSL) WaitForAPIBalance(ctx context.Context, t *testing.T, tok stack.Token, ownerAddr common.Address, minTokens string) {
175+
t.Helper()
176+
// Scale minTokens by 10^tok.Decimals.
177+
exp := new(big.Int).Exp(big.NewInt(decimalBase), big.NewInt(int64(tok.Decimals)), nil)
178+
minF, ok := new(big.Float).SetString(minTokens)
179+
if !ok {
180+
t.Fatalf("WaitForAPIBalance: invalid amount %q", minTokens)
181+
}
182+
minF.Mul(minF, new(big.Float).SetInt(exp))
183+
minWei, _ := minF.Int(nil)
184+
185+
deadline := time.Now().Add(waitForAPIBalanceTimeout)
186+
ticker := time.NewTicker(pollInterval)
187+
defer ticker.Stop()
188+
var lastErr error
189+
var lastBal *big.Int
190+
for time.Now().Before(deadline) {
191+
bal, err := d.apiServer.ERC20Balance(ctx, tok.Address, ownerAddr)
192+
if err != nil {
193+
lastErr = err
194+
} else {
195+
lastBal = bal
196+
if bal.Cmp(minWei) >= 0 {
197+
return
198+
}
199+
}
200+
select {
201+
case <-ctx.Done():
202+
t.Fatal("context canceled waiting for API balance")
203+
case <-ticker.C:
204+
}
205+
}
206+
if lastErr != nil {
207+
t.Fatalf("WaitForAPIBalance: timed out waiting for %s %s balance >= %s (owner %s): last error: %v",
208+
minTokens, tok.Symbol, minWei, ownerAddr.Hex(), lastErr)
209+
}
210+
if lastBal != nil {
211+
t.Fatalf("WaitForAPIBalance: timed out waiting for %s %s balance >= %s (owner %s): last seen balance: %s",
212+
minTokens, tok.Symbol, minWei, ownerAddr.Hex(), lastBal.String())
213+
}
214+
t.Fatalf("WaitForAPIBalance: timed out waiting for %s %s balance >= %s (owner %s)",
215+
minTokens, tok.Symbol, minWei, ownerAddr.Hex())
216+
}
217+
218+
// ERC20Balance returns the on-chain ERC-20 balance of account for tokenAddr.
219+
func (d *DSL) ERC20Balance(ctx context.Context, t *testing.T, tokenAddr common.Address, account stack.Account) *big.Int {
220+
t.Helper()
221+
bal, err := d.anvil.ERC20Balance(ctx, tokenAddr, account.Address)
222+
if err != nil {
223+
t.Fatalf("erc20 balance for %s: %v", account.Address.Hex(), err)
224+
}
225+
return bal
226+
}

tests/e2e/devstack/dsl/helpers.go

Lines changed: 149 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,149 @@
1+
//go:build e2e
2+
3+
package dsl
4+
5+
import (
6+
"context"
7+
"math/big"
8+
"strings"
9+
"testing"
10+
"time"
11+
12+
"github.com/chainsafe/canton-middleware/pkg/indexer"
13+
"github.com/chainsafe/canton-middleware/pkg/relayer"
14+
)
15+
16+
const (
17+
pollInterval = 500 * time.Millisecond
18+
relayerReadyTimeout = 60 * time.Second
19+
cantonBalanceTimeout = 60 * time.Second
20+
relayerTransferTimeout = 120 * time.Second
21+
indexerEventTimeout = 60 * time.Second
22+
)
23+
24+
// WaitForRelayerReady polls until the relayer reports ready or the 60s timeout
25+
// is reached.
26+
func (d *DSL) WaitForRelayerReady(ctx context.Context, t *testing.T) {
27+
t.Helper()
28+
if d.relayer == nil {
29+
t.Fatal("WaitForRelayerReady not available: Relayer shim not initialized (use NewFullStack)")
30+
return
31+
}
32+
deadline := time.Now().Add(relayerReadyTimeout)
33+
ticker := time.NewTicker(pollInterval)
34+
defer ticker.Stop()
35+
for time.Now().Before(deadline) {
36+
if d.relayer.IsReady(ctx) {
37+
return
38+
}
39+
select {
40+
case <-ctx.Done():
41+
t.Fatal("context canceled waiting for relayer ready")
42+
case <-ticker.C:
43+
}
44+
}
45+
t.Fatal("timeout waiting for relayer to be ready")
46+
}
47+
48+
// WaitForCantonBalance polls the indexer until partyID holds at least
49+
// minAmount for the token identified by (admin, id), or the 60s timeout
50+
// is reached.
51+
func (d *DSL) WaitForCantonBalance(ctx context.Context, t *testing.T, partyID, admin, id, minAmount string) {
52+
t.Helper()
53+
if d.indexer == nil {
54+
t.Fatal("WaitForCantonBalance not available: Indexer shim not initialized (use NewFullStack)")
55+
return
56+
}
57+
deadline := time.Now().Add(cantonBalanceTimeout)
58+
ticker := time.NewTicker(pollInterval)
59+
defer ticker.Stop()
60+
var lastBalance string
61+
for time.Now().Before(deadline) {
62+
bal, err := d.indexer.GetBalance(ctx, partyID, admin, id)
63+
if err == nil && bal != nil {
64+
lastBalance = bal.Amount
65+
if amountGTE(bal.Amount, minAmount) {
66+
return
67+
}
68+
}
69+
select {
70+
case <-ctx.Done():
71+
t.Fatal("context canceled waiting for Canton balance")
72+
case <-ticker.C:
73+
}
74+
}
75+
t.Fatalf("timeout waiting for Canton balance: party=%s admin=%s id=%s min=%s last=%s",
76+
partyID, admin, id, minAmount, lastBalance)
77+
}
78+
79+
// WaitForRelayerTransfer polls until the relayer has a completed transfer
80+
// matching sourceTxHash, or the 120s timeout is reached.
81+
func (d *DSL) WaitForRelayerTransfer(ctx context.Context, t *testing.T, sourceTxHash string) {
82+
t.Helper()
83+
if d.relayer == nil {
84+
t.Fatal("WaitForRelayerTransfer not available: Relayer shim not initialized (use NewFullStack)")
85+
return
86+
}
87+
deadline := time.Now().Add(relayerTransferTimeout)
88+
ticker := time.NewTicker(pollInterval)
89+
defer ticker.Stop()
90+
var lastStatus relayer.TransferStatus
91+
for time.Now().Before(deadline) {
92+
transfers, err := d.relayer.ListTransfers(ctx)
93+
if err == nil {
94+
for _, tr := range transfers {
95+
if strings.EqualFold(tr.SourceTxHash, sourceTxHash) {
96+
lastStatus = tr.Status
97+
if tr.Status == relayer.TransferStatusCompleted {
98+
return
99+
}
100+
}
101+
}
102+
}
103+
select {
104+
case <-ctx.Done():
105+
t.Fatal("context canceled waiting for relayer transfer")
106+
case <-ticker.C:
107+
}
108+
}
109+
t.Fatalf("timeout waiting for relayer transfer: sourceTxHash=%s lastStatus=%s", sourceTxHash, lastStatus)
110+
}
111+
112+
// WaitForIndexerEvent polls until the indexer has an event with the given
113+
// contractID, or the 60s timeout is reached.
114+
func (d *DSL) WaitForIndexerEvent(ctx context.Context, t *testing.T, contractID string) *indexer.ParsedEvent {
115+
t.Helper()
116+
if d.indexer == nil {
117+
t.Fatal("WaitForIndexerEvent not available: Indexer shim not initialized (use NewFullStack)")
118+
return nil // unreachable; t.Fatal calls runtime.Goexit
119+
}
120+
deadline := time.Now().Add(indexerEventTimeout)
121+
ticker := time.NewTicker(pollInterval)
122+
defer ticker.Stop()
123+
var lastErr error
124+
for time.Now().Before(deadline) {
125+
ev, err := d.indexer.GetEvent(ctx, contractID)
126+
if err == nil && ev != nil {
127+
return ev
128+
}
129+
lastErr = err
130+
select {
131+
case <-ctx.Done():
132+
t.Fatalf("context canceled waiting for indexer event: contractID=%s", contractID)
133+
case <-ticker.C:
134+
}
135+
}
136+
t.Fatalf("timeout waiting for indexer event: contractID=%s lastErr=%v", contractID, lastErr)
137+
return nil // unreachable; t.Fatalf calls runtime.Goexit
138+
}
139+
140+
// amountGTE returns true when amount >= min, comparing both as decimal numbers.
141+
// String comparison is intentionally avoided: "20" > "100" lexicographically.
142+
func amountGTE(amount, min string) bool {
143+
a, ok1 := new(big.Float).SetString(amount)
144+
m, ok2 := new(big.Float).SetString(min)
145+
if !ok1 || !ok2 {
146+
return false
147+
}
148+
return a.Cmp(m) >= 0
149+
}

0 commit comments

Comments
 (0)