From 6e06d2a9353b8a960c88870a1f00317cf756f06d Mon Sep 17 00:00:00 2001 From: Rodrigo Date: Mon, 6 Jul 2026 11:10:46 -0300 Subject: [PATCH 01/10] feat(load): Add load metrics --- ccip/devenv/impl.go | 6 +- ccip/devenv/tests/load/gun.go | 170 ++++++++++++--- ccip/devenv/tests/load/gun_canton2evm_test.go | 7 +- .../tests/load/gun_canton2evm_token_test.go | 7 +- ccip/devenv/tests/load/gun_evm2canton_test.go | 7 +- .../tests/load/gun_evm2canton_token_test.go | 7 +- ccip/devenv/tests/load/load_helpers.go | 116 ++++++++++- ccip/devenv/tests/load/metrics.go | 193 ++++++++++++++++++ 8 files changed, 467 insertions(+), 46 deletions(-) create mode 100644 ccip/devenv/tests/load/metrics.go diff --git a/ccip/devenv/impl.go b/ccip/devenv/impl.go index 612b1abcf..560ccea07 100644 --- a/ccip/devenv/impl.go +++ b/ccip/devenv/impl.go @@ -1400,7 +1400,6 @@ func (c *Chain) SendMessage(ctx context.Context, dest uint64, fields cciptestint if err != nil { return cciptestinterfaces.MessageSentEvent{}, fmt.Errorf("execute CCIP Send: %w", err) } - c.logger.Info().Str("UpdateID", ccipSendReport.Output.ExecInfo.UpdateID).Msg("CCIP Send executed") update, err := participant.LedgerServices.Update.GetUpdateById(ctx, &apiv2.GetUpdateByIdRequest{ UpdateId: ccipSendReport.Output.ExecInfo.UpdateID, UpdateFormat: &apiv2.UpdateFormat{ @@ -1443,6 +1442,11 @@ func (c *Chain) SendMessage(ctx context.Context, dest uint64, fields cciptestint if err != nil { return cciptestinterfaces.MessageSentEvent{}, err } + c.logger.Info(). + Str("UpdateID", ccipSendReport.Output.ExecInfo.UpdateID). + Str("messageID", protocol.Bytes32(parsedSend.messageID).String()). + Uint64("seqNo", parsedSend.seqNo). + Msg("CCIP Send executed") // Set next holdings err = c.setNextHoldings(update.GetTransaction().GetEvents(), hasTokenTransfer, fields.TokenAmount.Amount) diff --git a/ccip/devenv/tests/load/gun.go b/ccip/devenv/tests/load/gun.go index afddfb8df..a975d4a49 100644 --- a/ccip/devenv/tests/load/gun.go +++ b/ccip/devenv/tests/load/gun.go @@ -1,13 +1,14 @@ package load import ( + "context" "fmt" "sync" "sync/atomic" + "testing" "time" - "github.com/stretchr/testify/require" - + ccv "github.com/smartcontractkit/chainlink-ccv/build/devenv" "github.com/smartcontractkit/chainlink-ccv/build/devenv/cciptestinterfaces" "github.com/smartcontractkit/chainlink-ccv/protocol" "github.com/smartcontractkit/chainlink-testing-framework/wasp" @@ -15,6 +16,23 @@ import ( devenvtests "github.com/smartcontractkit/chainlink-canton/ccip/devenv/tests" ) +// ConfirmSendFunc confirms a CCIP send on the source chain after SendMessage returns. +// TODO: this is needed because EVM impls method has a bug on prod-testnet checks. +type ConfirmSendFunc func( + t *testing.T, + ctx context.Context, + destSelector uint64, + seqNo uint64, + sendResult cciptestinterfaces.MessageSentEvent, +) (cciptestinterfaces.MessageSentEvent, error) + +// LoadGunOptions configures send confirmation and exec timeout for CCIPLoadGun. +type LoadGunOptions struct { + ConfirmSend ConfirmSendFunc + ConfirmExecTimeout time.Duration + SkipExecConfirm bool +} + type loadMessageBuilder func( source cciptestinterfaces.CCIP17, callNum int64, @@ -52,9 +70,11 @@ func (d Destination) BuildMessage( // required; staging/prod runners rely on pre-existing funded accounts. type CCIPLoadGun struct { mu sync.Mutex + flightReady sync.Cond inFlight int32 maxConcurrent int32 calls atomic.Int64 + messageIDs []protocol.Bytes32 source cciptestinterfaces.CCIP17 destinations []Destination @@ -63,8 +83,11 @@ type CCIPLoadGun struct { ccvAddr protocol.UnknownAddress executorAddr protocol.UnknownAddress - confirmSendTimeout time.Duration + confirmSend ConfirmSendFunc confirmExecTimeout time.Duration + skipExecConfirm bool + + metricsCollector *LoadMetricsCollector } // NewCCIPLoadGun wires a CCIP source with one or more destinations for load testing. @@ -72,11 +95,14 @@ func NewCCIPLoadGun( source cciptestinterfaces.CCIP17, destinations []Destination, ccvAddr, executorAddr protocol.UnknownAddress, - confirmExecTimeout time.Duration, + opts LoadGunOptions, ) (*CCIPLoadGun, error) { if source == nil { return nil, fmt.Errorf("CCIPLoadGun: source is nil") } + if opts.ConfirmSend == nil { + return nil, fmt.Errorf("CCIPLoadGun: ConfirmSend is nil") + } if len(destinations) == 0 { return nil, fmt.Errorf("CCIPLoadGun: at least one destination is required") } @@ -95,18 +121,50 @@ func NewCCIPLoadGun( return nil, fmt.Errorf("CCIPLoadGun: destination[%d] mixes token and message-only destinations", i) } } + confirmExecTimeout := opts.ConfirmExecTimeout if confirmExecTimeout <= 0 { confirmExecTimeout = 5 * time.Minute } - return &CCIPLoadGun{ + g := &CCIPLoadGun{ source: source, destinations: destinations, ccvAddr: ccvAddr, executorAddr: executorAddr, - confirmSendTimeout: 30 * time.Second, + confirmSend: opts.ConfirmSend, confirmExecTimeout: confirmExecTimeout, - }, nil + skipExecConfirm: opts.SkipExecConfirm, + metricsCollector: &LoadMetricsCollector{}, + } + g.flightReady.L = &g.mu + + return g, nil +} + +func (g *CCIPLoadGun) acquireSingleFlight() { + g.mu.Lock() + for g.inFlight >= 1 { + g.flightReady.Wait() + } + g.inFlight++ + if g.inFlight > g.maxConcurrent { + g.maxConcurrent = g.inFlight + } + g.mu.Unlock() +} + +func (g *CCIPLoadGun) releaseSingleFlight() { + g.mu.Lock() + g.inFlight-- + if g.inFlight == 0 { + g.flightReady.Broadcast() + } + g.mu.Unlock() +} + +// ConfirmExecTimeout returns the exec confirmation timeout configured for this gun. +func (g *CCIPLoadGun) ConfirmExecTimeout() time.Duration { + return g.confirmExecTimeout } func (g *CCIPLoadGun) nextDestination() Destination { @@ -124,26 +182,10 @@ func (g *CCIPLoadGun) Call(gen *wasp.Generator) *wasp.Response { } t := gen.Cfg.T - var depth int32 - g.mu.Lock() - g.inFlight++ - depth = g.inFlight - if depth > g.maxConcurrent { - g.maxConcurrent = depth - } - g.mu.Unlock() + g.acquireSingleFlight() + defer g.releaseSingleFlight() g.calls.Add(1) - defer func() { - g.mu.Lock() - g.inFlight-- - g.mu.Unlock() - }() - - if depth > 1 { - require.FailNow(t, "overlapping CCIPLoadGun.Call", - "expected single-flight; concurrent depth=%d", depth) - } dest := g.nextDestination() destSelector := dest.Chain.ChainSelector() @@ -151,9 +193,11 @@ func (g *CCIPLoadGun) Call(gen *wasp.Generator) *wasp.Response { callNum := g.calls.Load() fields, opts, err := dest.BuildMessage(g.source, callNum, g.ccvAddr, g.executorAddr) if err != nil { + g.metricsCollector.incrementSendFailure() return &wasp.Response{Failed: true, Error: fmt.Sprintf("BuildMessage (dest=%d): %v", destSelector, err), Duration: time.Since(start)} } + sentTime := time.Now() sendRes, err := g.source.SendMessage( subtestCtx, destSelector, @@ -161,34 +205,70 @@ func (g *CCIPLoadGun) Call(gen *wasp.Generator) *wasp.Response { opts, 3, ) + sendDuration := time.Since(sentTime) if err != nil { + g.metricsCollector.incrementSendFailure() return &wasp.Response{Failed: true, Error: fmt.Sprintf("SendMessage (dest=%d): %v", destSelector, err), Duration: time.Since(start)} } if sendRes.Message == nil { + g.metricsCollector.incrementSendFailure() return &wasp.Response{Failed: true, Error: "SendMessage returned nil message", Duration: time.Since(start)} } seqNo := uint64(sendRes.Message.SequenceNumber) - sentEvent, err := g.source.ConfirmSendOnSource( - subtestCtx, - destSelector, - cciptestinterfaces.MessageEventKey{SeqNum: seqNo}, - g.confirmSendTimeout, - ) + confirmSendStart := time.Now() + sentEvent, err := g.confirmSend(t, subtestCtx, destSelector, seqNo, sendRes) + confirmSendDuration := time.Since(confirmSendStart) if err != nil { - return &wasp.Response{Failed: true, Error: fmt.Sprintf("ConfirmSendOnSource (dest=%d): %v", destSelector, err), Duration: time.Since(start)} + g.metricsCollector.incrementConfirmSendFailure() + return &wasp.Response{Failed: true, Error: fmt.Sprintf("ConfirmSend (dest=%d): %v", destSelector, err), Duration: time.Since(start)} } + g.mu.Lock() + g.messageIDs = append(g.messageIDs, sentEvent.MessageID) + g.mu.Unlock() + + ccv.Plog.Info(). + Str("messageID", sentEvent.MessageID.String()). + Uint64("seqNo", seqNo). + Uint64("destSelector", destSelector). + Msg("Load message confirmed on source") + + sourceChain := g.source.ChainSelector() + record := LoadMessageRecord{ + SeqNo: seqNo, + SourceChain: sourceChain, + DestChain: destSelector, + MessageID: sentEvent.MessageID, + SentTime: sentTime, + SendDuration: sendDuration, + ConfirmSendDuration: confirmSendDuration, + } + + if g.skipExecConfirm { + record.TotalDuration = time.Since(sentTime) + g.metricsCollector.appendRecord(record) + return &wasp.Response{ + Failed: false, + StatusCode: "200", + Duration: time.Since(start), + } + } + + confirmExecStart := time.Now() ev, err := dest.Chain.ConfirmExecOnDest( subtestCtx, g.source.ChainSelector(), cciptestinterfaces.MessageEventKey{SeqNum: seqNo, MessageID: sentEvent.MessageID}, g.confirmExecTimeout, ) + confirmExecDuration := time.Since(confirmExecStart) if err != nil { + g.metricsCollector.incrementConfirmExecFailure() return &wasp.Response{Failed: true, Error: fmt.Sprintf("ConfirmExecOnDest (dest=%d): %v", destSelector, err), Duration: time.Since(start)} } if ev.State != cciptestinterfaces.ExecutionStateSuccess { + g.metricsCollector.incrementConfirmExecFailure() return &wasp.Response{ Failed: true, Error: fmt.Sprintf("execution state=%s (dest=%d)", ev.State.String(), destSelector), @@ -197,6 +277,11 @@ func (g *CCIPLoadGun) Call(gen *wasp.Generator) *wasp.Response { } } + record.ConfirmExecDuration = confirmExecDuration + record.ExecutedTime = time.Now() + record.TotalDuration = record.ExecutedTime.Sub(sentTime) + g.metricsCollector.appendRecord(record) + return &wasp.Response{ Failed: false, StatusCode: "200", @@ -215,3 +300,24 @@ func (g *CCIPLoadGun) MaxConcurrentObserved() int32 { func (g *CCIPLoadGun) CallCount() int64 { return g.calls.Load() } + +// Metrics returns a copy of successful load message timing records. +func (g *CCIPLoadGun) Metrics() []LoadMessageRecord { + records, _ := g.metricsCollector.snapshot() + return records +} + +// FailureCounts returns phase failure counters from failed WASP calls. +func (g *CCIPLoadGun) FailureCounts() LoadFailureCounts { + _, failures := g.metricsCollector.snapshot() + return failures +} + +// MessageIDs returns a copy of message IDs collected after successful ConfirmSend. +func (g *CCIPLoadGun) MessageIDs() []protocol.Bytes32 { + g.mu.Lock() + defer g.mu.Unlock() + out := make([]protocol.Bytes32, len(g.messageIDs)) + copy(out, g.messageIDs) + return out +} diff --git a/ccip/devenv/tests/load/gun_canton2evm_test.go b/ccip/devenv/tests/load/gun_canton2evm_test.go index 0233a7b49..5fa890a58 100644 --- a/ccip/devenv/tests/load/gun_canton2evm_test.go +++ b/ccip/devenv/tests/load/gun_canton2evm_test.go @@ -90,9 +90,12 @@ func TestCanton2EVM_Load(t *testing.T) { destinations, ccvAddr, executorAddr, - utilstests.WaitTimeout(t), + LoadGunOptions{ + ConfirmSend: cantonSourceConfirmSend(cantonChain), + ConfirmExecTimeout: utilstests.WaitTimeout(t), + }, ) require.NoError(t, err) - runWASP(t, gun, "canton-load-canton2evm", sched, "message_only") + runWASP(t, gun, "canton-load-canton2evm", sched, "message_only", nil) } diff --git a/ccip/devenv/tests/load/gun_canton2evm_token_test.go b/ccip/devenv/tests/load/gun_canton2evm_token_test.go index df3a80bc9..cb53d4a26 100644 --- a/ccip/devenv/tests/load/gun_canton2evm_token_test.go +++ b/ccip/devenv/tests/load/gun_canton2evm_token_test.go @@ -78,11 +78,14 @@ func TestCanton2EVM_TokenLoad(t *testing.T) { destinations, ccvAddr, executorAddr, - utilstests.WaitTimeout(t), + LoadGunOptions{ + ConfirmSend: cantonSourceConfirmSend(cantonChain), + ConfirmExecTimeout: utilstests.WaitTimeout(t), + }, ) require.NoError(t, err) - runWASP(t, gun, "canton-load-canton2evm-token", sched, "token_transfer") + runWASP(t, gun, "canton-load-canton2evm-token", sched, "token_transfer", nil) receiverBalanceAfter, err := firstDest.Chain.GetTokenBalance(ctx, firstDest.Receiver, destToken) require.NoError(t, err) diff --git a/ccip/devenv/tests/load/gun_evm2canton_test.go b/ccip/devenv/tests/load/gun_evm2canton_test.go index 034633ae5..86fa2fb4f 100644 --- a/ccip/devenv/tests/load/gun_evm2canton_test.go +++ b/ccip/devenv/tests/load/gun_evm2canton_test.go @@ -66,9 +66,12 @@ func TestEVM2Canton_Load(t *testing.T) { []Destination{cantonDest}, ccvAddr, executorAddr, - utilstests.WaitTimeout(t), + LoadGunOptions{ + ConfirmSend: evmSourceConfirmSend(evmChain), // TODO: this confirmation will change on prod-testnet PR + ConfirmExecTimeout: utilstests.WaitTimeout(t), + }, ) require.NoError(t, err) - runWASP(t, gun, "canton-load-evm2canton", loadSchedule(t), "message_only") + runWASP(t, gun, "canton-load-evm2canton", loadSchedule(t), "message_only", nil) } diff --git a/ccip/devenv/tests/load/gun_evm2canton_token_test.go b/ccip/devenv/tests/load/gun_evm2canton_token_test.go index 426e29fed..cd2fc0cc1 100644 --- a/ccip/devenv/tests/load/gun_evm2canton_token_test.go +++ b/ccip/devenv/tests/load/gun_evm2canton_token_test.go @@ -91,11 +91,14 @@ func TestEVM2Canton_TokenLoad(t *testing.T) { []Destination{cantonDest}, ccvAddr, executorAddr, - utilstests.WaitTimeout(t), + LoadGunOptions{ + ConfirmSend: evmSourceConfirmSend(evmChain), // TODO: this confirmation will change on prod-testnet PR + ConfirmExecTimeout: utilstests.WaitTimeout(t), + }, ) require.NoError(t, err) - runWASP(t, gun, "canton-load-evm2canton-token", sched, "token_transfer") + runWASP(t, gun, "canton-load-evm2canton-token", sched, "token_transfer", nil) totalHoldingsRat, err := testhelpers.GetHoldingsBalance(ctx, receiverParticipant, nil) require.NoError(t, err) diff --git a/ccip/devenv/tests/load/load_helpers.go b/ccip/devenv/tests/load/load_helpers.go index 4bf0b1399..6343a2710 100644 --- a/ccip/devenv/tests/load/load_helpers.go +++ b/ccip/devenv/tests/load/load_helpers.go @@ -5,6 +5,7 @@ import ( "fmt" "math/big" "os" + "strings" "testing" "time" @@ -16,6 +17,7 @@ import ( "github.com/smartcontractkit/chainlink-ccv/build/devenv/cciptestinterfaces" "github.com/smartcontractkit/chainlink-ccv/build/devenv/common" ccvload "github.com/smartcontractkit/chainlink-ccv/build/devenv/tests/e2e/load" + ccvmetrics "github.com/smartcontractkit/chainlink-ccv/build/devenv/tests/e2e/metrics" "github.com/smartcontractkit/chainlink-ccv/protocol" "github.com/smartcontractkit/chainlink-deployments-framework/datastore" "github.com/smartcontractkit/chainlink-testing-framework/framework/components/blockchain" @@ -29,10 +31,13 @@ import ( ) const ( - envMessageRate = "CANTON_LOAD_MESSAGE_RATE" - envLoadDuration = "CANTON_LOAD_DURATION" - defaultMessageRate = "1/1s" - defaultLoadDuration = 90 * time.Second + envMessageRate = "CANTON_LOAD_MESSAGE_RATE" + envLoadDuration = "CANTON_LOAD_DURATION" + envLoadCallTimeout = "CANTON_LOAD_CALL_TIMEOUT" + defaultMessageRate = "1/1s" + defaultLoadDuration = 90 * time.Second + defaultLoadCallPadding = 2 * time.Minute + confirmSendTimeout = 30 * time.Second ) type scheduleConfig struct { @@ -70,9 +75,71 @@ func loadSchedule(t *testing.T) scheduleConfig { } } -func runWASP(t *testing.T, gun *CCIPLoadGun, genName string, sched scheduleConfig, scenario string) { +func waspCallTimeout(t *testing.T, gun *CCIPLoadGun, sched scheduleConfig) time.Duration { t.Helper() + if v := strings.TrimSpace(os.Getenv(envLoadCallTimeout)); v != "" { + parsed, err := time.ParseDuration(v) + require.NoError(t, err, "%s=%q invalid", envLoadCallTimeout, v) + return parsed + } + + return gun.ConfirmExecTimeout() + sched.rateUnit + defaultLoadCallPadding +} + +func printLoadMetrics(t *testing.T, gun *CCIPLoadGun) { + t.Helper() + + records := gun.Metrics() + failures := gun.FailureCounts() + PrintPhaseMetricsSummary(t, records, failures, false) + + ccvMetrics := ToCCVMessageMetrics(records) + if len(ccvMetrics) > 0 { + totals := ccvmetrics.MessageTotals{ + Sent: len(ccvMetrics), + Received: len(ccvMetrics), + } + summary := ccvmetrics.CalculateMetricsSummary(ccvMetrics, totals) + ccvmetrics.PrintMetricsSummary(t, summary) + } +} + +func logLoadMessageSummary(t *testing.T, gun *CCIPLoadGun, indexerEndpoints []string) { + t.Helper() + + ids := gun.MessageIDs() + lggr := ccv.Plog + lggr.Info().Int("count", len(ids)).Msg("Load message summary") + + var indexerBase string + if len(indexerEndpoints) > 0 { + indexerBase = strings.TrimSuffix(indexerEndpoints[0], "/") + } + + for i, id := range ids { + msgID := id.String() + ev := lggr.Info().Int("index", i+1).Str("messageID", msgID) + if indexerBase != "" { + ev = ev.Str("indexer", fmt.Sprintf("%s/v1/verifierresults/%s", indexerBase, msgID)) + } + ev.Msg("Load message sent") + } +} + +func runWASP(t *testing.T, gun *CCIPLoadGun, genName string, sched scheduleConfig, scenario string, indexerEndpoints []string) { + t.Helper() + defer logLoadMessageSummary(t, gun, indexerEndpoints) + + callTimeout := waspCallTimeout(t, gun, sched) + ccv.Plog.Info(). + Str("messageRate", sched.messageRate). + Dur("rateUnit", sched.rateUnit). + Dur("loadDuration", sched.duration). + Dur("callTimeout", callTimeout). + Dur("confirmExecTimeout", gun.ConfirmExecTimeout()). + Msg("WASP load schedule") + labels := map[string]string{ "go_test_name": genName, "branch": "test", @@ -92,6 +159,7 @@ func runWASP(t *testing.T, gun *CCIPLoadGun, genName string, sched scheduleConfi wasp.Plain(sched.rate, sched.duration), ), RateLimitUnitDuration: sched.rateUnit, + CallTimeout: callTimeout, Gun: gun, Labels: labels, LokiConfig: nil, @@ -104,6 +172,8 @@ func runWASP(t *testing.T, gun *CCIPLoadGun, genName string, sched scheduleConfi require.Positive(t, gun.CallCount(), "gun should have completed at least one message") require.LessOrEqual(t, gun.MaxConcurrentObserved(), int32(1), "Gun.Call must not overlap (single-flight)") + + printLoadMetrics(t, gun) } func discoverEVMDestinations(t *testing.T, in *ccv.Cfg, chainMap map[uint64]cciptestinterfaces.CCIP17) []Destination { @@ -370,3 +440,39 @@ func resolveEVMSourceAddrs(t *testing.T, lib ccv.Lib, evmSelector uint64) (proto return ccvAddr, executorAddr } + +func cantonSourceConfirmSend(source cciptestinterfaces.CCIP17) ConfirmSendFunc { + return func( + t *testing.T, + ctx context.Context, + destSelector uint64, + seqNo uint64, + _ cciptestinterfaces.MessageSentEvent, + ) (cciptestinterfaces.MessageSentEvent, error) { + return source.ConfirmSendOnSource( + ctx, + destSelector, + cciptestinterfaces.MessageEventKey{SeqNum: seqNo}, + confirmSendTimeout, + ) + } +} + +// TODO: this is needed because EVM impls method has a bug on prod-testnet checks. +// currently just a simple wrapper around the method. +func evmSourceConfirmSend(source cciptestinterfaces.CCIP17) ConfirmSendFunc { + return func( + t *testing.T, + ctx context.Context, + destSelector uint64, + seqNo uint64, + _ cciptestinterfaces.MessageSentEvent, + ) (cciptestinterfaces.MessageSentEvent, error) { + return source.ConfirmSendOnSource( + ctx, + destSelector, + cciptestinterfaces.MessageEventKey{SeqNum: seqNo}, + confirmSendTimeout, + ) + } +} diff --git a/ccip/devenv/tests/load/metrics.go b/ccip/devenv/tests/load/metrics.go new file mode 100644 index 000000000..4d405a0e9 --- /dev/null +++ b/ccip/devenv/tests/load/metrics.go @@ -0,0 +1,193 @@ +package load + +import ( + "slices" + "sync" + "testing" + "time" + + ccvmetrics "github.com/smartcontractkit/chainlink-ccv/build/devenv/tests/e2e/metrics" + "github.com/smartcontractkit/chainlink-ccv/protocol" +) + +// LoadMessageRecord captures per-message phase timings for a successful load test call. +type LoadMessageRecord struct { + SeqNo uint64 + SourceChain uint64 + DestChain uint64 + MessageID protocol.Bytes32 + SentTime time.Time + ExecutedTime time.Time + SendDuration time.Duration + ConfirmSendDuration time.Duration + ConfirmExecDuration time.Duration + TotalDuration time.Duration +} + +// LoadFailureCounts tracks failures by load test phase. +type LoadFailureCounts struct { + Send int + ConfirmSend int + ConfirmExec int +} + +// LoadMetricsCollector accumulates successful message records and phase failure counts. +type LoadMetricsCollector struct { + mu sync.Mutex + records []LoadMessageRecord + failures LoadFailureCounts +} + +func (c *LoadMetricsCollector) appendRecord(r LoadMessageRecord) { + c.mu.Lock() + defer c.mu.Unlock() + c.records = append(c.records, r) +} + +func (c *LoadMetricsCollector) incrementSendFailure() { + c.mu.Lock() + defer c.mu.Unlock() + c.failures.Send++ +} + +func (c *LoadMetricsCollector) incrementConfirmSendFailure() { + c.mu.Lock() + defer c.mu.Unlock() + c.failures.ConfirmSend++ +} + +func (c *LoadMetricsCollector) incrementConfirmExecFailure() { + c.mu.Lock() + defer c.mu.Unlock() + c.failures.ConfirmExec++ +} + +func (c *LoadMetricsCollector) snapshot() ([]LoadMessageRecord, LoadFailureCounts) { + c.mu.Lock() + defer c.mu.Unlock() + out := make([]LoadMessageRecord, len(c.records)) + copy(out, c.records) + return out, c.failures +} + +// PercentileStats holds common percentile values for a set of durations. +type PercentileStats struct { + Min time.Duration + Max time.Duration + P90 time.Duration + P95 time.Duration + P99 time.Duration +} + +func calculatePercentiles(durations []time.Duration) PercentileStats { + if len(durations) == 0 { + return PercentileStats{} + } + + slices.Sort(durations) + + p90Index := int(float64(len(durations)) * 0.90) + p95Index := int(float64(len(durations)) * 0.95) + p99Index := int(float64(len(durations)) * 0.99) + + if p90Index >= len(durations) { + p90Index = len(durations) - 1 + } + if p95Index >= len(durations) { + p95Index = len(durations) - 1 + } + if p99Index >= len(durations) { + p99Index = len(durations) - 1 + } + + return PercentileStats{ + Min: durations[0], + Max: durations[len(durations)-1], + P90: durations[p90Index], + P95: durations[p95Index], + P99: durations[p99Index], + } +} + +func logPhasePercentiles(t *testing.T, label string, stats PercentileStats, count int) { + t.Helper() + t.Logf("%s (n=%d):\n"+ + " Min: %v\n"+ + " Max: %v\n"+ + " P90: %v\n"+ + " P95: %v\n"+ + " P99: %v", + label, count, + stats.Min, stats.Max, stats.P90, stats.P95, stats.P99, + ) +} + +// PrintPhaseMetricsSummary prints per-phase timing percentiles for successful load messages. +func PrintPhaseMetricsSummary(t *testing.T, records []LoadMessageRecord, failures LoadFailureCounts, skipExecConfirm bool) { + t.Helper() + + t.Logf("\n" + + "========================================\n" + + " Load Phase Metrics \n" + + "========================================") + + if len(records) == 0 { + t.Logf("No successful load messages recorded") + } else { + sendDurations := make([]time.Duration, len(records)) + confirmSendDurations := make([]time.Duration, len(records)) + for i, r := range records { + sendDurations[i] = r.SendDuration + confirmSendDurations[i] = r.ConfirmSendDuration + } + + logPhasePercentiles(t, "Send", calculatePercentiles(sendDurations), len(records)) + logPhasePercentiles(t, "Confirm Send", calculatePercentiles(confirmSendDurations), len(records)) + + if skipExecConfirm { + sourceConfirmed := make([]time.Duration, len(records)) + for i, r := range records { + sourceConfirmed[i] = r.SendDuration + r.ConfirmSendDuration + } + logPhasePercentiles(t, "Source Confirmed (Send → Confirm Send)", calculatePercentiles(sourceConfirmed), len(records)) + } else { + confirmExecDurations := make([]time.Duration, len(records)) + totalDurations := make([]time.Duration, len(records)) + for i, r := range records { + confirmExecDurations[i] = r.ConfirmExecDuration + totalDurations[i] = r.TotalDuration + } + logPhasePercentiles(t, "Confirm Exec", calculatePercentiles(confirmExecDurations), len(records)) + logPhasePercentiles(t, "Total (E2E)", calculatePercentiles(totalDurations), len(records)) + } + } + + totalFailures := failures.Send + failures.ConfirmSend + failures.ConfirmExec + if totalFailures > 0 { + t.Logf("----------------------------------------\n"+ + "Failures: send=%d confirm_send=%d confirm_exec=%d", + failures.Send, failures.ConfirmSend, failures.ConfirmExec) + } + + t.Logf("========================================") +} + +// ToCCVMessageMetrics maps load records with ExecutedTime set to CCV message metrics. +func ToCCVMessageMetrics(records []LoadMessageRecord) []ccvmetrics.MessageMetrics { + out := make([]ccvmetrics.MessageMetrics, 0, len(records)) + for _, r := range records { + if r.ExecutedTime.IsZero() { + continue + } + out = append(out, ccvmetrics.MessageMetrics{ + SeqNo: r.SeqNo, + MessageID: r.MessageID.String(), + SourceChain: r.SourceChain, + DestChain: r.DestChain, + SentTime: r.SentTime, + ExecutedTime: r.ExecutedTime, + LatencyDuration: r.ExecutedTime.Sub(r.SentTime), + }) + } + return out +} From c9cfe0365781fb4d01ebd964716743868c4f7a8e Mon Sep 17 00:00:00 2001 From: Rodrigo Date: Tue, 7 Jul 2026 12:04:37 -0300 Subject: [PATCH 02/10] fix(lint) --- ccip/devenv/tests/load/gun.go | 1 + ccip/devenv/tests/load/gun_canton2evm_test.go | 2 +- .../tests/load/gun_canton2evm_token_test.go | 2 +- ccip/devenv/tests/load/gun_evm2canton_test.go | 2 +- .../tests/load/gun_evm2canton_token_test.go | 2 +- ccip/devenv/tests/load/load_helpers.go | 18 ++++-------------- ccip/devenv/tests/load/metrics.go | 2 ++ 7 files changed, 11 insertions(+), 18 deletions(-) diff --git a/ccip/devenv/tests/load/gun.go b/ccip/devenv/tests/load/gun.go index a975d4a49..9f9e94dcb 100644 --- a/ccip/devenv/tests/load/gun.go +++ b/ccip/devenv/tests/load/gun.go @@ -319,5 +319,6 @@ func (g *CCIPLoadGun) MessageIDs() []protocol.Bytes32 { defer g.mu.Unlock() out := make([]protocol.Bytes32, len(g.messageIDs)) copy(out, g.messageIDs) + return out } diff --git a/ccip/devenv/tests/load/gun_canton2evm_test.go b/ccip/devenv/tests/load/gun_canton2evm_test.go index 5fa890a58..29060312c 100644 --- a/ccip/devenv/tests/load/gun_canton2evm_test.go +++ b/ccip/devenv/tests/load/gun_canton2evm_test.go @@ -97,5 +97,5 @@ func TestCanton2EVM_Load(t *testing.T) { ) require.NoError(t, err) - runWASP(t, gun, "canton-load-canton2evm", sched, "message_only", nil) + runWASP(t, gun, "canton-load-canton2evm", sched, "message_only") } diff --git a/ccip/devenv/tests/load/gun_canton2evm_token_test.go b/ccip/devenv/tests/load/gun_canton2evm_token_test.go index cb53d4a26..291426a30 100644 --- a/ccip/devenv/tests/load/gun_canton2evm_token_test.go +++ b/ccip/devenv/tests/load/gun_canton2evm_token_test.go @@ -85,7 +85,7 @@ func TestCanton2EVM_TokenLoad(t *testing.T) { ) require.NoError(t, err) - runWASP(t, gun, "canton-load-canton2evm-token", sched, "token_transfer", nil) + runWASP(t, gun, "canton-load-canton2evm-token", sched, "token_transfer") receiverBalanceAfter, err := firstDest.Chain.GetTokenBalance(ctx, firstDest.Receiver, destToken) require.NoError(t, err) diff --git a/ccip/devenv/tests/load/gun_evm2canton_test.go b/ccip/devenv/tests/load/gun_evm2canton_test.go index 86fa2fb4f..b33555040 100644 --- a/ccip/devenv/tests/load/gun_evm2canton_test.go +++ b/ccip/devenv/tests/load/gun_evm2canton_test.go @@ -73,5 +73,5 @@ func TestEVM2Canton_Load(t *testing.T) { ) require.NoError(t, err) - runWASP(t, gun, "canton-load-evm2canton", loadSchedule(t), "message_only", nil) + runWASP(t, gun, "canton-load-evm2canton", loadSchedule(t), "message_only") } diff --git a/ccip/devenv/tests/load/gun_evm2canton_token_test.go b/ccip/devenv/tests/load/gun_evm2canton_token_test.go index cd2fc0cc1..4ad00269d 100644 --- a/ccip/devenv/tests/load/gun_evm2canton_token_test.go +++ b/ccip/devenv/tests/load/gun_evm2canton_token_test.go @@ -98,7 +98,7 @@ func TestEVM2Canton_TokenLoad(t *testing.T) { ) require.NoError(t, err) - runWASP(t, gun, "canton-load-evm2canton-token", sched, "token_transfer", nil) + runWASP(t, gun, "canton-load-evm2canton-token", sched, "token_transfer") totalHoldingsRat, err := testhelpers.GetHoldingsBalance(ctx, receiverParticipant, nil) require.NoError(t, err) diff --git a/ccip/devenv/tests/load/load_helpers.go b/ccip/devenv/tests/load/load_helpers.go index 6343a2710..0ba7e3daa 100644 --- a/ccip/devenv/tests/load/load_helpers.go +++ b/ccip/devenv/tests/load/load_helpers.go @@ -105,31 +105,21 @@ func printLoadMetrics(t *testing.T, gun *CCIPLoadGun) { } } -func logLoadMessageSummary(t *testing.T, gun *CCIPLoadGun, indexerEndpoints []string) { +func logLoadMessageSummary(t *testing.T, gun *CCIPLoadGun) { t.Helper() ids := gun.MessageIDs() lggr := ccv.Plog lggr.Info().Int("count", len(ids)).Msg("Load message summary") - var indexerBase string - if len(indexerEndpoints) > 0 { - indexerBase = strings.TrimSuffix(indexerEndpoints[0], "/") - } - for i, id := range ids { - msgID := id.String() - ev := lggr.Info().Int("index", i+1).Str("messageID", msgID) - if indexerBase != "" { - ev = ev.Str("indexer", fmt.Sprintf("%s/v1/verifierresults/%s", indexerBase, msgID)) - } - ev.Msg("Load message sent") + lggr.Info().Int("index", i+1).Str("messageID", id.String()).Msg("Load message sent") } } -func runWASP(t *testing.T, gun *CCIPLoadGun, genName string, sched scheduleConfig, scenario string, indexerEndpoints []string) { +func runWASP(t *testing.T, gun *CCIPLoadGun, genName string, sched scheduleConfig, scenario string) { t.Helper() - defer logLoadMessageSummary(t, gun, indexerEndpoints) + defer logLoadMessageSummary(t, gun) callTimeout := waspCallTimeout(t, gun, sched) ccv.Plog.Info(). diff --git a/ccip/devenv/tests/load/metrics.go b/ccip/devenv/tests/load/metrics.go index 4d405a0e9..38e0d7b80 100644 --- a/ccip/devenv/tests/load/metrics.go +++ b/ccip/devenv/tests/load/metrics.go @@ -67,6 +67,7 @@ func (c *LoadMetricsCollector) snapshot() ([]LoadMessageRecord, LoadFailureCount defer c.mu.Unlock() out := make([]LoadMessageRecord, len(c.records)) copy(out, c.records) + return out, c.failures } @@ -189,5 +190,6 @@ func ToCCVMessageMetrics(records []LoadMessageRecord) []ccvmetrics.MessageMetric LatencyDuration: r.ExecutedTime.Sub(r.SentTime), }) } + return out } From 0ea19d2c45b9694946c1a8441b9f7a49f9899cac Mon Sep 17 00:00:00 2001 From: Rodrigo Date: Tue, 7 Jul 2026 16:03:59 -0300 Subject: [PATCH 03/10] fix(e2e): Fix tests holding minting --- ccip/devenv/tests/e2e/canton2evm_e2e_test.go | 16 +++++----------- 1 file changed, 5 insertions(+), 11 deletions(-) diff --git a/ccip/devenv/tests/e2e/canton2evm_e2e_test.go b/ccip/devenv/tests/e2e/canton2evm_e2e_test.go index 28e2e1a3b..ca7550055 100644 --- a/ccip/devenv/tests/e2e/canton2evm_e2e_test.go +++ b/ccip/devenv/tests/e2e/canton2evm_e2e_test.go @@ -52,11 +52,15 @@ func TestCanton2EVM_Basic(t *testing.T) { require.NoError(t, err) }) + // TODO: currently minting 2 holdings of 2k for all the e2e tests. Otherwise one holding might conflict with the other. + // the tests need to be hardened to not rely on this and instead correctly pick the holding that suffices. + require.NoError(t, cantonImpl.MintTokens(ctx, uint64(devenvtests.CantonToEVMFeeAmount)*100)) + require.NoError(t, cantonImpl.MintTokens(ctx, uint64(devenvtests.CantonToEVMFeeAmount)*100)) + t.Run("EOA receiver and default committee verifier", func(t *testing.T) { subtestCtx := ccv.Plog.WithContext(t.Context()) // Setup message send - require.NoError(t, cantonImpl.MintTokens(ctx, uint64(devenvtests.CantonToEVMFeeAmount))) require.NoError(t, cantonImpl.SetupSend(ctx, uint64(devenvtests.CantonToEVMFeeAmount), 0)) ds, err := lib.DataStore() @@ -146,12 +150,6 @@ func TestCanton2EVM_Basic(t *testing.T) { tokenTransferAmount := lane.TransferAmount.Uint64() // Setup message send - require.NoError(t, cantonImpl.MintTokens(ctx, - devenvtests.CantonToEVMTokenSequentialSends*uint64(devenvtests.CantonToEVMFeeAmount), - )) // Holdings for fee - require.NoError(t, cantonImpl.MintTokens(ctx, - devenvtests.CantonToEVMTokenSequentialSends*tokenTransferAmount, - )) // Holdings for token transfer require.NoError(t, cantonImpl.SetupSend(ctx, uint64(devenvtests.CantonToEVMFeeAmount), tokenTransferAmount)) ds, err := lib.DataStore() @@ -237,8 +235,6 @@ func TestCanton2EVM_Basic(t *testing.T) { lane := devenvtests.ResolveTokenLane(t, in, lib, chainMap, cantonChain.ChainSelector(), []uint64{evmChain.ChainSelector()}) tokenTransferAmount := lane.TransferAmount.Uint64() - require.NoError(t, cantonImpl.MintTokens(ctx, uint64(devenvtests.CantonToEVMFeeAmount))) - require.NoError(t, cantonImpl.MintTokens(ctx, tokenTransferAmount)) require.NoError(t, cantonImpl.SetupSend(ctx, uint64(devenvtests.CantonToEVMFeeAmount), tokenTransferAmount)) receiver, err := evmChain.GetEOAReceiverAddress() @@ -281,8 +277,6 @@ func TestCanton2EVM_Basic(t *testing.T) { lane := devenvtests.ResolveTokenLane(t, in, lib, chainMap, cantonChain.ChainSelector(), []uint64{evmChain.ChainSelector()}) tokenTransferAmount := lane.TransferAmount.Uint64() - require.NoError(t, cantonImpl.MintTokens(ctx, uint64(devenvtests.CantonToEVMFeeAmount))) - require.NoError(t, cantonImpl.MintTokens(ctx, tokenTransferAmount)) require.NoError(t, cantonImpl.SetupSend(ctx, uint64(devenvtests.CantonToEVMFeeAmount), tokenTransferAmount)) ds, err := lib.DataStore() From 19f7888a28200a7c1848f733ee7e71cd43915487 Mon Sep 17 00:00:00 2001 From: Rodrigo Date: Tue, 7 Jul 2026 17:15:47 -0300 Subject: [PATCH 04/10] feat(e2e): Token Lane Refactor --- ccip/devenv/constants.go | 26 ++ ccip/devenv/impl.go | 80 +++-- ccip/devenv/manual_execution.go | 58 ++++ ccip/devenv/tests/constants.go | 14 - ccip/devenv/tests/e2e/canton2evm_e2e_test.go | 68 +++-- .../e2e/canton2evm_send_validation_test.go | 12 +- ccip/devenv/tests/e2e/evm2canton_e2e_test.go | 2 +- ccip/devenv/tests/env.go | 33 +++ ccip/devenv/tests/load/gun_canton2evm_test.go | 9 +- .../tests/load/gun_canton2evm_token_test.go | 4 +- .../tests/load/gun_evm2canton_token_test.go | 2 +- ccip/devenv/tests/load/load_helpers.go | 25 +- ccip/devenv/tests/token_lane.go | 273 ++++++++++++++---- ccip/devenv/tests/token_lane_test.go | 185 ++++++++++++ ccip/devenv/tests/token_transfer_config.toml | 51 +++- 15 files changed, 670 insertions(+), 172 deletions(-) create mode 100644 ccip/devenv/constants.go delete mode 100644 ccip/devenv/tests/constants.go create mode 100644 ccip/devenv/tests/env.go create mode 100644 ccip/devenv/tests/token_lane_test.go diff --git a/ccip/devenv/constants.go b/ccip/devenv/constants.go new file mode 100644 index 000000000..c0234b457 --- /dev/null +++ b/ccip/devenv/constants.go @@ -0,0 +1,26 @@ +package devenv + +import "github.com/smartcontractkit/chainlink-ccv/protocol" + +// Shared devenv test constants (message and token paths). + +const ( + // EVMToCantonFinalityConfig is the minimum block-depth FTF (1 confirmation). + EVMToCantonFinalityConfig = protocol.Finality(1) + + // CantonToEVMFeeAmount is the per-message CCIP fee budget in Amulet units for + // message-only sends (200k gas). Kept low for prod-testnet compatibility. + CantonToEVMFeeAmount int64 = 50 + + // CantonToEVMTokenTransferFeeAmount is the per-message fee budget for token transfers + // (500k execution gas), which quote ~127 Amulet per send in devenv. Used only by + // token e2e/load tests; must cover one send and leave enough change for sequential sends. + CantonToEVMTokenTransferFeeAmount int64 = 130 + + // Canton token amounts use 10-decimal fixed point (e.g. 1_000_000_000 = 0.1). + CantonFixedPointScale int64 = 10_000_000_000 + CantonFixedPointToEVMScale int64 = 100_000_000 // fixedPoint * this = EVM wei + + // CantonToEVMTokenSequentialSends is how many token transfers the Canton→EVM e2e subtest sends. + CantonToEVMTokenSequentialSends = 2 +) diff --git a/ccip/devenv/impl.go b/ccip/devenv/impl.go index 2bd2fba58..39d9fd3de 100644 --- a/ccip/devenv/impl.go +++ b/ccip/devenv/impl.go @@ -70,8 +70,9 @@ import ( ) // TODO: move this to share between devenv and integration tests -// and define a const for LINK instrument const AMTInstrument = types.TEXT("Amulet") +const LINKInstrument = types.TEXT("LINK") + const amuletTransferPreapprovalTemplateID = "#splice-amulet:Splice.AmuletRules:TransferPreapproval" var _ cciptestinterfaces.CCIP17 = &Chain{} @@ -551,9 +552,10 @@ func (c *Chain) GetTokenExpansionConfigs( "instrument-id:Amulet", ), }, - PoolType: string(poolRef.Type), - TokenPoolQualifier: poolRef.Qualifier, - AllowedFinalityConfig: finality.Config{WaitForFinality: true}, + PoolType: string(poolRef.Type), + TokenPoolQualifier: poolRef.Qualifier, + // BlockDepth 1: minimum FTF allowed by pool; messages may request finality>=1 via extra args. + AllowedFinalityConfig: finality.Config{BlockDepth: 1}, }, }}, nil } @@ -761,8 +763,9 @@ func (c *Chain) GetTokenTransferConfigs( Type: datastore.ContractType(token_admin_registry.ContractType), Version: token_admin_registry.Version, }, - RemoteChains: remoteChains, - AllowedFinalityConfig: finality.Config{WaitForFinality: true}, + RemoteChains: remoteChains, + // BlockDepth 1: pool must allow message FTF; WaitForFinality-only rejects BlockDepth requests. + AllowedFinalityConfig: finality.Config{BlockDepth: 1}, }}, nil } @@ -1013,39 +1016,28 @@ func (c *Chain) SetupReceive(ctx context.Context) error { // SetupSend sets up Canton sender specific prerequisites for sending a message. // eg: deploy per-party router, deploy ccipsender contract... +// transferInstrument defaults to Amulet under registryAdmin when omitted or Admin is empty. func (c *Chain) SetupSend( ctx context.Context, feeAmountPerMessage uint64, - transferAmountPerMessage uint64, + transferAmountPerMessage *big.Rat, + transferInstrument ...splice_api_token_holding_v1.InstrumentId, ) error { - participant, clientIdx, err := c.ClientParticipant() + participant, _, err := c.ClientParticipant() if err != nil { return fmt.Errorf("no canton participants configured: %w", err) } party := participant.PartyID - // Deploy contracts // - // Router for sender party. routerAddress, err := c.DeployPerPartyRouter(ctx, participant, party) if err != nil { return fmt.Errorf("failed to deploy per-party router: %w", err) } - // Deploy a sender-owned CCIPSender contract. - senderInstanceID := contracts.MustNewInstanceID("devenv-ccipsender") - out, err := operations.ExecuteOperation(c.e.OperationsBundle, sender.Deploy, c.chain, contract.DeployInput[ccipsender.CCIPSender]{ - Qualifier: nil, - ParticipantIndex: clientIdx, - Template: ccipsender.CCIPSender{ - InstanceId: types.TEXT(senderInstanceID), - Owner: types.PARTY(party), - }, - OwnerParty: types.PARTY(party), - }) + senderAddress, err := c.DeployCCIPSender(ctx, participant, party) if err != nil { return fmt.Errorf("failed to deploy ccip sender contract: %w", err) } - senderAddress := contracts.HexToInstanceAddress(out.Output.Address) registryAdmin, err := testhelpers.ResolveRegistryAdmin(ctx, participant) if err != nil { return fmt.Errorf("resolve registry admin: %w", err) @@ -1054,14 +1046,11 @@ func (c *Chain) SetupSend( c.routerAddress = routerAddress c.senderAddress = senderAddress - // Clients setup // _, err = c.getValidatorAPIClients() if err != nil { return fmt.Errorf("get validator API clients: %w", err) } - // Tokens setup // - // Setup fee holding feeTokenInstrument := splice_api_token_holding_v1.InstrumentId{ Admin: types.PARTY(registryAdmin), Id: AMTInstrument, @@ -1085,25 +1074,24 @@ func (c *Chain) SetupSend( c.feeTokenInstrument = feeTokenInstrument c.nextFeeCID = selectedFee[0].ContractID - // End setup, no transfer holdings needed - if transferAmountPerMessage == 0 { + if transferAmountPerMessage == nil || transferAmountPerMessage.Sign() <= 0 { return nil } - // Setup transfer holdings - // TODO: add support for LINK instrument transferTokenInstrument := &splice_api_token_holding_v1.InstrumentId{ Admin: types.PARTY(registryAdmin), Id: AMTInstrument, } + if len(transferInstrument) > 0 && transferInstrument[0].Admin != "" { + transferTokenInstrument = &transferInstrument[0] + } - // Select transfer holding using another call here because fee/token might use different instruments - filters := append(baseFilters, testhelpers.ExcludeCIDs([]string{c.nextFeeCID})) // this filter is here in case fee and transfer use the same instrument + filters := append(baseFilters, testhelpers.ExcludeCIDs([]string{c.nextFeeCID})) transferRows, err := testhelpers.ListHoldingsForInstrument(ctx, participant, transferTokenInstrument, filters...) if err != nil { return fmt.Errorf("list transfer holdings for setup: %w", err) } - transferMin := new(big.Rat).SetUint64(transferAmountPerMessage) + transferMin := new(big.Rat).Set(transferAmountPerMessage) selectedTransfer, err := testhelpers.SelectHoldingsForInstrument(transferRows, []*big.Rat{transferMin}) if err != nil || len(selectedTransfer) == 0 { return fmt.Errorf("select transfer holding for setup: %w", err) @@ -1117,8 +1105,11 @@ func (c *Chain) SetupSend( // MintTokens mint tokens for transfer and fees. To be used on devenv tests only. // this method won't work in staging/prod tests -// TODO: add support for LINK instrument -func (c *Chain) MintTokens(ctx context.Context, amount uint64) error { +func (c *Chain) MintTokens(ctx context.Context, amount *big.Rat) error { + if amount == nil || amount.Sign() <= 0 { + return nil + } + participant, _, err := c.ClientParticipant() if err != nil { return fmt.Errorf("no canton participants configured: %w", err) @@ -1137,7 +1128,7 @@ func (c *Chain) MintTokens(ctx context.Context, amount uint64) error { validatorAPIClients.transferClient, validatorAPIClients.scanClient, party, - strconv.FormatUint(amount, 10), + amount.FloatString(10), ) if err != nil { return fmt.Errorf("failed to mint tokens: %w", err) @@ -1166,7 +1157,8 @@ func (c *Chain) SendMessage(ctx context.Context, dest uint64, fields cciptestint return cciptestinterfaces.MessageSentEvent{}, fmt.Errorf("canton SendMessage: token transfer amount must be positive") } - hasTokenTransfer := c.transferTokenInstrument != nil && fields.TokenAmount.Amount != nil && fields.TokenAmount.Amount.Sign() > 0 + hasTokenTransfer := fields.TokenAmount.Amount != nil && + fields.TokenAmount.Amount.Sign() > 0 participant, clientIdx, err := c.ClientParticipant() if err != nil { @@ -1250,9 +1242,8 @@ func (c *Chain) SendMessage(ctx context.Context, dest uint64, fields cciptestint Receiver: hex.EncodeToString(fields.Receiver), } if hasTokenTransfer { - // TODO: move this to the other line also branching by tokenTransfer outgoingMessage.TokenTransfer = &oapiCommon.TokenTransfer{ - Amount: fields.TokenAmount.Amount.String(), + Amount: new(big.Rat).SetFrac(fields.TokenAmount.Amount, big.NewInt(CantonFixedPointScale)).FloatString(10), Token: oapiCommon.InstrumentId{ Admin: oapiCommon.PartyId(c.transferTokenInstrument.Admin), Id: string(c.transferTokenInstrument.Id), @@ -1300,8 +1291,7 @@ func (c *Chain) SendMessage(ctx context.Context, dest uint64, fields cciptestint // Token Pool var tokenPoolRequiredCCVs []string if hasTokenTransfer { - // Get Token Pool - token := contracts.EncodeInstrumentID(c.feeTokenInstrument) + token := contracts.EncodeInstrumentID(*c.transferTokenInstrument) tokenPoolAddress, err := c.GetTokenPoolForToken(ctx, token) if err != nil { return cciptestinterfaces.MessageSentEvent{}, fmt.Errorf("get token pool for token %s: %w", token.String(), err) @@ -1451,7 +1441,11 @@ func (c *Chain) SendMessage(ctx context.Context, dest uint64, fields cciptestint Msg("CCIP Send executed") // Set next holdings - err = c.setNextHoldings(update.GetTransaction().GetEvents(), hasTokenTransfer, fields.TokenAmount.Amount) + var tokenAmount *big.Rat + if hasTokenTransfer { + tokenAmount = new(big.Rat).SetFrac(fields.TokenAmount.Amount, big.NewInt(CantonFixedPointScale)) + } + err = c.setNextHoldings(update.GetTransaction().GetEvents(), hasTokenTransfer, tokenAmount) if err != nil { return cciptestinterfaces.MessageSentEvent{}, fmt.Errorf("set next holdings: %w", err) } @@ -1570,7 +1564,7 @@ func parseFirstCCIPMessageSentFromLedgerEvents(events []*apiv2.Event, previousSe // Created events from the send transaction. When no qualifying holding appears in those // events, the corresponding next CID is cleared (empty means no next holding available); // the current send already succeeded and a future SendMessage will fail its holding checks. -func (c *Chain) setNextHoldings(events []*apiv2.Event, hasTokenTransfer bool, tokenAmount *big.Int) error { +func (c *Chain) setNextHoldings(events []*apiv2.Event, hasTokenTransfer bool, tokenAmount *big.Rat) error { participant, _, err := c.ClientParticipant() if err != nil { return fmt.Errorf("no canton participants configured: %w", err) @@ -1621,7 +1615,7 @@ func (c *Chain) setNextHoldings(events []*apiv2.Event, hasTokenTransfer bool, to slices.Clone(refreshFilters), testhelpers.ExcludeCIDs(append(spentCIDs, c.nextFeeCID)), ) - transferValue := new(big.Rat).SetInt(tokenAmount) + transferValue := new(big.Rat).Set(tokenAmount) nextTransferCID, exhaustion, err := pickNextHolding( events, *c.transferTokenInstrument, diff --git a/ccip/devenv/manual_execution.go b/ccip/devenv/manual_execution.go index 5126be778..ad2e74f6f 100644 --- a/ccip/devenv/manual_execution.go +++ b/ccip/devenv/manual_execution.go @@ -5,6 +5,7 @@ import ( "encoding/hex" "fmt" "math/big" + "os" "strings" "sync" "time" @@ -24,15 +25,24 @@ import ( "github.com/smartcontractkit/chainlink-canton/bindings/generated/latest/ccip/ccipruntime" "github.com/smartcontractkit/chainlink-canton/bindings/generated/latest/ccip/events" ccipreceiver "github.com/smartcontractkit/chainlink-canton/bindings/generated/latest/ccip/receiver" + ccipsender "github.com/smartcontractkit/chainlink-canton/bindings/generated/latest/ccip/sender" "github.com/smartcontractkit/chainlink-canton/contracts" "github.com/smartcontractkit/chainlink-canton/deployment/operations/ccip/per_party_router_factory" "github.com/smartcontractkit/chainlink-canton/deployment/operations/ccip/receiver" + "github.com/smartcontractkit/chainlink-canton/deployment/operations/ccip/sender" "github.com/smartcontractkit/chainlink-canton/deployment/utils/operations/contract" "github.com/smartcontractkit/chainlink-canton/testhelpers" ) const perPartyRouterInstanceID = "test-router" +func instanceIDFromEnv(key, defaultID string) contracts.InstanceID { + if v := strings.TrimSpace(os.Getenv(key)); v != "" { + return contracts.InstanceID(v) + } + return contracts.InstanceID(defaultID) +} + // DeployPerPartyRouter uses the PerPartyRouterFactory to create a new PerPartyRouter instance for the given party. // partyOwner (client participant) exercises CreateRouter with factory disclosures from EDS. // It returns the instance address of the router. If a router already exists for the party, it returns the existing address. @@ -96,6 +106,54 @@ func (c *Chain) DeployPerPartyRouter(ctx context.Context, clientParticipant cant return routerAddress, nil } +// DeployCCIPSender returns a sender-owned CCIPSender instance address, deploying only when missing. +func (c *Chain) DeployCCIPSender(ctx context.Context, participant canton.Participant, partyId string) (contracts.InstanceAddress, error) { + var unset contracts.InstanceAddress + if c.senderAddress != unset { + return c.senderAddress, nil + } + + instanceID := instanceIDFromEnv("CANTON_SENDER_INSTANCE_ID", "e2e-ccipsender") + senderAddress := instanceID.RawInstanceAddress(types.PARTY(partyId)).InstanceAddress() + + if _, err := contract.FindActiveContractIDByInstanceAddress( + ctx, + participant.LedgerServices.State, + contract.LedgerQueryParties(participant), + ccipsender.CCIPSender{}.GetTemplateID(), + senderAddress, + ); err == nil { + c.senderAddress = senderAddress + return senderAddress, nil + } + + _, err := operations.ExecuteOperation(c.e.OperationsBundle, sender.Deploy, c.chain, contract.DeployInput[ccipsender.CCIPSender]{ + Qualifier: nil, + ParticipantIndex: c.clientParticipantIndex(), + Template: ccipsender.CCIPSender{ + InstanceId: types.TEXT(instanceID), + Owner: types.PARTY(partyId), + }, + OwnerParty: types.PARTY(partyId), + }) + if err != nil { + if _, findErr := contract.FindActiveContractIDByInstanceAddress( + ctx, + participant.LedgerServices.State, + contract.LedgerQueryParties(participant), + ccipsender.CCIPSender{}.GetTemplateID(), + senderAddress, + ); findErr == nil { + c.senderAddress = senderAddress + return senderAddress, nil + } + return contracts.InstanceAddress{}, fmt.Errorf("failed to deploy ccip sender contract: %w", err) + } + + c.senderAddress = senderAddress + return senderAddress, nil +} + func (c *Chain) DeployCCIPReceiver(ctx context.Context, participant canton.Participant, partyId string, receiverFinality int64) (contracts.InstanceAddress, error) { finalityConfig, err := encodeReceiverFinalityConfig(receiverFinality) if err != nil { diff --git a/ccip/devenv/tests/constants.go b/ccip/devenv/tests/constants.go deleted file mode 100644 index 4d16a64a8..000000000 --- a/ccip/devenv/tests/constants.go +++ /dev/null @@ -1,14 +0,0 @@ -package tests - -// Shared devenv test constants (message and token paths). - -const ( - // CantonToEVMFeeAmount is the Canton CCIP send fee in Amulet units. - CantonToEVMFeeAmount int64 = 2_000 - - // EVMDecimalsScale converts Canton token amounts to EVM 18-decimal balance units. - EVMDecimalsScale int64 = 1_000_000_000_000_000_000 - - // CantonToEVMTokenSequentialSends is how many token transfers the Canton→EVM e2e subtest sends. - CantonToEVMTokenSequentialSends = 2 -) diff --git a/ccip/devenv/tests/e2e/canton2evm_e2e_test.go b/ccip/devenv/tests/e2e/canton2evm_e2e_test.go index ca7550055..1f67f934f 100644 --- a/ccip/devenv/tests/e2e/canton2evm_e2e_test.go +++ b/ccip/devenv/tests/e2e/canton2evm_e2e_test.go @@ -54,14 +54,14 @@ func TestCanton2EVM_Basic(t *testing.T) { // TODO: currently minting 2 holdings of 2k for all the e2e tests. Otherwise one holding might conflict with the other. // the tests need to be hardened to not rely on this and instead correctly pick the holding that suffices. - require.NoError(t, cantonImpl.MintTokens(ctx, uint64(devenvtests.CantonToEVMFeeAmount)*100)) - require.NoError(t, cantonImpl.MintTokens(ctx, uint64(devenvtests.CantonToEVMFeeAmount)*100)) + require.NoError(t, cantonImpl.MintTokens(ctx, new(big.Rat).SetUint64(uint64(cantondevenv.CantonToEVMFeeAmount)*100))) + require.NoError(t, cantonImpl.MintTokens(ctx, new(big.Rat).SetUint64(uint64(cantondevenv.CantonToEVMFeeAmount)*100))) t.Run("EOA receiver and default committee verifier", func(t *testing.T) { subtestCtx := ccv.Plog.WithContext(t.Context()) // Setup message send - require.NoError(t, cantonImpl.SetupSend(ctx, uint64(devenvtests.CantonToEVMFeeAmount), 0)) + require.NoError(t, cantonImpl.SetupSend(ctx, uint64(cantondevenv.CantonToEVMFeeAmount), nil)) ds, err := lib.DataStore() require.NoError(t, err) @@ -146,11 +146,21 @@ func TestCanton2EVM_Basic(t *testing.T) { subtestCtx := ccv.Plog.WithContext(t.Context()) // Send params (transfer amount, gas limit, finality) come from token_transfer_config.toml. - lane := devenvtests.ResolveTokenLane(t, in, lib, chainMap, cantonChain.ChainSelector(), []uint64{evmChain.ChainSelector()}) - tokenTransferAmount := lane.TransferAmount.Uint64() - - // Setup message send - require.NoError(t, cantonImpl.SetupSend(ctx, uint64(devenvtests.CantonToEVMFeeAmount), tokenTransferAmount)) + lane := devenvtests.ResolveTokenLane(t, devenvtests.EnvDevenv, in, lib, chainMap, cantonChain.ChainSelector(), []uint64{evmChain.ChainSelector()}) + + fee := uint64(cantondevenv.CantonToEVMTokenTransferFeeAmount) + sends := cantondevenv.CantonToEVMTokenSequentialSends + feeTotal := new(big.Rat).SetUint64(uint64(sends) * fee) + transferTotalFP := new(big.Int).Mul(lane.TransferAmount, big.NewInt(int64(sends))) + transferTotal := new(big.Rat).SetFrac(transferTotalFP, big.NewInt(cantondevenv.CantonFixedPointScale)) + transferPerSend := new(big.Rat).SetFrac(lane.TransferAmount, big.NewInt(cantondevenv.CantonFixedPointScale)) + require.NoError(t, cantonImpl.MintTokens(ctx, feeTotal)) + require.NoError(t, cantonImpl.MintTokens(ctx, transferTotal)) + if lane.TransferInstrument.Admin != "" { + require.NoError(t, cantonImpl.SetupSend(ctx, fee, transferPerSend, lane.TransferInstrument)) + } else { + require.NoError(t, cantonImpl.SetupSend(ctx, fee, transferPerSend)) + } ds, err := lib.DataStore() require.NoError(t, err) @@ -176,8 +186,8 @@ func TestCanton2EVM_Basic(t *testing.T) { require.NoError(t, err) require.NotNil(t, receiverBalanceBefore) - for sendIdx := range devenvtests.CantonToEVMTokenSequentialSends { - t.Logf("Token transfer send %d/%d", sendIdx+1, devenvtests.CantonToEVMTokenSequentialSends) + for sendIdx := range cantondevenv.CantonToEVMTokenSequentialSends { + t.Logf("Token transfer send %d/%d", sendIdx+1, cantondevenv.CantonToEVMTokenSequentialSends) sendMessageResult, err := cantonChain.SendMessage( subtestCtx, evmChain.ChainSelector(), @@ -221,8 +231,8 @@ func TestCanton2EVM_Basic(t *testing.T) { require.NoError(t, err) require.NotNil(t, receiverBalanceAfter) - expectedTransferPerMessage := new(big.Int).Mul(lane.TransferAmount, big.NewInt(devenvtests.EVMDecimalsScale)) - totalExpectedTransfer := new(big.Int).Mul(expectedTransferPerMessage, big.NewInt(devenvtests.CantonToEVMTokenSequentialSends)) + expectedTransferPerMessage := new(big.Int).Mul(lane.TransferAmount, big.NewInt(cantondevenv.CantonFixedPointToEVMScale)) + totalExpectedTransfer := new(big.Int).Mul(expectedTransferPerMessage, big.NewInt(cantondevenv.CantonToEVMTokenSequentialSends)) expectedReceiverBalanceAfter := new(big.Int).Add(new(big.Int).Set(receiverBalanceBefore), totalExpectedTransfer) t.Logf("EVM receiver token balance: before=%s after=%s totalExpectedTransfer=%s", receiverBalanceBefore.String(), receiverBalanceAfter.String(), totalExpectedTransfer.String()) require.Equal(t, expectedReceiverBalanceAfter, receiverBalanceAfter) @@ -232,10 +242,19 @@ func TestCanton2EVM_Basic(t *testing.T) { t.Run("token transfer with default extraArgs", func(t *testing.T) { subtestCtx := ccv.Plog.WithContext(t.Context()) - lane := devenvtests.ResolveTokenLane(t, in, lib, chainMap, cantonChain.ChainSelector(), []uint64{evmChain.ChainSelector()}) - tokenTransferAmount := lane.TransferAmount.Uint64() - - require.NoError(t, cantonImpl.SetupSend(ctx, uint64(devenvtests.CantonToEVMFeeAmount), tokenTransferAmount)) + lane := devenvtests.ResolveTokenLane(t, devenvtests.EnvDevenv, in, lib, chainMap, cantonChain.ChainSelector(), []uint64{evmChain.ChainSelector()}) + + fee := uint64(cantondevenv.CantonToEVMTokenTransferFeeAmount) + feeTotal := new(big.Rat).SetUint64(fee) + transferTotal := new(big.Rat).SetFrac(lane.TransferAmount, big.NewInt(cantondevenv.CantonFixedPointScale)) + transferPerSend := new(big.Rat).SetFrac(lane.TransferAmount, big.NewInt(cantondevenv.CantonFixedPointScale)) + require.NoError(t, cantonImpl.MintTokens(ctx, feeTotal)) + require.NoError(t, cantonImpl.MintTokens(ctx, transferTotal)) + if lane.TransferInstrument.Admin != "" { + require.NoError(t, cantonImpl.SetupSend(ctx, fee, transferPerSend, lane.TransferInstrument)) + } else { + require.NoError(t, cantonImpl.SetupSend(ctx, fee, transferPerSend)) + } receiver, err := evmChain.GetEOAReceiverAddress() require.NoError(t, err) @@ -274,10 +293,19 @@ func TestCanton2EVM_Basic(t *testing.T) { t.Run("token transfer with zero gas limit", func(t *testing.T) { subtestCtx := ccv.Plog.WithContext(t.Context()) - lane := devenvtests.ResolveTokenLane(t, in, lib, chainMap, cantonChain.ChainSelector(), []uint64{evmChain.ChainSelector()}) - tokenTransferAmount := lane.TransferAmount.Uint64() - - require.NoError(t, cantonImpl.SetupSend(ctx, uint64(devenvtests.CantonToEVMFeeAmount), tokenTransferAmount)) + lane := devenvtests.ResolveTokenLane(t, devenvtests.EnvDevenv, in, lib, chainMap, cantonChain.ChainSelector(), []uint64{evmChain.ChainSelector()}) + + fee := uint64(cantondevenv.CantonToEVMTokenTransferFeeAmount) + feeTotal := new(big.Rat).SetUint64(fee) + transferTotal := new(big.Rat).SetFrac(lane.TransferAmount, big.NewInt(cantondevenv.CantonFixedPointScale)) + transferPerSend := new(big.Rat).SetFrac(lane.TransferAmount, big.NewInt(cantondevenv.CantonFixedPointScale)) + require.NoError(t, cantonImpl.MintTokens(ctx, feeTotal)) + require.NoError(t, cantonImpl.MintTokens(ctx, transferTotal)) + if lane.TransferInstrument.Admin != "" { + require.NoError(t, cantonImpl.SetupSend(ctx, fee, transferPerSend, lane.TransferInstrument)) + } else { + require.NoError(t, cantonImpl.SetupSend(ctx, fee, transferPerSend)) + } ds, err := lib.DataStore() require.NoError(t, err) diff --git a/ccip/devenv/tests/e2e/canton2evm_send_validation_test.go b/ccip/devenv/tests/e2e/canton2evm_send_validation_test.go index 60d57ba07..62458a55c 100644 --- a/ccip/devenv/tests/e2e/canton2evm_send_validation_test.go +++ b/ccip/devenv/tests/e2e/canton2evm_send_validation_test.go @@ -59,8 +59,8 @@ func setupCanton2EVMSendFixtureWithHoldings(t *testing.T, withFeeHoldings bool) require.True(t, ok) if withFeeHoldings { - require.NoError(t, cantonImpl.MintTokens(ctx, uint64(devenvtests.CantonToEVMFeeAmount))) - require.NoError(t, cantonImpl.SetupSend(ctx, uint64(devenvtests.CantonToEVMFeeAmount), 0)) + require.NoError(t, cantonImpl.MintTokens(ctx, new(big.Rat).SetUint64(uint64(cantondevenv.CantonToEVMFeeAmount)))) + require.NoError(t, cantonImpl.SetupSend(ctx, uint64(cantondevenv.CantonToEVMFeeAmount), nil)) } ds, err := lib.DataStore() @@ -229,8 +229,8 @@ func TestCanton2EVM_SendValidation(t *testing.T) { t.Run("Unsupported token on lane", func(t *testing.T) { f := setupCanton2EVMSendFixture(t) - require.NoError(t, f.cantonImpl.MintTokens(f.ctx, 1000)) - require.NoError(t, f.cantonImpl.SetupSend(f.ctx, uint64(devenvtests.CantonToEVMFeeAmount), 1000)) + require.NoError(t, f.cantonImpl.MintTokens(f.ctx, big.NewRat(1000, 1))) + require.NoError(t, f.cantonImpl.SetupSend(f.ctx, uint64(cantondevenv.CantonToEVMFeeAmount), big.NewRat(1000, 1))) f.cantonImpl.SetTransferTokenInstrumentForTest(splice_api_token_holding_v1.InstrumentId{ Admin: types.PARTY("unsupported-token-admin"), Id: types.TEXT("UnsupportedToken"), @@ -252,8 +252,8 @@ func TestCanton2EVM_SendValidation(t *testing.T) { t.Run("Zero token amount", func(t *testing.T) { f := setupCanton2EVMSendFixture(t) - require.NoError(t, f.cantonImpl.MintTokens(f.ctx, 1000)) - require.NoError(t, f.cantonImpl.SetupSend(f.ctx, uint64(devenvtests.CantonToEVMFeeAmount), 1000)) + require.NoError(t, f.cantonImpl.MintTokens(f.ctx, big.NewRat(1000, 1))) + require.NoError(t, f.cantonImpl.SetupSend(f.ctx, uint64(cantondevenv.CantonToEVMFeeAmount), big.NewRat(1000, 1))) err := f.send( cciptestinterfaces.MessageFields{ diff --git a/ccip/devenv/tests/e2e/evm2canton_e2e_test.go b/ccip/devenv/tests/e2e/evm2canton_e2e_test.go index 69b45eb03..23af4ac8f 100644 --- a/ccip/devenv/tests/e2e/evm2canton_e2e_test.go +++ b/ccip/devenv/tests/e2e/evm2canton_e2e_test.go @@ -126,7 +126,7 @@ func TestEVM2Canton_Basic(t *testing.T) { subtestCtx := ccv.Plog.WithContext(t.Context()) // Send params (transfer amount, gas limit, finality) come from token_transfer_config.toml. - lane := devenvtests.ResolveTokenLane(t, in, lib, chainMap, srcSelector, []uint64{dstSelector}) + lane := devenvtests.ResolveTokenLane(t, devenvtests.EnvDevenv, in, lib, chainMap, srcSelector, []uint64{dstSelector}) srcToken := lane.SrcToken srcSender, err := srcChain.GetEOAReceiverAddress() require.NoError(t, err) diff --git a/ccip/devenv/tests/env.go b/ccip/devenv/tests/env.go new file mode 100644 index 000000000..f414d8076 --- /dev/null +++ b/ccip/devenv/tests/env.go @@ -0,0 +1,33 @@ +package tests + +import ( + "fmt" + "strings" +) + +// CCIPEnv names a CCIP e2e target environment. +type CCIPEnv string + +const ( + EnvDevenv CCIPEnv = "devenv" + EnvProdTestnet CCIPEnv = "prod-testnet" +) + +// ParseCCIPEnv validates and returns a CCIPEnv from its string form. +func ParseCCIPEnv(s string) (CCIPEnv, error) { + switch CCIPEnv(strings.TrimSpace(s)) { + case EnvDevenv, EnvProdTestnet: + return CCIPEnv(strings.TrimSpace(s)), nil + case "staging": + return "", fmt.Errorf("ccip env %q is reserved but not yet supported", s) + case "mainnet": + return "", fmt.Errorf("ccip env %q is reserved but not yet supported", s) + default: + return "", fmt.Errorf("unknown ccip env %q: want devenv or prod-testnet", s) + } +} + +// IsRemote reports whether the environment targets live testnet infrastructure. +func (e CCIPEnv) IsRemote() bool { + return e == EnvProdTestnet +} diff --git a/ccip/devenv/tests/load/gun_canton2evm_test.go b/ccip/devenv/tests/load/gun_canton2evm_test.go index 29060312c..ccf6998d8 100644 --- a/ccip/devenv/tests/load/gun_canton2evm_test.go +++ b/ccip/devenv/tests/load/gun_canton2evm_test.go @@ -2,6 +2,7 @@ package load import ( "fmt" + "math/big" "os" "testing" @@ -79,11 +80,11 @@ func TestCanton2EVM_Load(t *testing.T) { if estimatedMessages == 0 { estimatedMessages = 1 } - mintAmount := estimatedMessages * uint64(devenvtests.CantonToEVMFeeAmount) * mintBufferNumerator / mintBufferDenominator + mintAmount := estimatedMessages * uint64(cantondevenv.CantonToEVMFeeAmount) * mintBufferNumerator / mintBufferDenominator t.Logf("Pre-mint: estimatedMessages=%d feePerMessage=%d totalFeeMint=%d", - estimatedMessages, devenvtests.CantonToEVMFeeAmount, mintAmount) - require.NoError(t, cantonImpl.MintTokens(ctx, mintAmount)) - require.NoError(t, cantonImpl.SetupSend(ctx, uint64(devenvtests.CantonToEVMFeeAmount), 0)) + estimatedMessages, cantondevenv.CantonToEVMFeeAmount, mintAmount) + require.NoError(t, cantonImpl.MintTokens(ctx, new(big.Rat).SetUint64(mintAmount))) + require.NoError(t, cantonImpl.SetupSend(ctx, uint64(cantondevenv.CantonToEVMFeeAmount), nil)) gun, err := NewCCIPLoadGun( cantonChain, diff --git a/ccip/devenv/tests/load/gun_canton2evm_token_test.go b/ccip/devenv/tests/load/gun_canton2evm_token_test.go index 291426a30..2c69b6d72 100644 --- a/ccip/devenv/tests/load/gun_canton2evm_token_test.go +++ b/ccip/devenv/tests/load/gun_canton2evm_token_test.go @@ -50,7 +50,7 @@ func TestCanton2EVM_TokenLoad(t *testing.T) { evmSelectors := discoverEVMTokenSelectors(t, in) require.NotEmpty(t, evmSelectors, "need at least one EVM token destination in the env file") - lane := devenvtests.ResolveTokenLane(t, in, lib, chainMap, cantonChain.ChainSelector(), evmSelectors) + lane := devenvtests.ResolveTokenLane(t, devenvtests.EnvDevenv, in, lib, chainMap, cantonChain.ChainSelector(), evmSelectors) t.Logf("Token lane: pool=%s transfer=%s", lane.PoolRef.Qualifier, lane.TransferAmount.String()) destinations := discoverEVMTokenDestinations(t, in, chainMap, lane) @@ -91,7 +91,7 @@ func TestCanton2EVM_TokenLoad(t *testing.T) { require.NoError(t, err) require.NotNil(t, receiverBalanceAfter) - expectedPerMessage := new(big.Int).Mul(lane.TransferAmount, big.NewInt(devenvtests.EVMDecimalsScale)) + expectedPerMessage := new(big.Int).Mul(lane.TransferAmount, big.NewInt(cantondevenv.CantonFixedPointToEVMScale)) expectedDelta := new(big.Int).Mul(expectedPerMessage, big.NewInt(gun.CallCount())) expectedBalance := new(big.Int).Add(new(big.Int).Set(receiverBalanceBefore), expectedDelta) t.Logf("EVM receiver token balance: before=%s after=%s expectedDelta=%s calls=%d", diff --git a/ccip/devenv/tests/load/gun_evm2canton_token_test.go b/ccip/devenv/tests/load/gun_evm2canton_token_test.go index 4ad00269d..eb44409e6 100644 --- a/ccip/devenv/tests/load/gun_evm2canton_token_test.go +++ b/ccip/devenv/tests/load/gun_evm2canton_token_test.go @@ -52,7 +52,7 @@ func TestEVM2Canton_TokenLoad(t *testing.T) { require.True(t, ok, "Canton dest chain must be *devenv.Chain") require.NoError(t, cantonImpl.SetupReceive(ctx)) - lane := devenvtests.ResolveTokenLane(t, in, lib, chainMap, evmChain.ChainSelector(), []uint64{cantonChain.ChainSelector()}) + lane := devenvtests.ResolveTokenLane(t, devenvtests.EnvDevenv, in, lib, chainMap, evmChain.ChainSelector(), []uint64{cantonChain.ChainSelector()}) t.Logf("Token lane: pool=%s transfer=%s srcToken=%x", lane.PoolRef.Qualifier, lane.TransferAmount.String(), diff --git a/ccip/devenv/tests/load/load_helpers.go b/ccip/devenv/tests/load/load_helpers.go index 0ba7e3daa..7f2d820d8 100644 --- a/ccip/devenv/tests/load/load_helpers.go +++ b/ccip/devenv/tests/load/load_helpers.go @@ -265,17 +265,20 @@ func setupCantonTokenLoadHoldings( t.Helper() estimated := estimateMessages(sched) - feeMint := estimated * uint64(devenvtests.CantonToEVMFeeAmount) - transferMint := estimated * lane.TransferAmount.Uint64() - t.Logf("Pre-mint: estimatedMessages=%d feeMint=%d transferMint=%d", - estimated, feeMint, transferMint) - require.NoError(t, cantonImpl.MintTokens(ctx, feeMint)) - require.NoError(t, cantonImpl.MintTokens(ctx, transferMint)) - require.NoError(t, cantonImpl.SetupSend( - ctx, - uint64(devenvtests.CantonToEVMFeeAmount), - lane.TransferAmount.Uint64(), - )) + fee := uint64(cantondevenv.CantonToEVMTokenTransferFeeAmount) + feeTotal := new(big.Rat).SetUint64(estimated * fee) + transferTotalFP := new(big.Int).Mul(lane.TransferAmount, big.NewInt(int64(estimated))) + transferTotal := new(big.Rat).SetFrac(transferTotalFP, big.NewInt(cantondevenv.CantonFixedPointScale)) + transferPerSend := new(big.Rat).SetFrac(lane.TransferAmount, big.NewInt(cantondevenv.CantonFixedPointScale)) + t.Logf("Pre-mint: estimatedMessages=%d feeTotal=%s transferTotal=%s", + estimated, feeTotal.FloatString(10), transferTotal.FloatString(10)) + require.NoError(t, cantonImpl.MintTokens(ctx, feeTotal)) + require.NoError(t, cantonImpl.MintTokens(ctx, transferTotal)) + if lane.TransferInstrument.Admin != "" { + require.NoError(t, cantonImpl.SetupSend(ctx, fee, transferPerSend, lane.TransferInstrument)) + } else { + require.NoError(t, cantonImpl.SetupSend(ctx, fee, transferPerSend)) + } } func evmLoadDestination(chain cciptestinterfaces.CCIP17, receiver protocol.UnknownAddress) Destination { diff --git a/ccip/devenv/tests/token_lane.go b/ccip/devenv/tests/token_lane.go index 6ca23d332..ad1607e74 100644 --- a/ccip/devenv/tests/token_lane.go +++ b/ccip/devenv/tests/token_lane.go @@ -21,7 +21,10 @@ import ( "github.com/smartcontractkit/chainlink-ccv/protocol" "github.com/smartcontractkit/chainlink-deployments-framework/datastore" "github.com/smartcontractkit/chainlink-deployments-framework/deployment" + "github.com/smartcontractkit/go-daml/pkg/types" "github.com/stretchr/testify/require" + + splice_api_token_holding_v1 "github.com/smartcontractkit/chainlink-canton/bindings/generated/latest/splice/splice_api_token_holding_v1" ) const ( @@ -34,8 +37,12 @@ const ( // TokenLane describes a resolved token pool pairing for load or e2e tests. type TokenLane struct { PoolRef datastore.AddressRef - // TransferAmount is the per-message token transfer amount. + // TransferAmount is the per-message token transfer amount (wei for EVM→Canton, 10^10 fixed-point for Canton→EVM). TransferAmount *big.Int + // TransferInstrumentID is a TOML hint only (e.g. Amulet, LINK); prod resolution uses TransferInstrument. + TransferInstrumentID string + // TransferInstrument is the resolved Canton transfer instrument when Canton is the source. + TransferInstrument splice_api_token_holding_v1.InstrumentId // ExecutionGasLimit is the per-message execution gas limit. ExecutionGasLimit uint32 // FinalityConfig is the per-message finality configuration. @@ -49,23 +56,42 @@ type TokenLane struct { } type tokenConfigTOML struct { + Devenv tokenDirectionsTOML `toml:"devenv"` + ProdTestnet tokenDirectionsTOML `toml:"prod-testnet"` +} + +type tokenDirectionsTOML struct { EVMToCanton tokenDirectionTOML `toml:"evm_to_canton"` CantonToEVM tokenDirectionTOML `toml:"canton_to_evm"` } type tokenDirectionTOML struct { - PoolType string `toml:"pool_type"` - PoolVersion string `toml:"pool_version"` - PoolQualifier string `toml:"pool_qualifier"` - TransferAmount string `toml:"transfer_amount"` - ExecutionGasLimit uint32 `toml:"execution_gas_limit"` - FinalityConfig uint32 `toml:"finality_config"` + PoolType string `toml:"pool_type"` + PoolVersion string `toml:"pool_version"` + PoolQualifier string `toml:"pool_qualifier"` + TransferAmount string `toml:"transfer_amount"` + ExecutionGasLimit uint32 `toml:"execution_gas_limit"` + FinalityConfig uint32 `toml:"finality_config"` + RemotePoolType string `toml:"remote_pool_type"` + RemotePoolVersion string `toml:"remote_pool_version"` + RemotePoolQualifier string `toml:"remote_pool_qualifier"` + TransferInstrumentID string `toml:"transfer_instrument_id"` +} + +type tokenDirectionParsed struct { + PoolRef datastore.AddressRef + RemotePoolRef *datastore.AddressRef + TransferAmount *big.Int + TransferInstrumentID string + ExecutionGasLimit uint32 + FinalityConfig protocol.Finality } // ResolveTokenLane loads send params from TOML, matches the pool on the source chain, // and resolves source/destination token addresses for the requested destinations. func ResolveTokenLane( t *testing.T, + env CCIPEnv, in *ccv.Cfg, lib ccv.Lib, chainMap map[uint64]cciptestinterfaces.CCIP17, @@ -74,68 +100,64 @@ func ResolveTokenLane( ) TokenLane { t.Helper() - // Load the CLDF environment and datastore used to resolve on-chain addresses. - env, err := lib.CLDFEnvironment() + cldfEnv, err := lib.CLDFEnvironment() require.NoError(t, err) - require.NotNil(t, env) + require.NotNil(t, cldfEnv) srcChain, ok := chainMap[srcSelector] require.True(t, ok, "source chain %d not in harness chain map", srcSelector) - // Pick the TOML block from the source chain family (Canton<->EVM only). direction := directionEVMToCanton if isCantonSelector(srcSelector) { direction = directionCantonToEVM } - // Read pool identity and per-message send params from token_transfer_config.toml. - poolRef, transferAmount, gasLimit, finalityConfig := loadTokenDirection(t, direction) + dir := loadTokenDirection(t, env, direction) - // List token transfer configs deployed on the source chain for the requested destinations. srcProvider := tokenConfigProvider(srcChain) - cfgs, err := srcProvider.GetTokenTransferConfigs(env, srcSelector, destSelectors, in.EnvironmentTopology) + cfgs, err := srcProvider.GetTokenTransferConfigs(cldfEnv, srcSelector, destSelectors, in.EnvironmentTopology) require.NoError(t, err, "get token transfer configs for source chain %d", srcSelector) - // Require exactly one config whose pool ref matches the TOML declaration. - cfg := selectTokenConfig(t, cfgs, poolRef, srcSelector) + cfg, matched := trySelectTokenConfig(cfgs, dir.PoolRef) + if !matched { + t.Fatalf("no token transfer config on chain %d matches pool %s (have %s)", + srcSelector, poolRefString(dir.PoolRef), poolRefsString(cfgs)) + } - // Fail fast if any requested destination is missing from that lane's RemoteChains. for _, sel := range destSelectors { if _, present := cfg.RemoteChains[sel]; !present { t.Fatalf("destination %d not configured for pool %s on chain %d (have %v)", - sel, poolRefString(poolRef), srcSelector, sortedRemoteSelectors(cfg)) + sel, poolRefString(dir.PoolRef), srcSelector, sortedRemoteSelectors(cfg)) } } - // Assemble the lane with TOML send params; token addresses are filled in below. lane := TokenLane{ - PoolRef: poolRef, - TransferAmount: transferAmount, - ExecutionGasLimit: gasLimit, - FinalityConfig: finalityConfig, - DestTokenBySelector: make(map[uint64]protocol.UnknownAddress, len(destSelectors)), + PoolRef: dir.PoolRef, + TransferAmount: dir.TransferAmount, + TransferInstrumentID: dir.TransferInstrumentID, + ExecutionGasLimit: dir.ExecutionGasLimit, + FinalityConfig: dir.FinalityConfig, + DestTokenBySelector: make(map[uint64]protocol.UnknownAddress, len(destSelectors)), } - // EVM source: resolve the ERC-20 from the matched config's TokenRef in the datastore. - // Canton source: instrument is chosen in SetupSend, so SrcToken stays empty. if !isCantonSelector(srcSelector) { - srcToken, err := resolveTokenRef(env.DataStore, srcSelector, cfg.TokenRef) + srcToken, err := resolveTokenRef(cldfEnv.DataStore, srcSelector, cfg.TokenRef) require.NoError(t, err, "resolve source token on chain %d", srcSelector) lane.SrcToken = srcToken + } else { + lane.TransferInstrument = resolveCantonTransferInstrument(t, cldfEnv.DataStore, srcSelector, cfg.TokenRef, dir.PoolRef, dir.TransferInstrumentID) } - // EVM destinations: look up each dest chain's config for the remote pool and resolve TokenRef. - // Canton destinations are skipped (no EVM-style token address for balance checks). for _, sel := range destSelectors { if isCantonSelector(sel) { continue } - lane.DestTokenBySelector[sel] = resolveDestToken(t, env, in, chainMap, srcSelector, sel, cfg.RemoteChains[sel], poolRef) + lane.DestTokenBySelector[sel] = resolveDestToken(t, cldfEnv, in, chainMap, srcSelector, sel, cfg.RemoteChains[sel], dir.PoolRef) } return lane } -func loadTokenDirection(t *testing.T, direction string) (poolRef datastore.AddressRef, transferAmount *big.Int, gasLimit uint32, finalityConfig protocol.Finality) { +func loadTokenDirection(t *testing.T, env CCIPEnv, direction string) tokenDirectionParsed { t.Helper() path := os.Getenv(envTokenTestConfig) @@ -147,40 +169,88 @@ func loadTokenDirection(t *testing.T, direction string) (poolRef datastore.Addre _, err := toml.DecodeFile(path, &cfg) require.NoError(t, err, "decode token transfer config %q (set %s to override)", path, envTokenTestConfig) + dirs, err := tokenDirectionsForEnv(cfg, env) + require.NoError(t, err, "%s: no token transfer config for env %q", path, env) + var dir tokenDirectionTOML switch direction { case directionEVMToCanton: - dir = cfg.EVMToCanton + dir = dirs.EVMToCanton case directionCantonToEVM: - dir = cfg.CantonToEVM + dir = dirs.CantonToEVM default: t.Fatalf("unknown token transfer direction %q (expected %q or %q)", direction, directionEVMToCanton, directionCantonToEVM) } - require.NotEmpty(t, dir.PoolType, "%s: pool_type is required for direction %q", path, direction) - require.NotEmpty(t, dir.PoolVersion, "%s: pool_version is required for direction %q", path, direction) - require.NotEmpty(t, dir.PoolQualifier, "%s: pool_qualifier is required for direction %q", path, direction) + require.NotEmpty(t, dir.PoolType, "%s: pool_type is required for env %q direction %q", path, env, direction) + require.NotEmpty(t, dir.PoolVersion, "%s: pool_version is required for env %q direction %q", path, env, direction) + require.NotEmpty(t, dir.PoolQualifier, "%s: pool_qualifier is required for env %q direction %q", path, env, direction) version, err := semver.NewVersion(dir.PoolVersion) - require.NoError(t, err, "%s: invalid pool_version %q for direction %q", path, dir.PoolVersion, direction) + require.NoError(t, err, "%s: invalid pool_version %q for env %q direction %q", path, dir.PoolVersion, env, direction) - amount, ok := new(big.Int).SetString(strings.TrimSpace(dir.TransferAmount), 10) - require.True(t, ok && amount.Sign() > 0, "%s: transfer_amount %q must be a positive integer for direction %q", path, dir.TransferAmount, direction) + amountStr := strings.TrimSpace(dir.TransferAmount) + intAmount, ok := new(big.Int).SetString(amountStr, 10) + switch direction { + case directionEVMToCanton: + require.True(t, ok && intAmount.Sign() > 0, "%s: transfer_amount %q must be a positive integer wei for env %q direction %q", path, dir.TransferAmount, env, direction) + case directionCantonToEVM: + require.True(t, ok && intAmount.Sign() > 0, "%s: transfer_amount %q must be a positive integer fixed-point (10^10 scale) for env %q direction %q", path, dir.TransferAmount, env, direction) + default: + t.Fatalf("unknown token transfer direction %q", direction) + } + + require.NotZero(t, dir.ExecutionGasLimit, "%s: execution_gas_limit is required for env %q direction %q", path, env, direction) + + parsed := tokenDirectionParsed{ + PoolRef: datastore.AddressRef{ + Type: datastore.ContractType(dir.PoolType), + Version: version, + Qualifier: dir.PoolQualifier, + }, + TransferAmount: intAmount, + TransferInstrumentID: strings.TrimSpace(dir.TransferInstrumentID), + ExecutionGasLimit: dir.ExecutionGasLimit, + FinalityConfig: protocol.Finality(dir.FinalityConfig), + } - require.NotZero(t, dir.ExecutionGasLimit, "%s: execution_gas_limit is required for direction %q", path, direction) + if dir.RemotePoolType != "" || dir.RemotePoolVersion != "" || dir.RemotePoolQualifier != "" { + require.NotEmpty(t, dir.RemotePoolType, "%s: remote_pool_type is required when remote pool fields are set (env %q direction %q)", path, env, direction) + require.NotEmpty(t, dir.RemotePoolVersion, "%s: remote_pool_version is required when remote pool fields are set (env %q direction %q)", path, env, direction) + require.NotEmpty(t, dir.RemotePoolQualifier, "%s: remote_pool_qualifier is required when remote pool fields are set (env %q direction %q)", path, env, direction) - poolRef = datastore.AddressRef{ - Type: datastore.ContractType(dir.PoolType), - Version: version, - Qualifier: dir.PoolQualifier, + remoteVersion, err := semver.NewVersion(dir.RemotePoolVersion) + require.NoError(t, err, "%s: invalid remote_pool_version %q for env %q direction %q", path, dir.RemotePoolVersion, env, direction) + + remoteRef := datastore.AddressRef{ + Type: datastore.ContractType(dir.RemotePoolType), + Version: remoteVersion, + Qualifier: dir.RemotePoolQualifier, + } + parsed.RemotePoolRef = &remoteRef } - return poolRef, amount, dir.ExecutionGasLimit, protocol.Finality(dir.FinalityConfig) + return parsed } -func selectTokenConfig(t *testing.T, cfgs []tokenscore.TokenTransferConfig, poolRef datastore.AddressRef, srcSelector uint64) tokenscore.TokenTransferConfig { - t.Helper() +func tokenDirectionsForEnv(cfg tokenConfigTOML, env CCIPEnv) (tokenDirectionsTOML, error) { + switch env { + case EnvDevenv: + if cfg.Devenv.EVMToCanton.PoolType == "" && cfg.Devenv.CantonToEVM.PoolType == "" { + return tokenDirectionsTOML{}, fmt.Errorf("missing [devenv.*] sections") + } + return cfg.Devenv, nil + case EnvProdTestnet: + if cfg.ProdTestnet.EVMToCanton.PoolType == "" && cfg.ProdTestnet.CantonToEVM.PoolType == "" { + return tokenDirectionsTOML{}, fmt.Errorf("missing [prod-testnet.*] sections") + } + return cfg.ProdTestnet, nil + default: + return tokenDirectionsTOML{}, fmt.Errorf("unsupported ccip env %q", env) + } +} +func trySelectTokenConfig(cfgs []tokenscore.TokenTransferConfig, poolRef datastore.AddressRef) (tokenscore.TokenTransferConfig, bool) { matches := make([]tokenscore.TokenTransferConfig, 0, 1) for _, cfg := range cfgs { if poolRefEqual(cfg.TokenPoolRef, poolRef) { @@ -189,16 +259,10 @@ func selectTokenConfig(t *testing.T, cfgs []tokenscore.TokenTransferConfig, pool } switch len(matches) { case 1: - return matches[0] - case 0: - t.Fatalf("no token transfer config on chain %d matches pool %s (have %s)", - srcSelector, poolRefString(poolRef), poolRefsString(cfgs)) + return matches[0], true default: - t.Fatalf("pool %s matched %d configs on chain %d (expected one): %s", - poolRefString(poolRef), len(matches), srcSelector, poolRefsString(matches)) + return tokenscore.TokenTransferConfig{}, false } - - return tokenscore.TokenTransferConfig{} } func resolveDestToken( @@ -243,6 +307,102 @@ func tokenConfigProvider(chain cciptestinterfaces.CCIP17) cciptestinterfaces.Tok return evm.NewEmptyCCIP17EVM() } +func resolveCantonTransferInstrument( + t *testing.T, + ds datastore.DataStore, + chainSelector uint64, + tokenRef datastore.AddressRef, + poolRef datastore.AddressRef, + transferInstrumentIDHint string, +) splice_api_token_holding_v1.InstrumentId { + t.Helper() + + addrRef, err := ds.Addresses().Get(datastore.NewAddressRefKey(chainSelector, tokenRef.Type, tokenRef.Version, tokenRef.Qualifier)) + require.NoError(t, err, "resolve Canton token ref %s on chain %d", poolRefString(tokenRef), chainSelector) + + poolAddrRef, err := ds.Addresses().Get(datastore.NewAddressRefKey(chainSelector, poolRef.Type, poolRef.Version, poolRef.Qualifier)) + require.NoError(t, err, "resolve Canton pool ref %s on chain %d", poolRefString(poolRef), chainSelector) + + instrument, err := resolveInstrumentFromTokenRefWithFallback(addrRef, poolAddrRef, transferInstrumentIDHint) + require.NoError(t, err, "parse instrument from token ref %s on chain %d", poolRefString(tokenRef), chainSelector) + + return instrument +} + +func resolveInstrumentFromTokenRefWithFallback( + tokenRef datastore.AddressRef, + poolRef datastore.AddressRef, + transferInstrumentIDHint string, +) (splice_api_token_holding_v1.InstrumentId, error) { + if instrument, err := parseInstrumentIDFromTokenRefLabels(tokenRef); err == nil { + return instrument, nil + } + + ccipOwner := ccipOwnerFromRefLabels(poolRef) + if ccipOwner == "" { + return splice_api_token_holding_v1.InstrumentId{}, fmt.Errorf( + "tokenRef labels must include instrument-admin: and instrument-id:", + ) + } + + instrumentID := normalizeTransferInstrumentID(transferInstrumentIDHint) + if instrumentID == "" { + return splice_api_token_holding_v1.InstrumentId{}, fmt.Errorf( + "transfer_instrument_id is required when token ref labels are missing", + ) + } + + return splice_api_token_holding_v1.InstrumentId{ + Admin: types.PARTY(ccipOwner), + Id: types.TEXT(instrumentID), + }, nil +} + +func parseInstrumentIDFromTokenRefLabels(tokenRef datastore.AddressRef) (splice_api_token_holding_v1.InstrumentId, error) { + var instrumentAdmin, instrumentIDText string + for _, label := range tokenRef.Labels.List() { + switch { + case strings.HasPrefix(label, "instrument-admin:"): + instrumentAdmin = strings.TrimSpace(strings.TrimPrefix(label, "instrument-admin:")) + case strings.HasPrefix(label, "instrument-id:"): + instrumentIDText = strings.TrimSpace(strings.TrimPrefix(label, "instrument-id:")) + } + } + if instrumentAdmin == "" || instrumentIDText == "" { + return splice_api_token_holding_v1.InstrumentId{}, fmt.Errorf( + "tokenRef labels must include instrument-admin: and instrument-id:", + ) + } + + return splice_api_token_holding_v1.InstrumentId{ + Admin: types.PARTY(instrumentAdmin), + Id: types.TEXT(instrumentIDText), + }, nil +} + +func ccipOwnerFromRefLabels(ref datastore.AddressRef) string { + for _, label := range ref.Labels.List() { + if party, ok := strings.CutPrefix(label, "ccip-owner:"); ok { + return strings.TrimSpace(party) + } + if idx := strings.LastIndex(label, "@ccipOwner::"); idx >= 0 { + return strings.TrimSpace(label[idx+1:]) + } + } + + return "" +} + +func normalizeTransferInstrumentID(hint string) string { + hint = strings.TrimSpace(hint) + switch strings.ToLower(hint) { + case "link": + return "link-token" + default: + return hint + } +} + func resolveTokenRef(ds datastore.DataStore, chainSelector uint64, ref datastore.AddressRef) (protocol.UnknownAddress, error) { addrRef, err := ds.Addresses().Get(datastore.NewAddressRefKey(chainSelector, ref.Type, ref.Version, ref.Qualifier)) if err != nil { @@ -280,7 +440,6 @@ func defaultTokenConfigPath() string { return filepath.Join(filepath.Dir(file), defaultTokenConfigFile) } -// Helpers for logging // func poolRefsString(cfgs []tokenscore.TokenTransferConfig) string { if len(cfgs) == 0 { return "none" diff --git a/ccip/devenv/tests/token_lane_test.go b/ccip/devenv/tests/token_lane_test.go new file mode 100644 index 000000000..88f9ba2d2 --- /dev/null +++ b/ccip/devenv/tests/token_lane_test.go @@ -0,0 +1,185 @@ +package tests + +import ( + "math/big" + "os" + "path/filepath" + "testing" + + "github.com/BurntSushi/toml" + "github.com/smartcontractkit/chainlink-deployments-framework/datastore" + "github.com/stretchr/testify/require" + + "github.com/smartcontractkit/chainlink-canton/ccip/devenv" +) + +func TestTokenDirectionsForEnv(t *testing.T) { + t.Parallel() + + cfg, err := decodeTokenConfigTOML(defaultTokenConfigPath()) + require.NoError(t, err) + + devenvDirs, err := tokenDirectionsForEnv(cfg, EnvDevenv) + require.NoError(t, err) + require.Equal(t, "BurnMintTokenPool", devenvDirs.EVMToCanton.PoolType) + require.Contains(t, devenvDirs.EVMToCanton.PoolQualifier, "BurnMintTokenPool 2.0.0 [default]") + require.Equal(t, "LockReleaseTokenPool", devenvDirs.CantonToEVM.PoolType) + + prodDirs, err := tokenDirectionsForEnv(cfg, EnvProdTestnet) + require.NoError(t, err) + require.Equal(t, "TEST", prodDirs.EVMToCanton.PoolQualifier) + require.Equal(t, "LINK", prodDirs.EVMToCanton.RemotePoolQualifier) + require.Equal(t, "LINK", prodDirs.CantonToEVM.PoolQualifier) + require.Equal(t, "100", prodDirs.CantonToEVM.TransferAmount) + require.Equal(t, "link-token", prodDirs.CantonToEVM.TransferInstrumentID) +} + +func TestLoadTokenDirection_envSelection(t *testing.T) { + t.Setenv(envTokenTestConfig, defaultTokenConfigPath()) + + devenvDir := loadTokenDirection(t, EnvDevenv, directionEVMToCanton) + require.Equal(t, "BurnMintTokenPool", string(devenvDir.PoolRef.Type)) + require.Contains(t, devenvDir.PoolRef.Qualifier, "BurnMintTokenPool 2.0.0 [default]") + require.Nil(t, devenvDir.RemotePoolRef) + + prodDir := loadTokenDirection(t, EnvProdTestnet, directionEVMToCanton) + require.Equal(t, "TEST", prodDir.PoolRef.Qualifier) + require.NotNil(t, prodDir.RemotePoolRef) + require.Equal(t, "LINK", prodDir.RemotePoolRef.Qualifier) +} + +func TestLoadTokenDirection_transferAmountParsing(t *testing.T) { + t.Setenv(envTokenTestConfig, defaultTokenConfigPath()) + + evmDir := loadTokenDirection(t, EnvDevenv, directionEVMToCanton) + require.Equal(t, "100000000001", evmDir.TransferAmount.String()) + + cantonDir := loadTokenDirection(t, EnvDevenv, directionCantonToEVM) + require.Equal(t, big.NewInt(1000), cantonDir.TransferAmount) + + prodCantonDir := loadTokenDirection(t, EnvProdTestnet, directionCantonToEVM) + require.Equal(t, big.NewInt(100), prodCantonDir.TransferAmount) + require.Equal(t, "link-token", prodCantonDir.TransferInstrumentID) + + expectedWei := new(big.Int).Mul(prodCantonDir.TransferAmount, big.NewInt(devenv.CantonFixedPointToEVMScale)) + require.Equal(t, "10000000000", expectedWei.String()) +} + +func TestLoadTokenDirection_missingEnvSection(t *testing.T) { + path := filepath.Join(t.TempDir(), "token_transfer_config.toml") + require.NoError(t, os.WriteFile(path, []byte(` +[devenv.evm_to_canton] +pool_type = "BurnMintTokenPool" +pool_version = "2.0.0" +pool_qualifier = "TEST" +transfer_amount = "1" +execution_gas_limit = 1 +finality_config = 0 +`), 0o600)) + + t.Setenv(envTokenTestConfig, path) + + _, err := tokenDirectionsForEnv(mustDecodeTokenConfig(t, path), EnvProdTestnet) + require.Error(t, err) + require.Contains(t, err.Error(), "prod-testnet") +} + +func mustDecodeTokenConfig(t *testing.T, path string) tokenConfigTOML { + t.Helper() + + cfg, err := decodeTokenConfigTOML(path) + require.NoError(t, err) + + return cfg +} + +func decodeTokenConfigTOML(path string) (tokenConfigTOML, error) { + var cfg tokenConfigTOML + _, err := toml.DecodeFile(path, &cfg) + + return cfg, err +} + +func TestParseInstrumentIDFromTokenRefLabels(t *testing.T) { + t.Parallel() + + ccipOwner := "ccipOwner::1220e382f4e57b0815e6be737006e381e6b7de448e06bd033ece6df498017879f551" + tokenRef := datastore.AddressRef{ + Labels: datastore.NewLabelSet( + "instrument-admin:"+ccipOwner, + "instrument-id:link-token", + ), + } + + instrument, err := parseInstrumentIDFromTokenRefLabels(tokenRef) + require.NoError(t, err) + require.Equal(t, ccipOwner, string(instrument.Admin)) + require.Equal(t, "link-token", string(instrument.Id)) + + _, err = parseInstrumentIDFromTokenRefLabels(datastore.AddressRef{}) + require.Error(t, err) + require.Contains(t, err.Error(), "instrument-admin") +} + +func TestNormalizeTransferInstrumentID(t *testing.T) { + t.Parallel() + + require.Equal(t, "link-token", normalizeTransferInstrumentID("LINK")) + require.Equal(t, "link-token", normalizeTransferInstrumentID("link")) + require.Equal(t, "link-token", normalizeTransferInstrumentID(" link ")) + require.Equal(t, "amulet", normalizeTransferInstrumentID("Amulet")) + require.Equal(t, "link-token", normalizeTransferInstrumentID("link-token")) +} + +func TestCCIPOwnerFromRefLabels(t *testing.T) { + t.Parallel() + + ccipOwner := "ccipOwner::1220e382f4e57b0815e6be737006e381e6b7de448e06bd033ece6df498017879f551" + + fromPrefix := datastore.AddressRef{ + Labels: datastore.NewLabelSet("ccip-owner:" + ccipOwner), + } + require.Equal(t, ccipOwner, ccipOwnerFromRefLabels(fromPrefix)) + + fromSuffix := datastore.AddressRef{ + Labels: datastore.NewLabelSet("burnminttokenpool-LINK@" + ccipOwner), + } + require.Equal(t, ccipOwner, ccipOwnerFromRefLabels(fromSuffix)) + + require.Empty(t, ccipOwnerFromRefLabels(datastore.AddressRef{})) +} + +func TestResolveInstrumentFromTokenRefWithFallback(t *testing.T) { + t.Parallel() + + ccipOwner := "ccipOwner::1220e382f4e57b0815e6be737006e381e6b7de448e06bd033ece6df498017879f551" + + tokenWithLabels := datastore.AddressRef{ + Labels: datastore.NewLabelSet( + "instrument-admin:"+ccipOwner, + "instrument-id:link-token", + ), + } + poolRef := datastore.AddressRef{ + Labels: datastore.NewLabelSet("burnminttokenpool-LINK@" + ccipOwner), + } + + instrument, err := resolveInstrumentFromTokenRefWithFallback(tokenWithLabels, poolRef, "LINK") + require.NoError(t, err) + require.Equal(t, ccipOwner, string(instrument.Admin)) + require.Equal(t, "link-token", string(instrument.Id)) + + tokenWithoutLabels := datastore.AddressRef{} + instrument, err = resolveInstrumentFromTokenRefWithFallback(tokenWithoutLabels, poolRef, "LINK") + require.NoError(t, err) + require.Equal(t, ccipOwner, string(instrument.Admin)) + require.Equal(t, "link-token", string(instrument.Id)) + + _, err = resolveInstrumentFromTokenRefWithFallback(tokenWithoutLabels, datastore.AddressRef{}, "LINK") + require.Error(t, err) + require.Contains(t, err.Error(), "instrument-admin") + + _, err = resolveInstrumentFromTokenRefWithFallback(tokenWithoutLabels, poolRef, "") + require.Error(t, err) + require.Contains(t, err.Error(), "transfer_instrument_id") +} diff --git a/ccip/devenv/tests/token_transfer_config.toml b/ccip/devenv/tests/token_transfer_config.toml index c3bbfe7ee..10530c621 100644 --- a/ccip/devenv/tests/token_transfer_config.toml +++ b/ccip/devenv/tests/token_transfer_config.toml @@ -1,24 +1,49 @@ -# Token lane inputs for devenv e2e/load tests. +# Token lane inputs for CCIP e2e/load tests. # +# Env selection follows -ccip-env / CCIP_ENV (same as CCIP harness). # Token identity (pool_type / pool_version / pool_qualifier) is REQUIRED and is # matched against the source chain's GetTokenTransferConfigs to discover the lane. # Numeric send params (transfer_amount / execution_gas_limit / finality_config) # are optional; when omitted, code-level per-direction defaults are used. # -# Override the file path with the CANTON_TOKEN_TEST_CONFIG env var. +# Override the entire file path with CANTON_TOKEN_TEST_CONFIG. -[evm_to_canton] -pool_type = "BurnMintTokenPool" -pool_version = "2.0.0" +[devenv.evm_to_canton] +pool_type = "BurnMintTokenPool" +pool_version = "2.0.0" pool_qualifier = "TEST (BurnMintTokenPool 2.0.0 [default], LockReleaseTokenPool 2.0.0 [default])::BurnMintTokenPool 2.0.0 [default]" -transfer_amount = "100000000000" -execution_gas_limit = 200000 -finality_config = 0 +transfer_amount = "100000000001" +execution_gas_limit = 200001 +# Per-message FTF (1-block depth); requires Canton pool remoteChainCfg BlockDepth >= 1 (see impl.go). +finality_config = 1 -[canton_to_evm] -pool_type = "LockReleaseTokenPool" -pool_version = "2.0.0" +[devenv.canton_to_evm] +pool_type = "LockReleaseTokenPool" +pool_version = "2.0.0" pool_qualifier = "TEST (BurnMintTokenPool 2.0.0 [default], LockReleaseTokenPool 2.0.0 [default])::LockReleaseTokenPool 2.0.0 [default]" -transfer_amount = "1000" +transfer_amount = "1000" execution_gas_limit = 500000 -finality_config = 1 +finality_config = 1 + +[prod-testnet.evm_to_canton] +pool_type = "BurnMintTokenPool" +pool_version = "2.0.0" +pool_qualifier = "TEST" +transfer_amount = "1000000000000001" +execution_gas_limit = 200001 +finality_config = 1 +remote_pool_type = "BurnMintTokenPool" +remote_pool_version = "2.0.0" +remote_pool_qualifier = "LINK" + +[prod-testnet.canton_to_evm] +pool_type = "BurnMintTokenPool" +pool_version = "2.0.0" +pool_qualifier = "LINK" +transfer_amount = "100" +execution_gas_limit = 500000 +finality_config = 1 +remote_pool_type = "BurnMintTokenPool" +remote_pool_version = "2.0.0" +remote_pool_qualifier = "TEST" +transfer_instrument_id = "link-token" From a0b7f765ddec379b5732198ba0e19683db14ecd9 Mon Sep 17 00:00:00 2001 From: Rodrigo Date: Tue, 7 Jul 2026 18:06:28 -0300 Subject: [PATCH 05/10] fix(tests): tests token + lint fix --- ccip/devenv/impl.go | 47 +++++++++++++++----- ccip/devenv/manual_execution.go | 3 +- ccip/devenv/tests/e2e/canton2evm_e2e_test.go | 20 +++------ ccip/devenv/tests/load/load_helpers.go | 3 +- ccip/devenv/tests/token_lane_test.go | 2 +- deployment/sequences/token_pools.go | 6 +-- 6 files changed, 49 insertions(+), 32 deletions(-) diff --git a/ccip/devenv/impl.go b/ccip/devenv/impl.go index 39d9fd3de..3fd4a1517 100644 --- a/ccip/devenv/impl.go +++ b/ccip/devenv/impl.go @@ -223,9 +223,11 @@ type Chain struct { validatorAPIClients validatorAPIClients feeTokenInstrument splice_api_token_holding_v1.InstrumentId + feeAmountPerMessage uint64 // per-message fee budget; used when rotating fee holdings after send nextFeeCID string // holding CID to be used as fee on next message send transferTokenInstrument *splice_api_token_holding_v1.InstrumentId nextTransferCID string // holding CID to be used as transfer on next message send + sendsLeft uint64 // remaining sends in current setup batch; 0 = always rotate // verifierObs is injected post-construction by test runners (see SetVerifierObservation). // Required by ConfirmExecOnDest to fetch verifier results from aggregator/indexer. @@ -1072,9 +1074,12 @@ func (c *Chain) SetupSend( } c.feeTokenInstrument = feeTokenInstrument + c.feeAmountPerMessage = feeAmountPerMessage c.nextFeeCID = selectedFee[0].ContractID if transferAmountPerMessage == nil || transferAmountPerMessage.Sign() <= 0 { + c.transferTokenInstrument = nil + c.nextTransferCID = "" return nil } @@ -1103,6 +1108,17 @@ func (c *Chain) SetupSend( return nil } +// SetSequentialSends limits holding rotation to the next sends messages in this setup batch. +// After the send whose on-chain seq equals nextSeq+sends, setNextHoldings is skipped. +// Pass 0 to always rotate (open-ended load). +func (c *Chain) SetSequentialSends(sends int) { + if sends <= 0 { + c.sendsLeft = 0 + return + } + c.sendsLeft = uint64(sends) +} + // MintTokens mint tokens for transfer and fees. To be used on devenv tests only. // this method won't work in staging/prod tests func (c *Chain) MintTokens(ctx context.Context, amount *big.Rat) error { @@ -1440,16 +1456,6 @@ func (c *Chain) SendMessage(ctx context.Context, dest uint64, fields cciptestint Uint64("seqNo", parsedSend.seqNo). Msg("CCIP Send executed") - // Set next holdings - var tokenAmount *big.Rat - if hasTokenTransfer { - tokenAmount = new(big.Rat).SetFrac(fields.TokenAmount.Amount, big.NewInt(CantonFixedPointScale)) - } - err = c.setNextHoldings(update.GetTransaction().GetEvents(), hasTokenTransfer, tokenAmount) - if err != nil { - return cciptestinterfaces.MessageSentEvent{}, fmt.Errorf("set next holdings: %w", err) - } - event := cciptestinterfaces.MessageSentEvent{ MessageID: parsedSend.messageID, ReceiptIssuers: nil, // TODO: add them later, not currently needed @@ -1462,6 +1468,24 @@ func (c *Chain) SendMessage(ctx context.Context, dest uint64, fields cciptestint c.lastSentSeq = parsedSend.seqNo c.lastSentEvent = event + c.sendsLeft-- + if c.sendsLeft == 0 { + c.logger.Info(). + Uint64("sendsLeft", c.sendsLeft). + Msg("Skipping holding rotation after last planned send in batch") + + return event, nil + } + + var tokenAmount *big.Rat + if hasTokenTransfer { + tokenAmount = new(big.Rat).SetFrac(fields.TokenAmount.Amount, big.NewInt(CantonFixedPointScale)) + } + err = c.setNextHoldings(update.GetTransaction().GetEvents(), hasTokenTransfer, tokenAmount) + if err != nil { + return cciptestinterfaces.MessageSentEvent{}, fmt.Errorf("set next holdings: %w", err) + } + return event, nil } @@ -1590,10 +1614,11 @@ func (c *Chain) setNextHoldings(events []*apiv2.Event, hasTokenTransfer bool, to testhelpers.ExcludeCIDs(spentCIDs), } + feeMin := new(big.Rat).SetUint64(c.feeAmountPerMessage) nextFeeCID, exhaustion, err := pickNextHolding( events, c.feeTokenInstrument, - big.NewRat(0, 1), // TODO: we'll have to consider real fee here once we go load tests + feeMin, refreshFilters..., ) if err != nil && !exhaustion { diff --git a/ccip/devenv/manual_execution.go b/ccip/devenv/manual_execution.go index ad2e74f6f..444d85623 100644 --- a/ccip/devenv/manual_execution.go +++ b/ccip/devenv/manual_execution.go @@ -40,6 +40,7 @@ func instanceIDFromEnv(key, defaultID string) contracts.InstanceID { if v := strings.TrimSpace(os.Getenv(key)); v != "" { return contracts.InstanceID(v) } + return contracts.InstanceID(defaultID) } @@ -149,8 +150,8 @@ func (c *Chain) DeployCCIPSender(ctx context.Context, participant canton.Partici } return contracts.InstanceAddress{}, fmt.Errorf("failed to deploy ccip sender contract: %w", err) } - c.senderAddress = senderAddress + return senderAddress, nil } diff --git a/ccip/devenv/tests/e2e/canton2evm_e2e_test.go b/ccip/devenv/tests/e2e/canton2evm_e2e_test.go index 1f67f934f..5a57847a7 100644 --- a/ccip/devenv/tests/e2e/canton2evm_e2e_test.go +++ b/ccip/devenv/tests/e2e/canton2evm_e2e_test.go @@ -54,8 +54,8 @@ func TestCanton2EVM_Basic(t *testing.T) { // TODO: currently minting 2 holdings of 2k for all the e2e tests. Otherwise one holding might conflict with the other. // the tests need to be hardened to not rely on this and instead correctly pick the holding that suffices. - require.NoError(t, cantonImpl.MintTokens(ctx, new(big.Rat).SetUint64(uint64(cantondevenv.CantonToEVMFeeAmount)*100))) - require.NoError(t, cantonImpl.MintTokens(ctx, new(big.Rat).SetUint64(uint64(cantondevenv.CantonToEVMFeeAmount)*100))) + require.NoError(t, cantonImpl.MintTokens(ctx, new(big.Rat).SetUint64(uint64(cantondevenv.CantonToEVMFeeAmount)*1000))) + require.NoError(t, cantonImpl.MintTokens(ctx, new(big.Rat).SetUint64(uint64(cantondevenv.CantonToEVMFeeAmount)*1000))) t.Run("EOA receiver and default committee verifier", func(t *testing.T) { subtestCtx := ccv.Plog.WithContext(t.Context()) @@ -150,17 +150,13 @@ func TestCanton2EVM_Basic(t *testing.T) { fee := uint64(cantondevenv.CantonToEVMTokenTransferFeeAmount) sends := cantondevenv.CantonToEVMTokenSequentialSends - feeTotal := new(big.Rat).SetUint64(uint64(sends) * fee) - transferTotalFP := new(big.Int).Mul(lane.TransferAmount, big.NewInt(int64(sends))) - transferTotal := new(big.Rat).SetFrac(transferTotalFP, big.NewInt(cantondevenv.CantonFixedPointScale)) transferPerSend := new(big.Rat).SetFrac(lane.TransferAmount, big.NewInt(cantondevenv.CantonFixedPointScale)) - require.NoError(t, cantonImpl.MintTokens(ctx, feeTotal)) - require.NoError(t, cantonImpl.MintTokens(ctx, transferTotal)) if lane.TransferInstrument.Admin != "" { require.NoError(t, cantonImpl.SetupSend(ctx, fee, transferPerSend, lane.TransferInstrument)) } else { require.NoError(t, cantonImpl.SetupSend(ctx, fee, transferPerSend)) } + cantonImpl.SetSequentialSends(sends) ds, err := lib.DataStore() require.NoError(t, err) @@ -245,16 +241,13 @@ func TestCanton2EVM_Basic(t *testing.T) { lane := devenvtests.ResolveTokenLane(t, devenvtests.EnvDevenv, in, lib, chainMap, cantonChain.ChainSelector(), []uint64{evmChain.ChainSelector()}) fee := uint64(cantondevenv.CantonToEVMTokenTransferFeeAmount) - feeTotal := new(big.Rat).SetUint64(fee) - transferTotal := new(big.Rat).SetFrac(lane.TransferAmount, big.NewInt(cantondevenv.CantonFixedPointScale)) transferPerSend := new(big.Rat).SetFrac(lane.TransferAmount, big.NewInt(cantondevenv.CantonFixedPointScale)) - require.NoError(t, cantonImpl.MintTokens(ctx, feeTotal)) - require.NoError(t, cantonImpl.MintTokens(ctx, transferTotal)) if lane.TransferInstrument.Admin != "" { require.NoError(t, cantonImpl.SetupSend(ctx, fee, transferPerSend, lane.TransferInstrument)) } else { require.NoError(t, cantonImpl.SetupSend(ctx, fee, transferPerSend)) } + cantonImpl.SetSequentialSends(1) receiver, err := evmChain.GetEOAReceiverAddress() require.NoError(t, err) @@ -296,16 +289,13 @@ func TestCanton2EVM_Basic(t *testing.T) { lane := devenvtests.ResolveTokenLane(t, devenvtests.EnvDevenv, in, lib, chainMap, cantonChain.ChainSelector(), []uint64{evmChain.ChainSelector()}) fee := uint64(cantondevenv.CantonToEVMTokenTransferFeeAmount) - feeTotal := new(big.Rat).SetUint64(fee) - transferTotal := new(big.Rat).SetFrac(lane.TransferAmount, big.NewInt(cantondevenv.CantonFixedPointScale)) transferPerSend := new(big.Rat).SetFrac(lane.TransferAmount, big.NewInt(cantondevenv.CantonFixedPointScale)) - require.NoError(t, cantonImpl.MintTokens(ctx, feeTotal)) - require.NoError(t, cantonImpl.MintTokens(ctx, transferTotal)) if lane.TransferInstrument.Admin != "" { require.NoError(t, cantonImpl.SetupSend(ctx, fee, transferPerSend, lane.TransferInstrument)) } else { require.NoError(t, cantonImpl.SetupSend(ctx, fee, transferPerSend)) } + cantonImpl.SetSequentialSends(1) ds, err := lib.DataStore() require.NoError(t, err) diff --git a/ccip/devenv/tests/load/load_helpers.go b/ccip/devenv/tests/load/load_helpers.go index 7f2d820d8..115a8e532 100644 --- a/ccip/devenv/tests/load/load_helpers.go +++ b/ccip/devenv/tests/load/load_helpers.go @@ -267,7 +267,7 @@ func setupCantonTokenLoadHoldings( estimated := estimateMessages(sched) fee := uint64(cantondevenv.CantonToEVMTokenTransferFeeAmount) feeTotal := new(big.Rat).SetUint64(estimated * fee) - transferTotalFP := new(big.Int).Mul(lane.TransferAmount, big.NewInt(int64(estimated))) + transferTotalFP := new(big.Int).Mul(lane.TransferAmount, new(big.Int).SetUint64(estimated)) transferTotal := new(big.Rat).SetFrac(transferTotalFP, big.NewInt(cantondevenv.CantonFixedPointScale)) transferPerSend := new(big.Rat).SetFrac(lane.TransferAmount, big.NewInt(cantondevenv.CantonFixedPointScale)) t.Logf("Pre-mint: estimatedMessages=%d feeTotal=%s transferTotal=%s", @@ -279,6 +279,7 @@ func setupCantonTokenLoadHoldings( } else { require.NoError(t, cantonImpl.SetupSend(ctx, fee, transferPerSend)) } + cantonImpl.SetSequentialSends(int(estimated)) } func evmLoadDestination(chain cciptestinterfaces.CCIP17, receiver protocol.UnknownAddress) Destination { diff --git a/ccip/devenv/tests/token_lane_test.go b/ccip/devenv/tests/token_lane_test.go index 88f9ba2d2..2c2470779 100644 --- a/ccip/devenv/tests/token_lane_test.go +++ b/ccip/devenv/tests/token_lane_test.go @@ -127,7 +127,7 @@ func TestNormalizeTransferInstrumentID(t *testing.T) { require.Equal(t, "link-token", normalizeTransferInstrumentID("LINK")) require.Equal(t, "link-token", normalizeTransferInstrumentID("link")) require.Equal(t, "link-token", normalizeTransferInstrumentID(" link ")) - require.Equal(t, "amulet", normalizeTransferInstrumentID("Amulet")) + require.Equal(t, "Amulet", normalizeTransferInstrumentID("Amulet")) require.Equal(t, "link-token", normalizeTransferInstrumentID("link-token")) } diff --git a/deployment/sequences/token_pools.go b/deployment/sequences/token_pools.go index 582916259..be082f958 100644 --- a/deployment/sequences/token_pools.go +++ b/deployment/sequences/token_pools.go @@ -592,12 +592,12 @@ var DeployTokenPoolForToken = operations.NewSequence( return ccipsequences.OnChainOutput{}, fmt.Errorf("tokenRef.address is required") } tokenRef := datastore.AddressRef{ - Address: tokenAddress, - Type: datastore.ContractType("Token"), - // TODO: what should this be set to? + Address: tokenAddress, + Type: datastore.ContractType("Token"), Version: input.TokenPoolVersion, Qualifier: qualifier, ChainSelector: input.ChainSelector, + Labels: input.TokenRef.Labels, } if mcmsEnabled && len(proposalOutputs) > 0 { From 6f70df2370a07429a0201d54ee68b2df417625e5 Mon Sep 17 00:00:00 2001 From: Rodrigo Date: Tue, 7 Jul 2026 22:19:56 -0300 Subject: [PATCH 06/10] try fixing tokens holdings issue --- ccip/devenv/impl.go | 106 +++++++++++++++---- ccip/devenv/tests/e2e/canton2evm_e2e_test.go | 4 +- 2 files changed, 87 insertions(+), 23 deletions(-) diff --git a/ccip/devenv/impl.go b/ccip/devenv/impl.go index 3fd4a1517..3f56bc14d 100644 --- a/ccip/devenv/impl.go +++ b/ccip/devenv/impl.go @@ -1068,14 +1068,14 @@ func (c *Chain) SetupSend( return fmt.Errorf("list fee holdings for setup: %w", err) } feeMin := new(big.Rat).SetUint64(feeAmountPerMessage) - selectedFee, err := testhelpers.SelectHoldingsForInstrument(feeRows, []*big.Rat{feeMin}) - if err != nil || len(selectedFee) == 0 { + selectedFee, err := c.selectHolding("setup-fee", feeTokenInstrument, feeRows, feeMin) + if err != nil { return fmt.Errorf("select fee holding for setup: %w", err) } c.feeTokenInstrument = feeTokenInstrument c.feeAmountPerMessage = feeAmountPerMessage - c.nextFeeCID = selectedFee[0].ContractID + c.nextFeeCID = selectedFee.ContractID if transferAmountPerMessage == nil || transferAmountPerMessage.Sign() <= 0 { c.transferTokenInstrument = nil @@ -1097,13 +1097,13 @@ func (c *Chain) SetupSend( return fmt.Errorf("list transfer holdings for setup: %w", err) } transferMin := new(big.Rat).Set(transferAmountPerMessage) - selectedTransfer, err := testhelpers.SelectHoldingsForInstrument(transferRows, []*big.Rat{transferMin}) - if err != nil || len(selectedTransfer) == 0 { + selectedTransfer, err := c.selectHolding("setup-transfer", *transferTokenInstrument, transferRows, transferMin) + if err != nil { return fmt.Errorf("select transfer holding for setup: %w", err) } c.transferTokenInstrument = transferTokenInstrument - c.nextTransferCID = selectedTransfer[0].ContractID + c.nextTransferCID = selectedTransfer.ContractID return nil } @@ -1615,18 +1615,17 @@ func (c *Chain) setNextHoldings(events []*apiv2.Event, hasTokenTransfer bool, to } feeMin := new(big.Rat).SetUint64(c.feeAmountPerMessage) - nextFeeCID, exhaustion, err := pickNextHolding( + nextFeeCID, feeExhausted, err := c.pickNextHolding( events, c.feeTokenInstrument, feeMin, refreshFilters..., ) - if err != nil && !exhaustion { + if err != nil && !feeExhausted { return fmt.Errorf("refresh next fee holding from update: %w", err) } - if !exhaustion { + if !feeExhausted { c.nextFeeCID = nextFeeCID - c.logger.Info().Str("NextFeeCID", c.nextFeeCID).Msg("Selected next fee holding") } else { c.nextFeeCID = "" c.logger.Info().Msg("No next fee holding available after send; clearing for future sends") @@ -1641,18 +1640,17 @@ func (c *Chain) setNextHoldings(events []*apiv2.Event, hasTokenTransfer bool, to testhelpers.ExcludeCIDs(append(spentCIDs, c.nextFeeCID)), ) transferValue := new(big.Rat).Set(tokenAmount) - nextTransferCID, exhaustion, err := pickNextHolding( + nextTransferCID, transferExhausted, err := c.pickNextHolding( events, *c.transferTokenInstrument, transferValue, transferFilters..., ) - if err != nil && !exhaustion { + if err != nil && !transferExhausted { return fmt.Errorf("refresh next transfer holding from update: %w", err) } - if !exhaustion { + if !transferExhausted { c.nextTransferCID = nextTransferCID - c.logger.Info().Str("NextTransferCID", c.nextTransferCID).Msg("Selected next transfer holding") } else { c.nextTransferCID = "" c.logger.Info().Msg("No next transfer holding available after send; clearing for future sends") @@ -1663,27 +1661,93 @@ func (c *Chain) setNextHoldings(events []*apiv2.Event, hasTokenTransfer bool, to // pickNextHolding chooses an unused holding for the next send from Created events in the // send transaction only. Returns an empty CID when nothing meets minAmount (not an error). -func pickNextHolding( +func (c *Chain) pickNextHolding( events []*apiv2.Event, instrument splice_api_token_holding_v1.InstrumentId, minAmount *big.Rat, filters ...testhelpers.Filter, ) (string, bool, error) { - minAmounts := []*big.Rat{minAmount} - fromEvents, err := testhelpers.ListedHoldingsFromTransactionEventsForInstrument(events, instrument, filters...) if err != nil { return "", false, err } if len(fromEvents) == 0 { - return "", true, nil // no qualifying Created events → exhaustion, next send will fail + c.logger.Info(). + Str("Source", "rotate"). + Str("InstrumentAdmin", string(instrument.Admin)). + Str("InstrumentId", string(instrument.Id)). + Str("MinRequired", minAmount.FloatString(10)). + Int("Candidates", 0). + Msg("No qualifying holding in send events") + + return "", true, nil + } + + picked, err := c.selectHolding("rotate", instrument, fromEvents, minAmount) + if err != nil { + return "", true, err + } + + return picked.ContractID, false, nil +} + +func (c *Chain) selectHolding( + source string, + instrument splice_api_token_holding_v1.InstrumentId, + rows []testhelpers.ListedHolding, + min *big.Rat, +) (testhelpers.ListedHolding, error) { + if min == nil { + min = big.NewRat(0, 1) } - picked, err := testhelpers.SelectHoldingsForInstrument(fromEvents, minAmounts) + + picked, err := testhelpers.SelectHoldingsForInstrument(rows, []*big.Rat{min}) if err != nil || len(picked) == 0 { - return "", true, fmt.Errorf("refresh next fee holding from update: %w", err) + c.logHoldingSelectionFailure(source, instrument, min, rows, err) + if err != nil { + return testhelpers.ListedHolding{}, err + } + + return testhelpers.ListedHolding{}, fmt.Errorf("no qualifying holding for %s", source) } - return picked[0].ContractID, false, nil + c.logger.Info(). + Str("Source", source). + Str("ContractID", picked[0].ContractID). + Str("Amount", picked[0].Amount.FloatString(10)). + Str("InstrumentAdmin", string(instrument.Admin)). + Str("InstrumentId", string(instrument.Id)). + Str("MinRequired", min.FloatString(10)). + Int("Candidates", len(rows)). + Msg("Selected holding") + + return picked[0], nil +} + +func (c *Chain) logHoldingSelectionFailure( + source string, + instrument splice_api_token_holding_v1.InstrumentId, + min *big.Rat, + rows []testhelpers.ListedHolding, + selectErr error, +) { + logEvent := c.logger.Info(). + Str("Source", source). + Str("InstrumentAdmin", string(instrument.Admin)). + Str("InstrumentId", string(instrument.Id)). + Str("MinRequired", min.FloatString(10)). + Int("Candidates", len(rows)) + if selectErr != nil { + logEvent = logEvent.Err(selectErr) + } + logEvent.Msg("No qualifying holding selected") + for _, row := range rows { + c.logger.Debug(). + Str("Source", source). + Str("ContractID", row.ContractID). + Str("Amount", row.Amount.FloatString(10)). + Msg("Holding candidate") + } } func (c *Chain) waitOneSentEventBySeqNo(ctx context.Context, to, seq uint64, timeout time.Duration) (cciptestinterfaces.MessageSentEvent, error) { diff --git a/ccip/devenv/tests/e2e/canton2evm_e2e_test.go b/ccip/devenv/tests/e2e/canton2evm_e2e_test.go index 5a57847a7..da46bf6e1 100644 --- a/ccip/devenv/tests/e2e/canton2evm_e2e_test.go +++ b/ccip/devenv/tests/e2e/canton2evm_e2e_test.go @@ -54,8 +54,8 @@ func TestCanton2EVM_Basic(t *testing.T) { // TODO: currently minting 2 holdings of 2k for all the e2e tests. Otherwise one holding might conflict with the other. // the tests need to be hardened to not rely on this and instead correctly pick the holding that suffices. - require.NoError(t, cantonImpl.MintTokens(ctx, new(big.Rat).SetUint64(uint64(cantondevenv.CantonToEVMFeeAmount)*1000))) - require.NoError(t, cantonImpl.MintTokens(ctx, new(big.Rat).SetUint64(uint64(cantondevenv.CantonToEVMFeeAmount)*1000))) + require.NoError(t, cantonImpl.MintTokens(ctx, new(big.Rat).SetUint64(uint64(cantondevenv.CantonToEVMFeeAmount)*10000))) + require.NoError(t, cantonImpl.MintTokens(ctx, new(big.Rat).SetUint64(uint64(cantondevenv.CantonToEVMFeeAmount)*10000))) t.Run("EOA receiver and default committee verifier", func(t *testing.T) { subtestCtx := ccv.Plog.WithContext(t.Context()) From 29e75371a6acc69ad24ead4715943f8af66dda50 Mon Sep 17 00:00:00 2001 From: Rodrigo Date: Tue, 7 Jul 2026 22:48:34 -0300 Subject: [PATCH 07/10] raise canton transfer amount --- ccip/devenv/tests/token_transfer_config.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ccip/devenv/tests/token_transfer_config.toml b/ccip/devenv/tests/token_transfer_config.toml index 10530c621..e6ff634e7 100644 --- a/ccip/devenv/tests/token_transfer_config.toml +++ b/ccip/devenv/tests/token_transfer_config.toml @@ -21,7 +21,7 @@ finality_config = 1 pool_type = "LockReleaseTokenPool" pool_version = "2.0.0" pool_qualifier = "TEST (BurnMintTokenPool 2.0.0 [default], LockReleaseTokenPool 2.0.0 [default])::LockReleaseTokenPool 2.0.0 [default]" -transfer_amount = "1000" +transfer_amount = "10000000001" execution_gas_limit = 500000 finality_config = 1 From 1b3d61df4c630f2bf267af4ca5f9d0e9a37246a3 Mon Sep 17 00:00:00 2001 From: Rodrigo Date: Tue, 7 Jul 2026 23:12:19 -0300 Subject: [PATCH 08/10] fix setupSend first hold picking --- ccip/devenv/impl.go | 24 ++++++++++++++------ ccip/devenv/tests/e2e/canton2evm_e2e_test.go | 7 +++--- ccip/devenv/tests/load/load_helpers.go | 2 +- 3 files changed, 22 insertions(+), 11 deletions(-) diff --git a/ccip/devenv/impl.go b/ccip/devenv/impl.go index 3f56bc14d..b08a5e95e 100644 --- a/ccip/devenv/impl.go +++ b/ccip/devenv/impl.go @@ -1018,11 +1018,16 @@ func (c *Chain) SetupReceive(ctx context.Context) error { // SetupSend sets up Canton sender specific prerequisites for sending a message. // eg: deploy per-party router, deploy ccipsender contract... +// +// feePerMessage is stored and used as the per-send fee budget in SendMessage and holding rotation. +// transferPerMessage is the per-send transfer amount (nil or ≤0 → message-only). Initial holding +// selection uses perMessage × sendsLeft when SetSequentialSends was called with sends > 0. +// // transferInstrument defaults to Amulet under registryAdmin when omitted or Admin is empty. func (c *Chain) SetupSend( ctx context.Context, - feeAmountPerMessage uint64, - transferAmountPerMessage *big.Rat, + feePerMessage uint64, + transferPerMessage *big.Rat, transferInstrument ...splice_api_token_holding_v1.InstrumentId, ) error { participant, _, err := c.ClientParticipant() @@ -1067,17 +1072,20 @@ func (c *Chain) SetupSend( if err != nil { return fmt.Errorf("list fee holdings for setup: %w", err) } - feeMin := new(big.Rat).SetUint64(feeAmountPerMessage) + feeMin := new(big.Rat).SetUint64(feePerMessage) + if c.sendsLeft > 0 { + feeMin.Mul(feeMin, new(big.Rat).SetUint64(c.sendsLeft)) + } selectedFee, err := c.selectHolding("setup-fee", feeTokenInstrument, feeRows, feeMin) if err != nil { return fmt.Errorf("select fee holding for setup: %w", err) } c.feeTokenInstrument = feeTokenInstrument - c.feeAmountPerMessage = feeAmountPerMessage + c.feeAmountPerMessage = feePerMessage c.nextFeeCID = selectedFee.ContractID - if transferAmountPerMessage == nil || transferAmountPerMessage.Sign() <= 0 { + if transferPerMessage == nil || transferPerMessage.Sign() <= 0 { c.transferTokenInstrument = nil c.nextTransferCID = "" return nil @@ -1096,7 +1104,10 @@ func (c *Chain) SetupSend( if err != nil { return fmt.Errorf("list transfer holdings for setup: %w", err) } - transferMin := new(big.Rat).Set(transferAmountPerMessage) + transferMin := new(big.Rat).Set(transferPerMessage) + if c.sendsLeft > 0 { + transferMin.Mul(transferMin, new(big.Rat).SetUint64(c.sendsLeft)) + } selectedTransfer, err := c.selectHolding("setup-transfer", *transferTokenInstrument, transferRows, transferMin) if err != nil { return fmt.Errorf("select transfer holding for setup: %w", err) @@ -1110,7 +1121,6 @@ func (c *Chain) SetupSend( // SetSequentialSends limits holding rotation to the next sends messages in this setup batch. // After the send whose on-chain seq equals nextSeq+sends, setNextHoldings is skipped. -// Pass 0 to always rotate (open-ended load). func (c *Chain) SetSequentialSends(sends int) { if sends <= 0 { c.sendsLeft = 0 diff --git a/ccip/devenv/tests/e2e/canton2evm_e2e_test.go b/ccip/devenv/tests/e2e/canton2evm_e2e_test.go index da46bf6e1..3d14fb393 100644 --- a/ccip/devenv/tests/e2e/canton2evm_e2e_test.go +++ b/ccip/devenv/tests/e2e/canton2evm_e2e_test.go @@ -61,6 +61,7 @@ func TestCanton2EVM_Basic(t *testing.T) { subtestCtx := ccv.Plog.WithContext(t.Context()) // Setup message send + cantonImpl.SetSequentialSends(1) require.NoError(t, cantonImpl.SetupSend(ctx, uint64(cantondevenv.CantonToEVMFeeAmount), nil)) ds, err := lib.DataStore() @@ -151,12 +152,12 @@ func TestCanton2EVM_Basic(t *testing.T) { fee := uint64(cantondevenv.CantonToEVMTokenTransferFeeAmount) sends := cantondevenv.CantonToEVMTokenSequentialSends transferPerSend := new(big.Rat).SetFrac(lane.TransferAmount, big.NewInt(cantondevenv.CantonFixedPointScale)) + cantonImpl.SetSequentialSends(sends) if lane.TransferInstrument.Admin != "" { require.NoError(t, cantonImpl.SetupSend(ctx, fee, transferPerSend, lane.TransferInstrument)) } else { require.NoError(t, cantonImpl.SetupSend(ctx, fee, transferPerSend)) } - cantonImpl.SetSequentialSends(sends) ds, err := lib.DataStore() require.NoError(t, err) @@ -242,12 +243,12 @@ func TestCanton2EVM_Basic(t *testing.T) { fee := uint64(cantondevenv.CantonToEVMTokenTransferFeeAmount) transferPerSend := new(big.Rat).SetFrac(lane.TransferAmount, big.NewInt(cantondevenv.CantonFixedPointScale)) + cantonImpl.SetSequentialSends(1) if lane.TransferInstrument.Admin != "" { require.NoError(t, cantonImpl.SetupSend(ctx, fee, transferPerSend, lane.TransferInstrument)) } else { require.NoError(t, cantonImpl.SetupSend(ctx, fee, transferPerSend)) } - cantonImpl.SetSequentialSends(1) receiver, err := evmChain.GetEOAReceiverAddress() require.NoError(t, err) @@ -290,12 +291,12 @@ func TestCanton2EVM_Basic(t *testing.T) { fee := uint64(cantondevenv.CantonToEVMTokenTransferFeeAmount) transferPerSend := new(big.Rat).SetFrac(lane.TransferAmount, big.NewInt(cantondevenv.CantonFixedPointScale)) + cantonImpl.SetSequentialSends(1) if lane.TransferInstrument.Admin != "" { require.NoError(t, cantonImpl.SetupSend(ctx, fee, transferPerSend, lane.TransferInstrument)) } else { require.NoError(t, cantonImpl.SetupSend(ctx, fee, transferPerSend)) } - cantonImpl.SetSequentialSends(1) ds, err := lib.DataStore() require.NoError(t, err) diff --git a/ccip/devenv/tests/load/load_helpers.go b/ccip/devenv/tests/load/load_helpers.go index 115a8e532..d29538203 100644 --- a/ccip/devenv/tests/load/load_helpers.go +++ b/ccip/devenv/tests/load/load_helpers.go @@ -274,12 +274,12 @@ func setupCantonTokenLoadHoldings( estimated, feeTotal.FloatString(10), transferTotal.FloatString(10)) require.NoError(t, cantonImpl.MintTokens(ctx, feeTotal)) require.NoError(t, cantonImpl.MintTokens(ctx, transferTotal)) + cantonImpl.SetSequentialSends(int(estimated)) if lane.TransferInstrument.Admin != "" { require.NoError(t, cantonImpl.SetupSend(ctx, fee, transferPerSend, lane.TransferInstrument)) } else { require.NoError(t, cantonImpl.SetupSend(ctx, fee, transferPerSend)) } - cantonImpl.SetSequentialSends(int(estimated)) } func evmLoadDestination(chain cciptestinterfaces.CCIP17, receiver protocol.UnknownAddress) Destination { From 4e05025bd61e67d56b38c3c7777123f2558289d7 Mon Sep 17 00:00:00 2001 From: Rodrigo Date: Tue, 7 Jul 2026 23:25:46 -0300 Subject: [PATCH 09/10] fix lint + unit tests --- ccip/devenv/impl.go | 16 ++++++++-------- ccip/devenv/manual_execution.go | 1 + ccip/devenv/tests/load/load_helpers.go | 4 +++- ccip/devenv/tests/token_lane.go | 2 ++ ccip/devenv/tests/token_lane_test.go | 2 +- 5 files changed, 15 insertions(+), 10 deletions(-) diff --git a/ccip/devenv/impl.go b/ccip/devenv/impl.go index b08a5e95e..51f63077e 100644 --- a/ccip/devenv/impl.go +++ b/ccip/devenv/impl.go @@ -1705,15 +1705,15 @@ func (c *Chain) selectHolding( source string, instrument splice_api_token_holding_v1.InstrumentId, rows []testhelpers.ListedHolding, - min *big.Rat, + minAmount *big.Rat, ) (testhelpers.ListedHolding, error) { - if min == nil { - min = big.NewRat(0, 1) + if minAmount == nil { + minAmount = big.NewRat(0, 1) } - picked, err := testhelpers.SelectHoldingsForInstrument(rows, []*big.Rat{min}) + picked, err := testhelpers.SelectHoldingsForInstrument(rows, []*big.Rat{minAmount}) if err != nil || len(picked) == 0 { - c.logHoldingSelectionFailure(source, instrument, min, rows, err) + c.logHoldingSelectionFailure(source, instrument, minAmount, rows, err) if err != nil { return testhelpers.ListedHolding{}, err } @@ -1727,7 +1727,7 @@ func (c *Chain) selectHolding( Str("Amount", picked[0].Amount.FloatString(10)). Str("InstrumentAdmin", string(instrument.Admin)). Str("InstrumentId", string(instrument.Id)). - Str("MinRequired", min.FloatString(10)). + Str("MinRequired", minAmount.FloatString(10)). Int("Candidates", len(rows)). Msg("Selected holding") @@ -1737,7 +1737,7 @@ func (c *Chain) selectHolding( func (c *Chain) logHoldingSelectionFailure( source string, instrument splice_api_token_holding_v1.InstrumentId, - min *big.Rat, + minAmount *big.Rat, rows []testhelpers.ListedHolding, selectErr error, ) { @@ -1745,7 +1745,7 @@ func (c *Chain) logHoldingSelectionFailure( Str("Source", source). Str("InstrumentAdmin", string(instrument.Admin)). Str("InstrumentId", string(instrument.Id)). - Str("MinRequired", min.FloatString(10)). + Str("MinRequired", minAmount.FloatString(10)). Int("Candidates", len(rows)) if selectErr != nil { logEvent = logEvent.Err(selectErr) diff --git a/ccip/devenv/manual_execution.go b/ccip/devenv/manual_execution.go index 444d85623..df58d74d9 100644 --- a/ccip/devenv/manual_execution.go +++ b/ccip/devenv/manual_execution.go @@ -148,6 +148,7 @@ func (c *Chain) DeployCCIPSender(ctx context.Context, participant canton.Partici c.senderAddress = senderAddress return senderAddress, nil } + return contracts.InstanceAddress{}, fmt.Errorf("failed to deploy ccip sender contract: %w", err) } c.senderAddress = senderAddress diff --git a/ccip/devenv/tests/load/load_helpers.go b/ccip/devenv/tests/load/load_helpers.go index d29538203..10641fddf 100644 --- a/ccip/devenv/tests/load/load_helpers.go +++ b/ccip/devenv/tests/load/load_helpers.go @@ -3,6 +3,7 @@ package load import ( "context" "fmt" + "math" "math/big" "os" "strings" @@ -274,7 +275,8 @@ func setupCantonTokenLoadHoldings( estimated, feeTotal.FloatString(10), transferTotal.FloatString(10)) require.NoError(t, cantonImpl.MintTokens(ctx, feeTotal)) require.NoError(t, cantonImpl.MintTokens(ctx, transferTotal)) - cantonImpl.SetSequentialSends(int(estimated)) + require.LessOrEqual(t, estimated, uint64(math.MaxInt)) + cantonImpl.SetSequentialSends(int(estimated)) //nolint:gosec // bounded by require above if lane.TransferInstrument.Admin != "" { require.NoError(t, cantonImpl.SetupSend(ctx, fee, transferPerSend, lane.TransferInstrument)) } else { diff --git a/ccip/devenv/tests/token_lane.go b/ccip/devenv/tests/token_lane.go index ad1607e74..85a9f4e37 100644 --- a/ccip/devenv/tests/token_lane.go +++ b/ccip/devenv/tests/token_lane.go @@ -239,11 +239,13 @@ func tokenDirectionsForEnv(cfg tokenConfigTOML, env CCIPEnv) (tokenDirectionsTOM if cfg.Devenv.EVMToCanton.PoolType == "" && cfg.Devenv.CantonToEVM.PoolType == "" { return tokenDirectionsTOML{}, fmt.Errorf("missing [devenv.*] sections") } + return cfg.Devenv, nil case EnvProdTestnet: if cfg.ProdTestnet.EVMToCanton.PoolType == "" && cfg.ProdTestnet.CantonToEVM.PoolType == "" { return tokenDirectionsTOML{}, fmt.Errorf("missing [prod-testnet.*] sections") } + return cfg.ProdTestnet, nil default: return tokenDirectionsTOML{}, fmt.Errorf("unsupported ccip env %q", env) diff --git a/ccip/devenv/tests/token_lane_test.go b/ccip/devenv/tests/token_lane_test.go index 2c2470779..07fe0c8df 100644 --- a/ccip/devenv/tests/token_lane_test.go +++ b/ccip/devenv/tests/token_lane_test.go @@ -55,7 +55,7 @@ func TestLoadTokenDirection_transferAmountParsing(t *testing.T) { require.Equal(t, "100000000001", evmDir.TransferAmount.String()) cantonDir := loadTokenDirection(t, EnvDevenv, directionCantonToEVM) - require.Equal(t, big.NewInt(1000), cantonDir.TransferAmount) + require.Equal(t, big.NewInt(10000000001), cantonDir.TransferAmount) prodCantonDir := loadTokenDirection(t, EnvProdTestnet, directionCantonToEVM) require.Equal(t, big.NewInt(100), prodCantonDir.TransferAmount) From 271d3532f60518addd24b859514dc72dcc7fd658 Mon Sep 17 00:00:00 2001 From: Rodrigo Date: Wed, 8 Jul 2026 00:00:35 -0300 Subject: [PATCH 10/10] fix load tests CI --- .github/actions/ccip-load-test/action.yml | 28 ++------ .github/workflows/ccip-load-command.yml | 18 +++++ .github/workflows/ccip-load-tests.yml | 69 +++++++++++++++----- ccip/devenv/tests/token_transfer_config.toml | 2 +- 4 files changed, 77 insertions(+), 40 deletions(-) diff --git a/.github/actions/ccip-load-test/action.yml b/.github/actions/ccip-load-test/action.yml index 18ededfb7..d85c38c4b 100644 --- a/.github/actions/ccip-load-test/action.yml +++ b/.github/actions/ccip-load-test/action.yml @@ -1,7 +1,8 @@ name: Run CCIP load test description: >- - Shared setup and execution for CCIP WASP load tests against local devenv or - prod-testnet infrastructure. + Shared execution for CCIP WASP load tests against local devenv or prod-testnet + infrastructure. Call setup-ccip-devenv and upload-ccip-devenv-logs separately + when you want distinct workflow steps (see ccip-load-tests.yml). inputs: ccip_env: @@ -77,16 +78,6 @@ inputs: runs: using: composite steps: - - name: Setup CCIP devenv - if: inputs.ccip_env == 'devenv' - uses: ./.github/actions/setup-ccip-devenv - with: - canton-ref: ${{ inputs.canton_ref }} - canton-path: ${{ inputs.canton_path }} - ccv-iam-role: ${{ inputs.ccv-iam-role }} - jd-registry: ${{ inputs.jd-registry }} - jd-image: ${{ inputs.jd-image }} - - name: Install Go (prod-testnet) if: inputs.ccip_env == 'prod-testnet' uses: actions/setup-go@v6 @@ -116,7 +107,7 @@ runs: evm2canton-token) TEST_RUN='^TestEVM2Canton_TokenLoad$' ;; *) echo "unknown direction: ${{ inputs.direction }}" >&2; exit 1 ;; esac - go test -timeout "${{ inputs.test_timeout }}" -v -count 1 -ccip-env=devenv -run "$TEST_RUN" + go test -timeout "${{ inputs.test_timeout }}" -v -count 1 -run "$TEST_RUN" - name: Run load tests (prod-testnet) if: inputs.ccip_env == 'prod-testnet' @@ -144,13 +135,4 @@ runs: evm2canton-token) TEST_RUN='^TestEVM2Canton_TokenLoad$' ;; *) echo "unknown direction: ${{ inputs.direction }}" >&2; exit 1 ;; esac - go test -timeout "${{ inputs.test_timeout }}" -v -count 1 -ccip-env=prod-testnet -run "$TEST_RUN" - - - name: Upload devenv logs - if: always() && inputs.ccip_env == 'devenv' - uses: ./.github/actions/upload-ccip-devenv-logs - with: - canton-path: ${{ inputs.canton_path }} - test-package-dir: load - log-dump-suffix: ccip-load-tests - artifact-name: container-logs-ccip-load-tests + go test -timeout "${{ inputs.test_timeout }}" -v -count 1 -run "$TEST_RUN" diff --git a/.github/workflows/ccip-load-command.yml b/.github/workflows/ccip-load-command.yml index 1016f0022..cfcf4db12 100644 --- a/.github/workflows/ccip-load-command.yml +++ b/.github/workflows/ccip-load-command.yml @@ -79,6 +79,15 @@ jobs: echo "test_timeout=30m" >> "$GITHUB_OUTPUT" fi + - name: Setup CCIP devenv + if: steps.params.outputs.ccip_env == 'devenv' + uses: ./.github/actions/setup-ccip-devenv + with: + canton-path: . + ccv-iam-role: ${{ secrets.CCV_IAM_ROLE }} + jd-registry: ${{ secrets.JD_REGISTRY }} + jd-image: ${{ secrets.JD_IMAGE }} + - name: Run CCIP load test uses: ./.github/actions/ccip-load-test with: @@ -98,6 +107,15 @@ jobs: CANTON_OKTA_CLIENT_SECRET_TESTNET: ${{ secrets.CANTON_OKTA_CLIENT_SECRET_TESTNET }} CCIP_PROD_TESTNET_PRIVATE_KEY: ${{ secrets.CCIP_PROD_TESTNET_PRIVATE_KEY }} + - name: Upload devenv logs + if: always() && steps.params.outputs.ccip_env == 'devenv' + uses: ./.github/actions/upload-ccip-devenv-logs + with: + canton-path: . + test-package-dir: load + log-dump-suffix: ccip-load-tests + artifact-name: container-logs-ccip-load-tests + - name: Update comment if: always() uses: mshick/add-pr-comment@b8f338c590a895d50bcbfa6c5859251edc8952fc # v2.8.2 diff --git a/.github/workflows/ccip-load-tests.yml b/.github/workflows/ccip-load-tests.yml index 77e9b655b..8fd9e3656 100644 --- a/.github/workflows/ccip-load-tests.yml +++ b/.github/workflows/ccip-load-tests.yml @@ -104,23 +104,60 @@ jobs: echo "test_timeout=${{ inputs.test_timeout }}" >> "$GITHUB_OUTPUT" fi - - name: Run CCIP load test - uses: ./.github/actions/ccip-load-test + - name: Setup CCIP devenv + if: inputs.ccip_env == 'devenv' + uses: ./.github/actions/setup-ccip-devenv with: - ccip_env: ${{ inputs.ccip_env }} - direction: ${{ inputs.direction }} - message_rate: ${{ steps.prod_defaults.outputs.message_rate }} - load_duration: ${{ steps.prod_defaults.outputs.load_duration }} - test_timeout: ${{ steps.prod_defaults.outputs.test_timeout }} - config_file: ${{ inputs.config_file }} - canton_path: ${{ steps.prod_defaults.outputs.canton_path }} - canton_ref: ${{ inputs.canton_ref }} - skip_exec_confirm: ${{ inputs.skip_exec_confirm }} - confirm_exec_timeout: ${{ inputs.confirm_exec_timeout }} - CANTON_OKTA_AUTHORIZER_TESTNET: ${{ secrets.CANTON_OKTA_AUTHORIZER_TESTNET }} - CANTON_OKTA_CLIENT_ID_TESTNET: ${{ secrets.CANTON_OKTA_CLIENT_ID_TESTNET }} - CANTON_OKTA_CLIENT_SECRET_TESTNET: ${{ secrets.CANTON_OKTA_CLIENT_SECRET_TESTNET }} - CCIP_PROD_TESTNET_PRIVATE_KEY: ${{ secrets.CCIP_PROD_TESTNET_PRIVATE_KEY }} + canton-ref: ${{ inputs.canton_ref }} + canton-path: ${{ steps.prod_defaults.outputs.canton_path }} ccv-iam-role: ${{ secrets.CCV_IAM_ROLE }} jd-registry: ${{ secrets.JD_REGISTRY }} jd-image: ${{ secrets.JD_IMAGE }} + + - name: Install Go (prod-testnet) + if: inputs.ccip_env == 'prod-testnet' + uses: actions/setup-go@v6 + with: + cache: true + go-version-file: ${{ steps.prod_defaults.outputs.canton_path }}/go.mod + cache-dependency-path: ${{ steps.prod_defaults.outputs.canton_path }}/go.sum + + - name: Download Go dependencies (prod-testnet) + if: inputs.ccip_env == 'prod-testnet' + working-directory: ${{ steps.prod_defaults.outputs.canton_path }} + run: go mod download + + - name: Run CCIP load test (${{ inputs.direction }}) + working-directory: ${{ steps.prod_defaults.outputs.canton_path }}/ccip/devenv/tests/load + env: + CANTON_LOAD_MESSAGE_RATE: ${{ steps.prod_defaults.outputs.message_rate }} + CANTON_LOAD_DURATION: ${{ steps.prod_defaults.outputs.load_duration }} + CCIP_ENV: ${{ inputs.ccip_env == 'prod-testnet' && 'prod-testnet' || '' }} + CCIP_CONFIG_FILE: ${{ inputs.config_file }} + CANTON_AUTH_TYPE: ${{ inputs.ccip_env == 'prod-testnet' && 'clientCredentials' || '' }} + CANTON_AUTH_URL: ${{ inputs.ccip_env == 'prod-testnet' && secrets.CANTON_OKTA_AUTHORIZER_TESTNET || '' }} + CANTON_CLIENT_ID: ${{ inputs.ccip_env == 'prod-testnet' && secrets.CANTON_OKTA_CLIENT_ID_TESTNET || '' }} + CANTON_CLIENT_SECRET: ${{ inputs.ccip_env == 'prod-testnet' && secrets.CANTON_OKTA_CLIENT_SECRET_TESTNET || '' }} + CANTON_PARTY_ID: ${{ inputs.ccip_env == 'prod-testnet' && 'u_d53a15c42af6::1220c250c23c55120f7c758bccc5cbc739629015ab921594e1c29656981f985bffa7' || '' }} + CANTON_GRPC_URL: ${{ inputs.ccip_env == 'prod-testnet' && 'testnet.cv1.bcy-v.metalhosts.com:443' || '' }} + CANTON_CONFIRM_EXEC_TIMEOUT: ${{ inputs.confirm_exec_timeout }} + CANTON_LOAD_SKIP_EXEC_CONFIRM: ${{ inputs.skip_exec_confirm }} + PRIVATE_KEY: ${{ inputs.ccip_env == 'prod-testnet' && secrets.CCIP_PROD_TESTNET_PRIVATE_KEY || '' }} + run: | + case "${{ inputs.direction }}" in + canton2evm) TEST_RUN='^TestCanton2EVM_Load$' ;; + evm2canton) TEST_RUN='^TestEVM2Canton_Load$' ;; + canton2evm-token) TEST_RUN='^TestCanton2EVM_TokenLoad$' ;; + evm2canton-token) TEST_RUN='^TestEVM2Canton_TokenLoad$' ;; + *) echo "unknown direction: ${{ inputs.direction }}" >&2; exit 1 ;; + esac + go test -timeout "${{ steps.prod_defaults.outputs.test_timeout }}" -v -count 1 -run "$TEST_RUN" + + - name: Upload devenv logs + if: always() && inputs.ccip_env == 'devenv' + uses: ./.github/actions/upload-ccip-devenv-logs + with: + canton-path: ${{ steps.prod_defaults.outputs.canton_path }} + test-package-dir: load + log-dump-suffix: ccip-load-tests + artifact-name: container-logs-ccip-load-tests diff --git a/ccip/devenv/tests/token_transfer_config.toml b/ccip/devenv/tests/token_transfer_config.toml index e6ff634e7..31f5691f5 100644 --- a/ccip/devenv/tests/token_transfer_config.toml +++ b/ccip/devenv/tests/token_transfer_config.toml @@ -1,6 +1,6 @@ # Token lane inputs for CCIP e2e/load tests. # -# Env selection follows -ccip-env / CCIP_ENV (same as CCIP harness). +# Env selection uses CCIP_ENV (prod-testnet) or defaults to devenv in tests. # Token identity (pool_type / pool_version / pool_qualifier) is REQUIRED and is # matched against the source chain's GetTokenTransferConfigs to discover the lane. # Numeric send params (transfer_amount / execution_gas_limit / finality_config)