Skip to content

Commit 7cc430c

Browse files
feat(load): token transfer load tests (#617)
* feat(load): token transfer load tests * fix(e2e): fix token finding * fix(load): fix load tests token transfer * refactor(load): simplify token_lane * make gomodtidy * chore(load): remove verbose debug * fix go.mod * fix error
1 parent e2bade9 commit 7cc430c

16 files changed

Lines changed: 854 additions & 96 deletions

.github/workflows/ccip-load-tests.yml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,8 @@ on:
1111
options:
1212
- canton2evm
1313
- evm2canton
14+
- canton2evm-token
15+
- evm2canton-token
1416
message_rate:
1517
description: 'CANTON_LOAD_MESSAGE_RATE (e.g. 1/1s, 1/20s)'
1618
required: false
@@ -65,6 +67,8 @@ jobs:
6567
case "${{ inputs.direction }}" in
6668
canton2evm) TEST_RUN='^TestCanton2EVM_Load$' ;;
6769
evm2canton) TEST_RUN='^TestEVM2Canton_Load$' ;;
70+
canton2evm-token) TEST_RUN='^TestCanton2EVM_TokenLoad$' ;;
71+
evm2canton-token) TEST_RUN='^TestEVM2Canton_TokenLoad$' ;;
6872
*) echo "unknown direction: ${{ inputs.direction }}" >&2; exit 1 ;;
6973
esac
7074
go test -timeout ${{ inputs.test_timeout }} -v -count 1 -run "$TEST_RUN"

Makefile

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -108,6 +108,14 @@ run-canton2evm-load: ## Canton→EVM WASP load (requires running devenv + env-ca
108108
run-evm2canton-load: ## EVM→Canton WASP load (requires running devenv + env-canton-evm-out.toml).
109109
cd ccip/devenv/tests/load && go test -timeout 15m -v -count 1 -run '^TestEVM2Canton_Load$$'
110110

111+
.PHONY: run-canton2evm-token-load
112+
run-canton2evm-token-load: ## Canton→EVM token WASP load (requires running devenv + env-canton-evm-out.toml).
113+
cd ccip/devenv/tests/load && go test -timeout 20m -v -count 1 -run '^TestCanton2EVM_TokenLoad$$'
114+
115+
.PHONY: run-evm2canton-token-load
116+
run-evm2canton-token-load: ## EVM→Canton token WASP load (requires running devenv + env-canton-evm-out.toml).
117+
cd ccip/devenv/tests/load && go test -timeout 20m -v -count 1 -run '^TestEVM2Canton_TokenLoad$$'
118+
111119
.PHONY: build-run-e2e-tests
112120
build-run-e2e-tests: start-devenv run-e2e-tests
113121

ccip/devenv/README.md

Lines changed: 71 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,20 @@ cd ccip/devenv/tests/load && go test -timeout 15m -v -count 1 -run '^TestCanton2
6464

6565
If the out file is missing the test skips with a hint.
6666

67+
### Canton → EVM token load (requires devenv)
68+
69+
Separate test from message-only load: `TestCanton2EVM_TokenLoad`. Resolves the token lane declared in [`token_transfer_config.toml`](./tests/token_transfer_config.toml) (see [Token lane configuration](#token-lane-configuration)) against the source chain's `GetTokenTransferConfigs`, validating every destination has the lane. Pre-mints Canton fee + transfer holdings, runs WASP, then asserts EVM receiver token balance delta.
70+
71+
```bash
72+
make run-canton2evm-token-load
73+
```
74+
75+
Equivalent:
76+
77+
```bash
78+
cd ccip/devenv/tests/load && go test -timeout 20m -v -count 1 -run '^TestCanton2EVM_TokenLoad$'
79+
```
80+
6781
### EVM → Canton load (requires devenv)
6882

6983
Sequential EVM→Canton messages against the Canton destination in the env file. Uses the same schedule env vars as Canton→EVM (`CANTON_LOAD_MESSAGE_RATE`, `CANTON_LOAD_DURATION`). EVM accounts are pre-funded by devenv; no Canton pre-mint.
@@ -85,15 +99,69 @@ Equivalent:
8599
cd ccip/devenv/tests/load && go test -timeout 15m -v -count 1 -run '^TestEVM2Canton_Load$'
86100
```
87101

102+
### EVM → Canton token load (requires devenv)
103+
104+
Separate test from message-only load: `TestEVM2Canton_TokenLoad`. Resolves the token lane declared in [`token_transfer_config.toml`](./tests/token_transfer_config.toml) (see [Token lane configuration](#token-lane-configuration)), logs EVM sender balance vs estimated transfer need (devenv pre-funds sender), runs WASP, logs Canton holdings post-run.
105+
106+
```bash
107+
make run-evm2canton-token-load
108+
```
109+
110+
Equivalent:
111+
112+
```bash
113+
cd ccip/devenv/tests/load && go test -timeout 20m -v -count 1 -run '^TestEVM2Canton_TokenLoad$'
114+
```
115+
116+
### Token lane configuration
117+
118+
Token transfer tests (both e2e and load) declare the token lane to send in [`tests/token_transfer_config.toml`](./tests/token_transfer_config.toml). The test resolves the declared **token pool identity** against the source chain's `GetTokenTransferConfigs`, then validates that every destination chain has that lane configured before running. The committed file holds the devenv defaults.
119+
120+
Override the file path with `CANTON_TOKEN_TEST_CONFIG`:
121+
122+
```bash
123+
CANTON_TOKEN_TEST_CONFIG=/path/to/custom.toml make run-canton2evm-token-load
124+
```
125+
126+
The file has one block per direction (`[evm_to_canton]`, `[canton_to_evm]`):
127+
128+
| Key | Required | Meaning |
129+
|---|---|---|
130+
| `pool_type` | yes | token pool contract type on the source chain (e.g. `BurnMintTokenPool`, `LockReleaseTokenPool`) |
131+
| `pool_version` | yes | semantic version of the token pool (e.g. `2.0.0`) |
132+
| `pool_qualifier` | yes | datastore qualifier identifying the exact pool |
133+
| `transfer_amount` | no | per-message token amount (string integer); falls back to a per-direction default |
134+
| `execution_gas_limit` | no | per-message execution gas limit; falls back to a per-direction default |
135+
| `finality_config` | no | per-message finality config; falls back to a per-direction default |
136+
137+
Token **identity** (`pool_type` / `pool_version` / `pool_qualifier`) is always required and is never defaulted; only the numeric send params have code-level fallbacks. If the qualifier matches zero or multiple pools, or a destination lacks the lane, the test fails fast listing the available pool refs / selectors.
138+
139+
```toml
140+
[evm_to_canton]
141+
pool_type = "BurnMintTokenPool"
142+
pool_version = "2.0.0"
143+
pool_qualifier = "TEST (BurnMintTokenPool 2.0.0 [default], LockReleaseTokenPool 2.0.0 [default])::BurnMintTokenPool 2.0.0 [default]"
144+
transfer_amount = "100000000000"
145+
execution_gas_limit = 200000
146+
finality_config = 0
147+
148+
[canton_to_evm]
149+
pool_type = "LockReleaseTokenPool"
150+
pool_version = "2.0.0"
151+
pool_qualifier = "TEST (BurnMintTokenPool 2.0.0 [default], LockReleaseTokenPool 2.0.0 [default])::LockReleaseTokenPool 2.0.0 [default]"
152+
transfer_amount = "1000"
153+
execution_gas_limit = 500000
154+
finality_config = 1
155+
```
156+
88157
### CI (on demand)
89158

90159
The **CCIP Canton Load Tests** workflow (`ccip-load-tests.yml`) can be triggered manually from GitHub Actions
91-
(`workflow_dispatch`). It reuses the same devenv setup as the CCIP E2E workflow and runs either
92-
`TestCanton2EVM_Load` or `TestEVM2Canton_Load` depending on the `direction` input. Inputs:
160+
(`workflow_dispatch`). It reuses the same devenv setup as the CCIP E2E workflow and runs one of the load tests depending on the `direction` input. Inputs:
93161

94162
| Input | Default | Maps to |
95163
|---|---|---|
96-
| `direction` | `canton2evm` | `canton2evm``TestCanton2EVM_Load`; `evm2canton``TestEVM2Canton_Load` |
164+
| `direction` | `canton2evm` | `canton2evm``TestCanton2EVM_Load`; `evm2canton``TestEVM2Canton_Load`; `canton2evm-token``TestCanton2EVM_TokenLoad`; `evm2canton-token``TestEVM2Canton_TokenLoad` |
97165
| `message_rate` | `1/1s` | `CANTON_LOAD_MESSAGE_RATE` |
98166
| `load_duration` | `90s` | `CANTON_LOAD_DURATION` |
99167
| `test_timeout` | `40m` | `go test -timeout` |

ccip/devenv/impl.go

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1078,6 +1078,12 @@ func (c *Chain) SendMessage(ctx context.Context, dest uint64, fields cciptestint
10781078
}
10791079
}
10801080

1081+
sendLog := c.logger.Info().Str("NextFeeCID", c.nextFeeCID)
1082+
if hasTokenTransfer {
1083+
sendLog = sendLog.Str("NextTransferCID", c.nextTransferCID)
1084+
}
1085+
sendLog.Bool("HasTokenTransfer", hasTokenTransfer).Msg("Sending CCIP message with holdings")
1086+
10811087
feeFactoryChoice, err := testhelpers.GetTransferFactoryV2(
10821088
ctx,
10831089
c.validatorAPIClients.transferClient,
@@ -1433,10 +1439,19 @@ func parseFirstCCIPMessageSentFromLedgerEvents(events []*apiv2.Event, previousSe
14331439
func (c *Chain) setNextHoldings(events []*apiv2.Event, hasTokenTransfer bool, tokenAmount *big.Int) error {
14341440
party := c.chain.Participants[0].PartyID
14351441

1442+
previousFeeCID := c.nextFeeCID
1443+
previousTransferCID := c.nextTransferCID
1444+
c.logger.Info().
1445+
Str("PreviousNextFeeCID", previousFeeCID).
1446+
Str("PreviousNextTransferCID", previousTransferCID).
1447+
Bool("HasTokenTransfer", hasTokenTransfer).
1448+
Msg("Refreshing next holdings from send update")
1449+
14361450
spentCIDs := []string{c.nextFeeCID}
14371451
if hasTokenTransfer && c.nextTransferCID != "" {
14381452
spentCIDs = append(spentCIDs, c.nextTransferCID)
14391453
}
1454+
c.logger.Debug().Strs("SpentCIDs", spentCIDs).Msg("Holding rotation spent CIDs")
14401455
refreshFilters := []testhelpers.Filter{
14411456
testhelpers.WithHoldingOwner(party),
14421457
testhelpers.WithUnlockedHoldingsOnly(),
@@ -1487,6 +1502,13 @@ func (c *Chain) setNextHoldings(events []*apiv2.Event, hasTokenTransfer bool, to
14871502
}
14881503
c.nextTransferCID = pickedTransfer[0].ContractID
14891504
c.logger.Info().Str("NextTransferCID", c.nextTransferCID).Msg("Selected next transfer holding")
1505+
if payload, err := json.MarshalIndent(freshTransferHoldings, "", " "); err != nil {
1506+
c.logger.Warn().Err(err).Msg("marshal freshTransferHoldings for log")
1507+
} else {
1508+
c.logger.Debug().
1509+
RawJSON("freshTransferHoldings", payload).
1510+
Msg("Fresh transfer holdings parsed from send update")
1511+
}
14901512

14911513
return nil
14921514
}

ccip/devenv/tests/constants.go

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
package tests
2+
3+
// Shared devenv test constants (message and token paths).
4+
5+
const (
6+
// CantonToEVMFeeAmount is the Canton CCIP send fee in Amulet units.
7+
CantonToEVMFeeAmount int64 = 2_000
8+
9+
// EVMDecimalsScale converts Canton token amounts to EVM 18-decimal balance units.
10+
EVMDecimalsScale int64 = 1_000_000_000_000_000_000
11+
12+
// CantonToEVMTokenSequentialSends is how many token transfers the Canton→EVM e2e subtest sends.
13+
CantonToEVMTokenSequentialSends = 2
14+
)

ccip/devenv/tests/e2e/canton2evm_e2e_test.go

Lines changed: 21 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,6 @@ import (
66
"testing"
77
"time"
88

9-
"github.com/Masterminds/semver/v3"
10-
gethcommon "github.com/ethereum/go-ethereum/common"
119
ccv "github.com/smartcontractkit/chainlink-ccv/build/devenv"
1210
"github.com/smartcontractkit/chainlink-ccv/build/devenv/cciptestinterfaces"
1311
devenvcommon "github.com/smartcontractkit/chainlink-ccv/build/devenv/common"
@@ -26,13 +24,6 @@ import (
2624
"github.com/smartcontractkit/chainlink-canton/deployment/operations/ccip/executor"
2725
)
2826

29-
const (
30-
cantonToEVMFeeAmount = int64(2_000)
31-
cantonToEVMTokenTransferAmount = int64(1_000) // 1000 tokens (1000 * 10^10) on canton decimals
32-
evmDecimalsScale = int64(1_000_000_000_000_000_000) // EVM 18 decimals
33-
cantonToEVMTokenSequentialSends = 2
34-
)
35-
3627
//nolint:paralleltest // we won't run this in parallel.
3728
func TestCanton2EVM_Basic(t *testing.T) {
3829
if testing.Short() {
@@ -65,8 +56,8 @@ func TestCanton2EVM_Basic(t *testing.T) {
6556
subtestCtx := ccv.Plog.WithContext(t.Context())
6657

6758
// Setup message send
68-
require.NoError(t, cantonImpl.MintTokens(ctx, uint64(cantonToEVMFeeAmount)))
69-
require.NoError(t, cantonImpl.SetupSend(ctx, uint64(cantonToEVMFeeAmount), 0))
59+
require.NoError(t, cantonImpl.MintTokens(ctx, uint64(devenvtests.CantonToEVMFeeAmount)))
60+
require.NoError(t, cantonImpl.SetupSend(ctx, uint64(devenvtests.CantonToEVMFeeAmount), 0))
7061

7162
ds, err := lib.DataStore()
7263
require.NoError(t, err)
@@ -150,10 +141,18 @@ func TestCanton2EVM_Basic(t *testing.T) {
150141
t.Run("EOA receiver and default committee verifier token transfer", func(t *testing.T) {
151142
subtestCtx := ccv.Plog.WithContext(t.Context())
152143

144+
// Send params (transfer amount, gas limit, finality) come from token_transfer_config.toml.
145+
lane := devenvtests.ResolveTokenLane(t, in, lib, chainMap, cantonChain.ChainSelector(), []uint64{evmChain.ChainSelector()})
146+
tokenTransferAmount := lane.TransferAmount.Uint64()
147+
153148
// Setup message send
154-
require.NoError(t, cantonImpl.MintTokens(ctx, cantonToEVMTokenSequentialSends*uint64(cantonToEVMFeeAmount))) // Holdings for fee
155-
require.NoError(t, cantonImpl.MintTokens(ctx, cantonToEVMTokenSequentialSends*uint64(cantonToEVMTokenTransferAmount))) // Holdings for token transfer
156-
require.NoError(t, cantonImpl.SetupSend(ctx, uint64(cantonToEVMFeeAmount), uint64(cantonToEVMTokenTransferAmount))) // Setup with fee and token transfer amounts
149+
require.NoError(t, cantonImpl.MintTokens(ctx,
150+
devenvtests.CantonToEVMTokenSequentialSends*uint64(devenvtests.CantonToEVMFeeAmount),
151+
)) // Holdings for fee
152+
require.NoError(t, cantonImpl.MintTokens(ctx,
153+
devenvtests.CantonToEVMTokenSequentialSends*tokenTransferAmount,
154+
)) // Holdings for token transfer
155+
require.NoError(t, cantonImpl.SetupSend(ctx, uint64(devenvtests.CantonToEVMFeeAmount), tokenTransferAmount))
157156

158157
ds, err := lib.DataStore()
159158
require.NoError(t, err)
@@ -173,35 +172,27 @@ func TestCanton2EVM_Basic(t *testing.T) {
173172
devenvcommon.DefaultExecutorQualifier,
174173
"source executor",
175174
)
176-
destTokenRef, err := ds.Addresses().Get(
177-
datastore.NewAddressRefKey(
178-
evmChain.ChainSelector(),
179-
datastore.ContractType("BurnMintERC20WithDripToken"),
180-
semver.MustParse("1.0.0"),
181-
burnMint20ToLockRelease20TokenQualifier(t),
182-
),
183-
)
184175
require.NoError(t, err)
185-
destTokenAddress := protocol.UnknownAddress(gethcommon.HexToAddress(destTokenRef.Address).Bytes())
176+
destTokenAddress := lane.DestTokenBySelector[evmChain.ChainSelector()]
186177
receiverBalanceBefore, err := evmChain.GetTokenBalance(subtestCtx, receiver, destTokenAddress)
187178
require.NoError(t, err)
188179
require.NotNil(t, receiverBalanceBefore)
189180

190-
for sendIdx := range cantonToEVMTokenSequentialSends {
191-
t.Logf("Token transfer send %d/%d", sendIdx+1, cantonToEVMTokenSequentialSends)
181+
for sendIdx := range devenvtests.CantonToEVMTokenSequentialSends {
182+
t.Logf("Token transfer send %d/%d", sendIdx+1, devenvtests.CantonToEVMTokenSequentialSends)
192183
sendMessageResult, err := cantonChain.SendMessage(
193184
subtestCtx,
194185
evmChain.ChainSelector(),
195186
cciptestinterfaces.MessageFields{
196187
Receiver: receiver,
197188
Data: []byte("canton2evm token transfer"),
198189
TokenAmount: cciptestinterfaces.TokenAmount{
199-
Amount: big.NewInt(cantonToEVMTokenTransferAmount),
190+
Amount: lane.TransferAmount,
200191
},
201192
},
202193
cciptestinterfaces.MessageOptions{
203-
ExecutionGasLimit: 500_000,
204-
FinalityConfig: 1,
194+
ExecutionGasLimit: lane.ExecutionGasLimit,
195+
FinalityConfig: lane.FinalityConfig,
205196
Executor: executorAddr,
206197
CCVs: []protocol.CCV{
207198
{
@@ -232,8 +223,8 @@ func TestCanton2EVM_Basic(t *testing.T) {
232223
require.NoError(t, err)
233224
require.NotNil(t, receiverBalanceAfter)
234225

235-
expectedTransferPerMessage := new(big.Int).Mul(big.NewInt(cantonToEVMTokenTransferAmount), big.NewInt(evmDecimalsScale))
236-
totalExpectedTransfer := new(big.Int).Mul(expectedTransferPerMessage, big.NewInt(cantonToEVMTokenSequentialSends))
226+
expectedTransferPerMessage := new(big.Int).Mul(lane.TransferAmount, big.NewInt(devenvtests.EVMDecimalsScale))
227+
totalExpectedTransfer := new(big.Int).Mul(expectedTransferPerMessage, big.NewInt(devenvtests.CantonToEVMTokenSequentialSends))
237228
expectedReceiverBalanceAfter := new(big.Int).Add(new(big.Int).Set(receiverBalanceBefore), totalExpectedTransfer)
238229
t.Logf("EVM receiver token balance: before=%s after=%s totalExpectedTransfer=%s", receiverBalanceBefore.String(), receiverBalanceAfter.String(), totalExpectedTransfer.String())
239230
require.Equal(t, expectedReceiverBalanceAfter, receiverBalanceAfter)

ccip/devenv/tests/e2e/evm2canton_e2e_test.go

Lines changed: 6 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,6 @@ import (
66
"testing"
77
"time"
88

9-
"github.com/Masterminds/semver/v3"
10-
gethcommon "github.com/ethereum/go-ethereum/common"
119
"github.com/smartcontractkit/chainlink-ccip/chains/evm/deployment/v2_0_0/operations/proxy"
1210
"github.com/smartcontractkit/chainlink-ccip/chains/evm/deployment/v2_0_0/sequences"
1311
"github.com/smartcontractkit/chainlink-ccip/chains/evm/deployment/v2_0_0/versioned_verifier_resolver"
@@ -29,11 +27,6 @@ import (
2927
"github.com/smartcontractkit/chainlink-canton/testhelpers"
3028
)
3129

32-
const (
33-
// 1e11 (10-decimal units) gives a stable non-dust transfer in this lane after fee handling.
34-
evmToCantonTransferAmount = int64(100_000_000_000)
35-
)
36-
3730
//nolint:paralleltest // we won't run this in parallel.
3831
func TestEVM2Canton_Basic(t *testing.T) {
3932
if testing.Short() {
@@ -134,16 +127,9 @@ func TestEVM2Canton_Basic(t *testing.T) {
134127
t.Run("token transfer", func(t *testing.T) {
135128
subtestCtx := ccv.Plog.WithContext(t.Context())
136129

137-
tokenRef, err := ds.Addresses().Get(
138-
datastore.NewAddressRefKey(
139-
srcSelector,
140-
datastore.ContractType("BurnMintERC20WithDripToken"),
141-
semver.MustParse("1.0.0"),
142-
burnMint20ToLockRelease20TokenQualifier(t),
143-
),
144-
)
145-
require.NoError(t, err)
146-
srcToken := protocol.UnknownAddress(gethcommon.HexToAddress(tokenRef.Address).Bytes())
130+
// Send params (transfer amount, gas limit, finality) come from token_transfer_config.toml.
131+
lane := devenvtests.ResolveTokenLane(t, in, lib, chainMap, srcSelector, []uint64{dstSelector})
132+
srcToken := lane.SrcToken
147133
srcSender, err := srcChain.GetEOAReceiverAddress()
148134
require.NoError(t, err)
149135
seqNo, err := srcChain.GetExpectedNextSequenceNumber(subtestCtx, dstSelector)
@@ -152,12 +138,12 @@ func TestEVM2Canton_Basic(t *testing.T) {
152138
Receiver: receiver,
153139
Data: []byte("Hello token transfer from EVM!"),
154140
TokenAmount: cciptestinterfaces.TokenAmount{
155-
Amount: big.NewInt(evmToCantonTransferAmount),
141+
Amount: lane.TransferAmount,
156142
TokenAddress: srcToken,
157143
},
158144
}, cciptestinterfaces.MessageOptions{
159-
ExecutionGasLimit: 200_000,
160-
FinalityConfig: 0,
145+
ExecutionGasLimit: lane.ExecutionGasLimit,
146+
FinalityConfig: lane.FinalityConfig,
161147
Executor: executorAddress,
162148
CCVs: []protocol.CCV{
163149
{

ccip/devenv/tests/e2e/token_qualifiers_test.go

Lines changed: 0 additions & 26 deletions
This file was deleted.

0 commit comments

Comments
 (0)