From a0214181a354808b76f3d2d2215aa6af5a93da93 Mon Sep 17 00:00:00 2001 From: Rodrigo Date: Wed, 17 Jun 2026 22:34:36 -0300 Subject: [PATCH 1/9] feat(devenv): enable connection to prod --- .gitignore | 2 + ccip/devenv/README.md | 56 +++++ ccip/devenv/cldf.go | 90 +++++--- ccip/devenv/cldf_auth.go | 157 ++++++++++++++ ccip/devenv/cldf_auth_test.go | 201 ++++++++++++++++++ .../canton_prod_testnet_connection_test.go | 75 +++++++ 6 files changed, 553 insertions(+), 28 deletions(-) create mode 100644 ccip/devenv/cldf_auth.go create mode 100644 ccip/devenv/cldf_auth_test.go create mode 100644 ccip/devenv/tests/integration/canton_prod_testnet_connection_test.go diff --git a/.gitignore b/.gitignore index 90ff4b190..3028cade8 100644 --- a/.gitignore +++ b/.gitignore @@ -20,3 +20,5 @@ scripts/prod_testnet/.env eds/staging_testnet_canton.local.toml eds/prod_testnet_canton.local.toml eds/prod_testnet_canton.local.secrets.toml +ccip/devenv/.env.staging.local +ccip/devenv/.env.prod-testnet.local diff --git a/ccip/devenv/README.md b/ccip/devenv/README.md index f26b4b1fe..b93bccc6d 100644 --- a/ccip/devenv/README.md +++ b/ccip/devenv/README.md @@ -177,3 +177,59 @@ If you want to build docker images, spin up a new env, and run the test in a sin # From repo root make build-run-e2e-tests ``` + +## Prod testnet connection smoke test + +Minimal Canton-only connectivity check against real testnet infrastructure. Uses [`env-prod-testnet-out.toml`](./env-prod-testnet-out.toml) for public endpoint URLs; auth secrets and party identity come from environment variables. + +The test is opt-in: it skips unless `CANTON_GRPC_URL` is set (even though the TOML file includes default URLs). This keeps CI green while allowing manual or workflow-triggered runs. + +### Environment variables + +| Variable | Required | Notes | +|---|---|---| +| `CANTON_PARTY_ID` | yes | Ledger party to query; skips `GetUser` when set | +| `CANTON_AUTH_URL` | yes | OIDC issuer | +| `CANTON_CLIENT_ID` | yes | OAuth2 client ID | +| `CANTON_AUTH_TYPE` | no | `authorizationCode` (local), `clientCredentials` (CI), `static`, `insecureStatic` | +| `CANTON_USER_ID` | no | Required for `clientCredentials`; optional for `authorizationCode` (extracted from token `sub` after login) | +| `CANTON_CLIENT_SECRET` | CI only | Required when `CANTON_AUTH_TYPE=clientCredentials` | +| `CANTON_JWT` | static only | For `static` / `insecureStatic` auth | +| `CANTON_GRPC_URL` | opt-in signal | Must be set to run the test; overrides TOML gRPC URL | +| `CANTON_VALIDATOR_API_URL` | no | Overrides TOML validator API URL | + +**Local (browser Okta login):** + +```bash +export CANTON_GRPC_URL='testnet.cv1.bcy-v.metalhosts.com:443' +export CANTON_PARTY_ID='u_0e0328cbbcb7::1220c250c23c55120f7c758bccc5cbc739629015ab921594e1c29656981f985bffa7' +export CANTON_AUTH_URL='https://smartcontract.okta.com/oauth2/austsuml9q2WhPBMM5d7' +export CANTON_CLIENT_ID='0oau1l22b1Jv3dcih5d7' +export CANTON_AUTH_TYPE='authorizationCode' +``` + +**CI (`clientCredentials`, no browser):** + +```bash +export CANTON_GRPC_URL='testnet.cv1.bcy-v.metalhosts.com:443' +export CANTON_PARTY_ID='...' +export CANTON_AUTH_URL='https://smartcontract.okta.com/oauth2/austsuml9q2WhPBMM5d7' +export CANTON_CLIENT_ID='0oau1l22b1Jv3dcih5d7' +export CANTON_AUTH_TYPE='clientCredentials' +export CANTON_CLIENT_SECRET='...' +export CANTON_USER_ID='...' +``` + +### Run command + +```bash +cd ccip/devenv/tests/integration && go test -v -run TestIntegration_CantonProdTestnet_Connection -count=1 +``` + +Use `-canton-env-out` to point at a different CCV env output TOML (default `../../env-prod-testnet-out.toml`; local devenv uses `../../env-canton-evm-out.toml`): + +```bash +cd ccip/devenv/tests/integration && go test -v -run TestIntegration_CantonProdTestnet_Connection -count=1 -canton-env-out=../../env-canton-evm-out.toml +``` + +The test connects via `NewCLDF`, asserts `PartyID` is set, and lists holdings for the party (empty balance is OK). diff --git a/ccip/devenv/cldf.go b/ccip/devenv/cldf.go index 01a6e1bb0..fd0ea59be 100644 --- a/ccip/devenv/cldf.go +++ b/ccip/devenv/cldf.go @@ -4,14 +4,14 @@ import ( "context" "fmt" - adminv2 "github.com/digital-asset/dazl-client/v8/go/api/com/daml/ledger/api/v2/admin" chainsel "github.com/smartcontractkit/chain-selectors" - "github.com/smartcontractkit/chainlink-deployments-framework/chain/canton/provider/authentication" "github.com/smartcontractkit/chainlink-testing-framework/framework/components/blockchain" "google.golang.org/grpc" cldf_chain "github.com/smartcontractkit/chainlink-deployments-framework/chain" cldf_canton_provider "github.com/smartcontractkit/chainlink-deployments-framework/chain/canton/provider" + + "github.com/smartcontractkit/chainlink-canton/commonconfig" ) func NewCLDF(ctx context.Context, b *blockchain.Input) (cldf_chain.BlockChain, uint64, error) { @@ -24,46 +24,80 @@ func NewCLDF(ctx context.Context, b *blockchain.Input) (cldf_chain.BlockChain, u Participants: make([]cldf_canton_provider.ParticipantConfig, len(b.Out.NetworkSpecificData.CantonData.ExternalEndpoints.Participants)), } + presetPartyID := resolvePartyID() + internalParticipants := b.Out.NetworkSpecificData.CantonData.InternalEndpoints.Participants + for i, config := range b.Out.NetworkSpecificData.CantonData.ExternalEndpoints.Participants { - authProvider := authentication.NewInsecureStaticProvider(config.JWT) - // Get Primary Party for user - ledgerApiConn, err := grpc.NewClient( - config.GRPCLedgerAPIURL, - grpc.WithTransportCredentials(authProvider.TransportCredentials()), - grpc.WithPerRPCCredentials(authProvider.PerRPCCredentials()), - ) + authCfg := resolveAuthConfig(config.JWT, config.UserID) + authProvider, err := newAuthProvider(ctx, authCfg) if err != nil { - return nil, 0, fmt.Errorf("failed to create gRPC connection to Ledger API for Canton participant %d: %w", i+1, err) + return nil, 0, fmt.Errorf("create auth provider for Canton participant %d: %w", i+1, err) } - userResp, err := adminv2.NewUserManagementServiceClient(ledgerApiConn).GetUser(context.Background(), &adminv2.GetUserRequest{UserId: config.UserID}) + + userID := resolveUserID(config.UserID) + if userID == "" && authCfg.Type == commonconfig.AuthTypeAuthorizationCode { + userID, err = userIDFromToken(ctx, authProvider) + if err != nil { + return nil, 0, fmt.Errorf("resolve user id for Canton participant %d: %w", i+1, err) + } + } + + grpcURL := resolveGRPCLedgerURL(config.GRPCLedgerAPIURL) + party, err := func() (string, error) { + conn, err := grpc.NewClient( + grpcURL, + grpc.WithTransportCredentials(authProvider.TransportCredentials()), + grpc.WithPerRPCCredentials(authProvider.PerRPCCredentials()), + ) + if err != nil { + return "", fmt.Errorf("create gRPC connection to Ledger API for Canton participant %d: %w", i+1, err) + } + defer conn.Close() + + party, err := resolveParticipantParty(ctx, conn, userID, presetPartyID) + if err != nil { + return "", fmt.Errorf("resolve party for Canton participant %d: %w", i+1, err) + } + + return party, nil + }() if err != nil { - return nil, 0, fmt.Errorf("failed to get user info for user %s for Canton participant %d: %w", config.UserID, i+1, err) + return nil, 0, err } - party := userResp.GetUser().GetPrimaryParty() - if party == "" { - return nil, 0, fmt.Errorf("no primary party found for user %s for Canton participant %d", config.UserID, i+1) + + var internalEndpoints *cldf_canton_provider.Endpoints + if i < len(internalParticipants) { + internal := internalParticipants[i] + if endpointsNonEmpty( + internal.JSONLedgerAPIURL, + internal.GRPCLedgerAPIURL, + internal.AdminAPIURL, + internal.ValidatorAPIURL, + ) { + internalEndpoints = &cldf_canton_provider.Endpoints{ + JSONLedgerAPIURL: internal.JSONLedgerAPIURL, + GRPCLedgerAPIURL: internal.GRPCLedgerAPIURL, + AdminAPIURL: internal.AdminAPIURL, + ValidatorAPIURL: internal.ValidatorAPIURL, + } + } } - _ = ledgerApiConn.Close() providerConfig.Participants[i] = cldf_canton_provider.ParticipantConfig{ Endpoints: cldf_canton_provider.Endpoints{ JSONLedgerAPIURL: config.JSONLedgerAPIURL, - GRPCLedgerAPIURL: config.GRPCLedgerAPIURL, + GRPCLedgerAPIURL: grpcURL, AdminAPIURL: config.AdminAPIURL, - ValidatorAPIURL: config.ValidatorAPIURL, + ValidatorAPIURL: resolveValidatorAPIURL(config.ValidatorAPIURL), }, - InternalEndpoints: &cldf_canton_provider.Endpoints{ - JSONLedgerAPIURL: b.Out.NetworkSpecificData.CantonData.InternalEndpoints.Participants[i].JSONLedgerAPIURL, - GRPCLedgerAPIURL: b.Out.NetworkSpecificData.CantonData.InternalEndpoints.Participants[i].GRPCLedgerAPIURL, - AdminAPIURL: b.Out.NetworkSpecificData.CantonData.InternalEndpoints.Participants[i].AdminAPIURL, - ValidatorAPIURL: b.Out.NetworkSpecificData.CantonData.InternalEndpoints.Participants[i].ValidatorAPIURL, - }, - UserID: config.UserID, - PartyID: party, - AuthProvider: authProvider, + InternalEndpoints: internalEndpoints, + UserID: userID, + PartyID: party, + AuthProvider: authProvider, } } - p, err := cldf_canton_provider.NewRPCChainProvider(d.ChainSelector, providerConfig).Initialize(context.TODO()) + + p, err := cldf_canton_provider.NewRPCChainProvider(d.ChainSelector, providerConfig).Initialize(ctx) if err != nil { return nil, 0, err } diff --git a/ccip/devenv/cldf_auth.go b/ccip/devenv/cldf_auth.go new file mode 100644 index 000000000..25ac66143 --- /dev/null +++ b/ccip/devenv/cldf_auth.go @@ -0,0 +1,157 @@ +package devenv + +import ( + "context" + "encoding/base64" + "encoding/json" + "fmt" + "os" + "strings" + + adminv2 "github.com/digital-asset/dazl-client/v8/go/api/com/daml/ledger/api/v2/admin" + "github.com/smartcontractkit/chainlink-deployments-framework/chain/canton/provider/authentication" + "google.golang.org/grpc" + + "github.com/smartcontractkit/chainlink-canton/commonconfig" + "github.com/smartcontractkit/chainlink-canton/deployment/authentication/authorizationcode" +) + +func resolveAuthConfig(tomlJWT, tomlUserID string) commonconfig.AuthConfig { + jwt := envTrim("CANTON_JWT") + if jwt == "" { + jwt = tomlJWT + } + + authType := envTrim("CANTON_AUTH_TYPE") + switch { + case authType == "" && jwt != "": + authType = commonconfig.AuthTypeInsecureStatic + case authType == "": + authType = commonconfig.AuthTypeAuthorizationCode + } + + authURL := envTrim("CANTON_AUTH_URL") + clientID := envTrim("CANTON_CLIENT_ID") + clientSecret := envTrim("CANTON_CLIENT_SECRET") + if authType != commonconfig.AuthTypeClientCredentials { + clientSecret = "" + } + + return commonconfig.AuthConfig{ + Type: authType, + UserID: resolveUserID(tomlUserID), + JWT: jwt, + AuthURL: authURL, + ClientID: clientID, + ClientSecret: clientSecret, + } +} + +func resolveUserID(tomlUserID string) string { + if userID := envTrim("CANTON_USER_ID"); userID != "" { + return userID + } + + return tomlUserID +} + +func resolvePartyID() string { + return envTrim("CANTON_PARTY_ID") +} + +func resolveGRPCLedgerURL(tomlURL string) string { + if url := envTrim("CANTON_GRPC_URL"); url != "" { + return url + } + + return tomlURL +} + +func resolveValidatorAPIURL(tomlURL string) string { + if url := envTrim("CANTON_VALIDATOR_API_URL"); url != "" { + return url + } + + return tomlURL +} + +func newAuthProvider(ctx context.Context, authCfg commonconfig.AuthConfig) (authentication.Provider, error) { + if authCfg.Type == commonconfig.AuthTypeAuthorizationCode && authCfg.UserID == "" { + return authorizationcode.NewDiscoveryProvider(ctx, authCfg.AuthURL, authCfg.ClientID) + } + + return authCfg.NewProvider(ctx) +} + +func userIDFromToken(ctx context.Context, provider authentication.Provider) (string, error) { + _ = ctx + + token, err := provider.TokenSource().Token() + if err != nil { + return "", fmt.Errorf("get oauth token: %w", err) + } + + sub, err := jwtSubject(token.AccessToken) + if err != nil { + return "", fmt.Errorf("extract user id from token sub: %w", err) + } + if sub == "" { + return "", fmt.Errorf("token sub claim is empty") + } + + return sub, nil +} + +func jwtSubject(accessToken string) (string, error) { + parts := strings.Split(accessToken, ".") + if len(parts) != 3 { + return "", fmt.Errorf("invalid JWT format") + } + + payload, err := base64.RawURLEncoding.DecodeString(parts[1]) + if err != nil { + return "", fmt.Errorf("decode JWT payload: %w", err) + } + + var claims struct { + Sub string `json:"sub"` + } + if err := json.Unmarshal(payload, &claims); err != nil { + return "", fmt.Errorf("parse JWT payload: %w", err) + } + + return claims.Sub, nil +} + +func resolveParticipantParty(ctx context.Context, conn *grpc.ClientConn, userID, partyID string) (string, error) { + if party := strings.TrimSpace(partyID); party != "" { + return party, nil + } + + if strings.TrimSpace(userID) == "" { + return "", fmt.Errorf("party ID not preset and user ID unset") + } + + userResp, err := adminv2.NewUserManagementServiceClient(conn).GetUser(ctx, &adminv2.GetUserRequest{UserId: userID}) + if err != nil { + return "", fmt.Errorf("get user %s: %w", userID, err) + } + + party := userResp.GetUser().GetPrimaryParty() + if party == "" { + return "", fmt.Errorf("no primary party found for user %s", userID) + } + + return party, nil +} + +func endpointsNonEmpty(jsonURL, grpcURL, adminURL, validatorURL string) bool { + return strings.TrimSpace(jsonURL) != "" || + strings.TrimSpace(grpcURL) != "" || + strings.TrimSpace(adminURL) != "" || + strings.TrimSpace(validatorURL) != "" +} + +func envTrim(key string) string { + return strings.TrimSpace(os.Getenv(key)) +} diff --git a/ccip/devenv/cldf_auth_test.go b/ccip/devenv/cldf_auth_test.go new file mode 100644 index 000000000..29600a4e2 --- /dev/null +++ b/ccip/devenv/cldf_auth_test.go @@ -0,0 +1,201 @@ +package devenv + +import ( + "context" + "testing" + + "github.com/smartcontractkit/chainlink-deployments-framework/chain/canton/provider/authentication" + "github.com/stretchr/testify/require" + "golang.org/x/oauth2" + "google.golang.org/grpc/credentials" + "google.golang.org/grpc/credentials/oauth" + + "github.com/smartcontractkit/chainlink-canton/commonconfig" +) + +// validJWT is a well-formed JWT with sub "1234567890". +const validJWT = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiYWRtaW4iOnRydWUsImlhdCI6MTUxNjIzOTAyMn0.KMUFsIDTnFmyG3nMiGM6H9FNFUROf3wh7SmqJp-QV30" + +func clearCantonEnv(t *testing.T) { + t.Helper() + for _, key := range []string{ + "CANTON_AUTH_TYPE", + "CANTON_AUTH_URL", + "CANTON_CLIENT_ID", + "CANTON_CLIENT_SECRET", + "CANTON_JWT", + "CANTON_USER_ID", + "CANTON_PARTY_ID", + "CANTON_GRPC_URL", + "CANTON_VALIDATOR_API_URL", + } { + t.Setenv(key, "") + } +} + +func TestEnvTrim(t *testing.T) { + clearCantonEnv(t) + + require.Equal(t, "", envTrim("CANTON_PARTY_ID")) + + t.Setenv("CANTON_PARTY_ID", "party-1") + require.Equal(t, "party-1", envTrim("CANTON_PARTY_ID")) + + t.Setenv("CANTON_PARTY_ID", " party-2 ") + require.Equal(t, "party-2", envTrim("CANTON_PARTY_ID")) +} + +func TestResolveAuthConfig(t *testing.T) { + tests := []struct { + name string + env map[string]string + tomlJWT string + tomlUserID string + want commonconfig.AuthConfig + }{ + { + name: "toml jwt defaults to insecureStatic", + tomlJWT: validJWT, + want: commonconfig.AuthConfig{ + Type: commonconfig.AuthTypeInsecureStatic, + JWT: validJWT, + }, + }, + { + name: "no jwt defaults to authorizationCode", + want: commonconfig.AuthConfig{ + Type: commonconfig.AuthTypeAuthorizationCode, + }, + }, + { + name: "env overrides toml", + env: map[string]string{ + "CANTON_AUTH_TYPE": commonconfig.AuthTypeClientCredentials, + "CANTON_JWT": "env-jwt", + "CANTON_USER_ID": "env-user", + "CANTON_AUTH_URL": "https://auth.example.com/", + "CANTON_CLIENT_ID": "env-client", + "CANTON_CLIENT_SECRET": "env-secret", + }, + tomlJWT: validJWT, + tomlUserID: "toml-user", + want: commonconfig.AuthConfig{ + Type: commonconfig.AuthTypeClientCredentials, + UserID: "env-user", + JWT: "env-jwt", + AuthURL: "https://auth.example.com/", + ClientID: "env-client", + ClientSecret: "env-secret", + }, + }, + { + name: "client secret cleared for authorizationCode", + env: map[string]string{ + "CANTON_AUTH_TYPE": commonconfig.AuthTypeAuthorizationCode, + "CANTON_AUTH_URL": "https://auth.example.com/", + "CANTON_CLIENT_ID": "env-client", + "CANTON_CLIENT_SECRET": "should-not-apply", + }, + want: commonconfig.AuthConfig{ + Type: commonconfig.AuthTypeAuthorizationCode, + AuthURL: "https://auth.example.com/", + ClientID: "env-client", + ClientSecret: "", + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + clearCantonEnv(t) + for key, value := range tt.env { + t.Setenv(key, value) + } + + got := resolveAuthConfig(tt.tomlJWT, tt.tomlUserID) + require.Equal(t, tt.want, got) + }) + } +} + +func TestResolveUserID(t *testing.T) { + clearCantonEnv(t) + + require.Equal(t, "toml-user", resolveUserID("toml-user")) + + t.Setenv("CANTON_USER_ID", "env-user") + require.Equal(t, "env-user", resolveUserID("toml-user")) +} + +func TestResolvePartyID(t *testing.T) { + clearCantonEnv(t) + + require.Equal(t, "", resolvePartyID()) + + t.Setenv("CANTON_PARTY_ID", "party::1220abc") + require.Equal(t, "party::1220abc", resolvePartyID()) +} + +func TestResolveGRPCLedgerURL(t *testing.T) { + clearCantonEnv(t) + + require.Equal(t, "toml-host:443", resolveGRPCLedgerURL("toml-host:443")) + + t.Setenv("CANTON_GRPC_URL", "env-host:443") + require.Equal(t, "env-host:443", resolveGRPCLedgerURL("toml-host:443")) +} + +func TestResolveValidatorAPIURL(t *testing.T) { + clearCantonEnv(t) + + require.Equal(t, "https://toml.example/validator/", resolveValidatorAPIURL("https://toml.example/validator/")) + + t.Setenv("CANTON_VALIDATOR_API_URL", "https://env.example/validator/") + require.Equal(t, "https://env.example/validator/", resolveValidatorAPIURL("https://toml.example/validator/")) +} + +func TestJWTSubject(t *testing.T) { + t.Parallel() + + sub, err := jwtSubject(validJWT) + require.NoError(t, err) + require.Equal(t, "1234567890", sub) +} + +func TestUserIDFromToken(t *testing.T) { + userID, err := userIDFromToken(context.Background(), testAuthProvider{token: validJWT}) + require.NoError(t, err) + require.Equal(t, "1234567890", userID) +} + +func TestEndpointsNonEmpty(t *testing.T) { + t.Parallel() + + require.False(t, endpointsNonEmpty("", "", "", "")) + require.True(t, endpointsNonEmpty("", "grpc:443", "", "")) +} + +func TestResolveAuthConfig_envJWTOverridesToml(t *testing.T) { + clearCantonEnv(t) + + t.Setenv("CANTON_JWT", validJWT) + got := resolveAuthConfig("toml-jwt-should-lose", "") + require.Equal(t, validJWT, got.JWT) + require.Equal(t, commonconfig.AuthTypeInsecureStatic, got.Type) +} + +type testAuthProvider struct { + token string +} + +func (p testAuthProvider) TokenSource() oauth2.TokenSource { + return oauth2.StaticTokenSource(&oauth2.Token{AccessToken: p.token}) +} + +func (p testAuthProvider) TransportCredentials() credentials.TransportCredentials { + return authentication.NewInsecureStaticProvider(p.token).TransportCredentials() +} + +func (p testAuthProvider) PerRPCCredentials() credentials.PerRPCCredentials { + return oauth.TokenSource{TokenSource: p.TokenSource()} +} diff --git a/ccip/devenv/tests/integration/canton_prod_testnet_connection_test.go b/ccip/devenv/tests/integration/canton_prod_testnet_connection_test.go new file mode 100644 index 000000000..10cfa1b73 --- /dev/null +++ b/ccip/devenv/tests/integration/canton_prod_testnet_connection_test.go @@ -0,0 +1,75 @@ +package integration + +import ( + "flag" + "os" + "testing" + + ccv "github.com/smartcontractkit/chainlink-ccv/build/devenv" + _ "github.com/smartcontractkit/chainlink-ccv/build/devenv/evm" // register EVM ImplFactory + "github.com/smartcontractkit/chainlink-deployments-framework/chain/canton" + "github.com/smartcontractkit/chainlink-testing-framework/framework/components/blockchain" + "github.com/stretchr/testify/require" + + cantondevenv "github.com/smartcontractkit/chainlink-canton/ccip/devenv" + _ "github.com/smartcontractkit/chainlink-canton/ccip/devenv" // register canton impl factory + "github.com/smartcontractkit/chainlink-canton/testhelpers" +) + +const ( + EnvCantonEvmOutToml = "../../env-canton-evm-out.toml" + EnvProdTestnetOutToml = "../../env-prod-testnet-out.toml" +) + +var cantonEnvOut = flag.String("canton-env-out", EnvProdTestnetOutToml, "path to CCV env output TOML") + +func TestMain(m *testing.M) { + flag.Parse() + os.Exit(m.Run()) +} + +func TestIntegration_CantonProdTestnet_Connection(t *testing.T) { + if testing.Short() { + t.Skip("skipping prod testnet connection smoke test in short mode") + } + if os.Getenv("CANTON_GRPC_URL") == "" { + t.Skip("CANTON_GRPC_URL unset: not configured for real Canton testnet") + } + + configPath := *cantonEnvOut + in, err := ccv.LoadOutput[ccv.Cfg](configPath) + require.NoError(t, err) + + var cantonBlockchain *blockchain.Input + for _, bc := range in.Blockchains { + if bc.Type == blockchain.TypeCanton && bc.ChainID == "TestNet" { + cantonBlockchain = bc + break + } + } + require.NotNil(t, cantonBlockchain, "need Canton TestNet blockchain in %s", configPath) + + ctx := t.Context() + chainBC, _, err := cantondevenv.NewCLDF(ctx, cantonBlockchain) + require.NoError(t, err) + + chain, ok := chainBC.(*canton.Chain) + require.True(t, ok, "expected *canton.Chain, got %T", chainBC) + require.NotEmpty(t, chain.Participants) + + participant := chain.Participants[0] + require.NotEmpty(t, participant.PartyID, "participant should be connected with a party ID") + t.Logf("connected participant user_id=%s party_id=%s grpc=%s", participant.UserID, participant.PartyID, participant.Endpoints.GRPCLedgerAPIURL) + + holdings, err := testhelpers.ListHoldingsForInstrument( + ctx, + participant, + nil, + testhelpers.WithHoldingOwner(participant.PartyID), + ) + require.NoError(t, err) + t.Logf("listed %d holdings for party %s", len(holdings), participant.PartyID) + for _, holding := range holdings { + t.Logf("holding contract_id=%s instrument=%s amount=%s", holding.ContractID, holding.View.InstrumentId.Id, holding.Amount) + } +} From 996010c279dff318e48fdb227b42d1e73e80368c Mon Sep 17 00:00:00 2001 From: Rodrigo Date: Thu, 18 Jun 2026 01:30:07 -0300 Subject: [PATCH 2/9] feat(tests): remove aggregator requirement --- ccip/devenv/impl.go | 8 ++--- ccip/devenv/manual_execution.go | 7 ++--- ccip/devenv/tests/helpers.go | 1 - ccip/devenv/verifier_observation.go | 40 +++++++++++++----------- ccip/devenv/verifier_observation_test.go | 22 +++++++++++++ 5 files changed, 49 insertions(+), 29 deletions(-) create mode 100644 ccip/devenv/verifier_observation_test.go diff --git a/ccip/devenv/impl.go b/ccip/devenv/impl.go index ce977c537..1cb164896 100644 --- a/ccip/devenv/impl.go +++ b/ccip/devenv/impl.go @@ -225,7 +225,7 @@ type Chain struct { nextTransferCID string // holding CID to be used as transfer on next message send // verifierObs is injected post-construction by test runners (see SetVerifierObservation). - // Required by ConfirmExecOnDest to fetch verifier results from aggregator/indexer. + // Required by ConfirmExecOnDest to fetch verifier results from indexer (aggregator optional). verifierObs VerifierObservation // partyMutexes serializes manual execution per receiver party so that @@ -941,9 +941,9 @@ func (c *Chain) ConfirmSendOnSource(ctx context.Context, to uint64, key cciptest // 1. Lock per receiver party (PerPartyRouter CID is consumed on every Execute). // 2. Idempotency: if an ExecutionStateChanged for (from, seqNo, messageID) already // exists on the ledger, parse and return it without re-executing. -// 3. Otherwise fetch the verifier result via verifier observation (aggregator + -// indexer), translate the verifier dest address to its hashed instance address, -// and call ManuallyExecuteMessage. +// 3. Otherwise fetch the verifier result via verifier observation (indexer; +// aggregator optional), translate the verifier dest address to its hashed +// instance address, and call ManuallyExecuteMessage. // // Both SeqNum AND MessageID must be set on key: they key the idempotency lookup and // the verifier-result fetch respectively. EVM-side ConfirmExecOnDest is permissive diff --git a/ccip/devenv/manual_execution.go b/ccip/devenv/manual_execution.go index c4947e642..13597b224 100644 --- a/ccip/devenv/manual_execution.go +++ b/ccip/devenv/manual_execution.go @@ -440,8 +440,8 @@ func verifierAssertTimeout(timeout time.Duration) time.Duration { return timeout } -// fetchVerifierResult queries the aggregator + indexer for the verifier output for -// messageID. Caller must have wired [VerifierObservation] on the chain first. +// fetchVerifierResult queries the indexer (aggregator optional) for the verifier +// output for messageID. Caller must have wired [VerifierObservation] on the chain first. func (c *Chain) fetchVerifierResult(ctx context.Context, messageID protocol.Bytes32, timeout time.Duration) (verifierResult, error) { if !c.verifierObs.wired() { return verifierResult{}, fmt.Errorf("verifier observation not wired") @@ -457,9 +457,6 @@ func (c *Chain) fetchVerifierResult(ctx context.Context, messageID protocol.Byte if err != nil { return verifierResult{}, fmt.Errorf("assertMessage: %w", err) } - if res.AggregatedResult == nil { - return verifierResult{}, fmt.Errorf("aggregated verifier result missing") - } if len(res.IndexedVerifications.Results) != 1 { return verifierResult{}, fmt.Errorf("expected 1 indexed verifier result, got %d", len(res.IndexedVerifications.Results)) } diff --git a/ccip/devenv/tests/helpers.go b/ccip/devenv/tests/helpers.go index 8d6e77795..8818cab1c 100644 --- a/ccip/devenv/tests/helpers.go +++ b/ccip/devenv/tests/helpers.go @@ -135,7 +135,6 @@ func AssertSingleVerifierResult( AssertExecutorLogs: false, }) require.NoError(t, err) - require.NotNil(t, result.AggregatedResult) require.Len(t, result.IndexedVerifications.Results, 1) return result diff --git a/ccip/devenv/verifier_observation.go b/ccip/devenv/verifier_observation.go index ac12e11ce..dc495ce6f 100644 --- a/ccip/devenv/verifier_observation.go +++ b/ccip/devenv/verifier_observation.go @@ -11,56 +11,58 @@ import ( ) // VerifierObservation holds off-chain clients used to wait for CCIP verifier -// results (aggregator + indexer). ConfirmExecOnDest needs these; it does not -// need ChainsMap or other Lib methods. +// results (indexer required; aggregator optional). ConfirmExecOnDest needs these; +// it does not need ChainsMap or other Lib methods. // // Build from a CCV env Lib via [VerifierObservationFromLib] (requires -// [ccv.NewLibFromCCVEnv] — CLDF-only Lib backends cannot provide aggregator/indexer). +// [ccv.NewLibFromCCVEnv] — CLDF-only Lib backends cannot provide indexer). type VerifierObservation struct { AggregatorClient *ccv.AggregatorClient IndexerMonitor *ccv.IndexerMonitor } func (o VerifierObservation) wired() bool { - return o.AggregatorClient != nil && o.IndexerMonitor != nil + return o.IndexerMonitor != nil } -// VerifierObservationFromLib extracts aggregator and indexer clients from lib. +// VerifierObservationFromLib extracts indexer (required) and optional aggregator +// clients from lib. func VerifierObservationFromLib(lib ccv.Lib) (VerifierObservation, error) { if lib == nil { return VerifierObservation{}, fmt.Errorf("VerifierObservationFromLib: lib is nil") } - aggregatorClients, err := lib.AllAggregators() + indexerMonitor, err := lib.IndexerMonitor() if err != nil { - return VerifierObservation{}, fmt.Errorf("all aggregators: %w", err) + return VerifierObservation{}, fmt.Errorf("indexer monitor: %w", err) } - aggregatorClient, ok := aggregatorClients[devenvcommon.DefaultCommitteeVerifierQualifier] - if !ok || aggregatorClient == nil { - return VerifierObservation{}, fmt.Errorf("no aggregator client for qualifier %q", devenvcommon.DefaultCommitteeVerifierQualifier) + + obs := VerifierObservation{ + IndexerMonitor: indexerMonitor, } - indexerMonitor, err := lib.IndexerMonitor() + aggregatorClients, err := lib.AllAggregators() if err != nil { - return VerifierObservation{}, fmt.Errorf("indexer monitor: %w", err) + return obs, nil + } + aggregatorClient, ok := aggregatorClients[devenvcommon.DefaultCommitteeVerifierQualifier] + if ok && aggregatorClient != nil { + obs.AggregatorClient = aggregatorClient } - return VerifierObservation{ - AggregatorClient: aggregatorClient, - IndexerMonitor: indexerMonitor, - }, nil + return obs, nil } // AssertMessageWithVerifierObservation waits for verifier results for messageID -// using aggregator and indexer only (no chain map). +// using indexer (and aggregator when configured). func AssertMessageWithVerifierObservation( ctx context.Context, obs VerifierObservation, messageID protocol.Bytes32, opts tcapi.AssertMessageOptions, ) (tcapi.AssertionResult, error) { - if !obs.wired() { - return tcapi.AssertionResult{}, fmt.Errorf("verifier observation not wired (aggregator and indexer required)") + if obs.IndexerMonitor == nil { + return tcapi.AssertionResult{}, fmt.Errorf("verifier observation not wired (indexer required)") } testCtx, cleanupFn := tcapi.NewTestingContext(ctx, nil, obs.AggregatorClient, obs.IndexerMonitor) diff --git a/ccip/devenv/verifier_observation_test.go b/ccip/devenv/verifier_observation_test.go new file mode 100644 index 000000000..f8fd6412c --- /dev/null +++ b/ccip/devenv/verifier_observation_test.go @@ -0,0 +1,22 @@ +package devenv + +import ( + "testing" + + ccv "github.com/smartcontractkit/chainlink-ccv/build/devenv" + "github.com/stretchr/testify/require" +) + +func TestVerifierObservation_wired_indexerOnly(t *testing.T) { + obs := VerifierObservation{ + IndexerMonitor: &ccv.IndexerMonitor{}, + } + require.True(t, obs.wired()) +} + +func TestVerifierObservation_wired_indexerNil(t *testing.T) { + obs := VerifierObservation{ + AggregatorClient: &ccv.AggregatorClient{}, + } + require.False(t, obs.wired()) +} From aa0239e75aed8d2d1a64c19210ca498170462b83 Mon Sep 17 00:00:00 2001 From: Rodrigo Date: Thu, 18 Jun 2026 03:26:02 -0300 Subject: [PATCH 3/9] feat(e2e): bootstrap tests from different envs --- ccip/devenv/README.md | 47 +++- ccip/devenv/impl.go | 17 +- ccip/devenv/manual_execution.go | 236 ++++++++++++++---- ccip/devenv/tests/constants.go | 2 +- ccip/devenv/tests/e2e/canton2evm_e2e_test.go | 105 +++----- ccip/devenv/tests/e2e/evm2canton_e2e_test.go | 78 +++--- ccip/devenv/tests/e2e/main_test.go | 12 + ccip/devenv/tests/env.go | 73 ++++++ ccip/devenv/tests/helpers.go | 97 +++++++ .../canton_prod_testnet_connection_test.go | 12 +- 10 files changed, 486 insertions(+), 193 deletions(-) create mode 100644 ccip/devenv/tests/e2e/main_test.go create mode 100644 ccip/devenv/tests/env.go diff --git a/ccip/devenv/README.md b/ccip/devenv/README.md index b93bccc6d..e0e1c9ff3 100644 --- a/ccip/devenv/README.md +++ b/ccip/devenv/README.md @@ -30,6 +30,45 @@ After the environment is spun up, you can run a test like: make run-e2e-tests ``` +### E2E environment selection (`-ccip-env` / `CCIP_ENV`) + +Message e2e tests run against **local devenv** by default or **Canton TestNet + Sepolia** when prod-testnet is selected. Set the environment by name (not TOML path): + +| Value | Config file | Remote | +|---|---|---| +| `devenv` (default) | [`env-canton-evm-out.toml`](./env-canton-evm-out.toml) | no | +| `prod-testnet` | [`env-prod-testnet-out.toml`](./env-prod-testnet-out.toml) | yes | + +Use the `-ccip-env` flag or `CCIP_ENV` env var (flag wins if both are set): + +```bash +# Local devenv (default) +cd ccip/devenv/tests/e2e && go test -v -run 'TestCanton2EVM_Basic/EOA' -count=1 + +# Prod testnet +CCIP_ENV=prod-testnet \ + CANTON_GRPC_URL=... CANTON_PARTY_ID=... CANTON_AUTH_*=... \ + PRIVATE_KEY=... \ + go test -timeout 8m -v -count=1 -ccip-env=prod-testnet \ + -run 'TestEVM2Canton_Basic/message|TestCanton2EVM_Basic/EOA' +``` + +**Prod prerequisites** + +- Canton party wallet funded with at least **50 Amulet units** (message fee) +- Canton auth env vars (`CANTON_GRPC_URL`, `CANTON_PARTY_ID`, `CANTON_AUTH_*`) — see [Prod testnet connection smoke test](#prod-testnet-connection-smoke-test) +- Sepolia gas via `PRIVATE_KEY` (EVM sender / receiver for prod runs) + +**Optional instance ID overrides** (defaults: `test-router`, `e2e-ccipsender`, `e2e-receiver`): + +| Env var | Default | +|---|---| +| `CANTON_ROUTER_INSTANCE_ID` | `test-router` | +| `CANTON_SENDER_INSTANCE_ID` | `e2e-ccipsender` | +| `CANTON_RECEIVER_INSTANCE_ID` | `e2e-receiver` | + +Token e2e subtests are skipped on prod-testnet. A second prod run reuses existing router/sender/receiver contracts on ledger when instance IDs match. + ## Load tests Load tests live in `ccip/devenv/tests/load`. They use [WASP](https://pkg.go.dev/github.com/smartcontractkit/chainlink-testing-framework/wasp) and run sequentially (RPS=1) because Canton holdings are single-flight. @@ -180,7 +219,7 @@ make build-run-e2e-tests ## Prod testnet connection smoke test -Minimal Canton-only connectivity check against real testnet infrastructure. Uses [`env-prod-testnet-out.toml`](./env-prod-testnet-out.toml) for public endpoint URLs; auth secrets and party identity come from environment variables. +Minimal Canton-only connectivity check against real testnet infrastructure. Uses [`env-prod-testnet-out.toml`](./env-prod-testnet-out.toml) (Canton TestNet ↔ Sepolia message lane: contract refs, indexer URLs, EDS URL, verifier issuers); auth secrets and party identity come from environment variables. The test is opt-in: it skips unless `CANTON_GRPC_URL` is set (even though the TOML file includes default URLs). This keeps CI green while allowing manual or workflow-triggered runs. @@ -226,10 +265,6 @@ export CANTON_USER_ID='...' cd ccip/devenv/tests/integration && go test -v -run TestIntegration_CantonProdTestnet_Connection -count=1 ``` -Use `-canton-env-out` to point at a different CCV env output TOML (default `../../env-prod-testnet-out.toml`; local devenv uses `../../env-canton-evm-out.toml`): - -```bash -cd ccip/devenv/tests/integration && go test -v -run TestIntegration_CantonProdTestnet_Connection -count=1 -canton-env-out=../../env-canton-evm-out.toml -``` +Use `-ccip-env=prod-testnet` (or `CCIP_ENV=prod-testnet`) so the test loads `env-prod-testnet-out.toml`; default devenv config is `env-canton-evm-out.toml` via `-ccip-env=devenv`. The test connects via `NewCLDF`, asserts `PartyID` is set, and lists holdings for the party (empty balance is OK). diff --git a/ccip/devenv/impl.go b/ccip/devenv/impl.go index 1cb164896..c61f6b891 100644 --- a/ccip/devenv/impl.go +++ b/ccip/devenv/impl.go @@ -44,7 +44,6 @@ import ( "github.com/smartcontractkit/go-daml/pkg/types" "github.com/smartcontractkit/chainlink-canton/bindings" - "github.com/smartcontractkit/chainlink-canton/bindings/generated/latest/ccip/ccipruntime" "github.com/smartcontractkit/chainlink-canton/bindings/generated/latest/ccip/core" ccipsender "github.com/smartcontractkit/chainlink-canton/bindings/generated/latest/ccip/sender" "github.com/smartcontractkit/chainlink-canton/bindings/generated/latest/splice/splice_api_token_holding_v1" @@ -216,6 +215,7 @@ type Chain struct { // Send setup prerequisites routerAddress contracts.InstanceAddress senderAddress contracts.InstanceAddress + receiverAddress contracts.InstanceAddress registryAdmin string validatorAPIClients validatorAPIClients @@ -1034,21 +1034,10 @@ func (c *Chain) SetupSend( 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) @@ -1246,7 +1235,7 @@ func (c *Chain) SendMessage(ctx context.Context, dest uint64, fields cciptestint } } // TODO come up with a better way of doing this - routerCid, err := contract.FindActiveContractIDByInstanceAddress(ctx, participant.LedgerServices.State, []string{party}, ccipruntime.PerPartyRouter{}.GetTemplateID(), c.routerAddress) + routerCid, err := c.findPerPartyRouterCidByParty(ctx, participant, party) if err != nil { return cciptestinterfaces.MessageSentEvent{}, fmt.Errorf("find active contract ID for router at address %s: %w", c.routerAddress, err) } diff --git a/ccip/devenv/manual_execution.go b/ccip/devenv/manual_execution.go index 13597b224..12bb81fe3 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" @@ -22,40 +23,48 @@ import ( "github.com/smartcontractkit/chainlink-canton/bindings/generated/latest/ccip/ccipruntime" "github.com/smartcontractkit/chainlink-canton/bindings/generated/latest/ccip/core" 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. -func (c *Chain) DeployPerPartyRouter(ctx context.Context, clientParticipant canton.Participant, partyOwner string) (routerAddress contracts.InstanceAddress, err error) { - perPartyRouterFactoryDisclosure, err := c.GetPerPartyRouterFactoryDisclosure(ctx, partyOwner) - if err != nil { - return contracts.InstanceAddress{}, fmt.Errorf("failed to get canton per party router factory disclosure: %w", err) +// DeployPerPartyRouter uses the PerPartyRouterFactory to create a PerPartyRouter instance for the given party. +// It returns the partyOwner-based instance address used in CCIP protocol fields, reusing an existing router on ledger when present. +func (c *Chain) DeployPerPartyRouter(ctx context.Context, participant canton.Participant, partyId string) (routerAddress contracts.InstanceAddress, err error) { + var unset contracts.InstanceAddress + routerInstanceID := instanceIDFromEnv("CANTON_ROUTER_INSTANCE_ID", "test-router") + if c.routerAddress != unset { + if found, _, ok, findErr := c.findPerPartyRouterByParty(ctx, participant, partyId, routerInstanceID); findErr != nil { + return contracts.InstanceAddress{}, fmt.Errorf("verify cached per-party router: %w", findErr) + } else if ok && found == c.routerAddress { + return c.routerAddress, nil + } + c.routerAddress = unset } - c.logger.Debug().Str("ContractId", perPartyRouterFactoryDisclosure.ContractId).Msg("Resolved per-party router factory disclosure") - routerInstanceID := contracts.InstanceID(perPartyRouterInstanceID) - ccipOwner := perPartyRouterFactoryDisclosure.Address.Owner() - routerAddress = routerInstanceID.RawInstanceAddress(types.PARTY(ccipOwner)).InstanceAddress() + if found, _, ok, findErr := c.findPerPartyRouterByParty(ctx, participant, partyId, routerInstanceID); findErr != nil { + return contracts.InstanceAddress{}, fmt.Errorf("find existing per-party router: %w", findErr) + } else if ok { + c.routerAddress = found + return found, nil + } - // Return early only if the router already exists at the expected instance address. - _, err = contract.FindActiveContractIDByInstanceAddress( - ctx, - clientParticipant.LedgerServices.State, - []string{partyOwner}, - ccipruntime.PerPartyRouter{}.GetTemplateID(), - routerAddress, - ) - if err == nil { - return routerAddress, nil + perPartyRouterFactoryDisclosure, err := c.GetPerPartyRouterFactoryDisclosure(ctx, partyId) + if err != nil { + return contracts.InstanceAddress{}, fmt.Errorf("failed to get canton per party router factory disclosure: %w", err) } + c.logger.Debug().Str("ContractId", perPartyRouterFactoryDisclosure.ContractId).Msg("Resolved per-party router factory address") _, err = operations.ExecuteOperation( c.e.OperationsBundle, @@ -67,44 +76,172 @@ func (c *Chain) DeployPerPartyRouter(ctx context.Context, clientParticipant cant ParticipantIndex: c.clientParticipantIndex(), DisclosedContracts: contract.DisclosedContractsFromProto(perPartyRouterFactoryDisclosure.DisclosedContracts), Args: ccipruntime.CreateRouter{ - PartyOwner: types.PARTY(partyOwner), + PartyOwner: types.PARTY(partyId), InstanceId: types.TEXT(routerInstanceID.String()), }, }, operations.WithForceExecute[contract.ChoiceInput[ccipruntime.CreateRouter], canton.Chain](), ) if err != nil { - return contracts.InstanceAddress{}, fmt.Errorf("failed to create per-party router: %w", err) + if found, _, ok, findErr := c.findPerPartyRouterByParty(ctx, participant, partyId, routerInstanceID); findErr != nil { + return contracts.InstanceAddress{}, fmt.Errorf("create per-party router: %w (find after failure: %v)", err, findErr) + } else if ok { + c.routerAddress = found + return found, nil + } + return contracts.InstanceAddress{}, fmt.Errorf("create per-party router: %w", err) + } + + found, _, ok, findErr := c.findPerPartyRouterByParty(ctx, participant, partyId, routerInstanceID) + if findErr != nil { + return contracts.InstanceAddress{}, fmt.Errorf("find per-party router after create: %w", findErr) + } + if !ok { + return contracts.InstanceAddress{}, fmt.Errorf("per-party router not found after create for party %s", partyId) + } + + c.routerAddress = found + return found, nil +} + +func (c *Chain) findPerPartyRouterByParty( + ctx context.Context, + participant canton.Participant, + partyId string, + preferredInstanceID contracts.InstanceID, +) (contracts.InstanceAddress, string, bool, error) { + templateID := contracts.TemplateIDFromBinding(ccipruntime.PerPartyRouter{}).ToLedgerIdentifier() + activeContracts, err := testhelpers.ListActiveContractsByTemplateId(ctx, participant, templateID) + if err != nil { + return contracts.InstanceAddress{}, "", false, err } - _, err = contract.FindActiveContractIDByInstanceAddress( + var fallback contracts.InstanceAddress + var fallbackCid string + var hasFallback bool + for _, ac := range activeContracts { + created := ac.GetCreatedEvent() + if created == nil { + continue + } + router, err := bindings.UnmarshalCreatedEvent[ccipruntime.PerPartyRouter](created) + if err != nil { + c.logger.Debug().Err(err).Msg("Skipping unparseable PerPartyRouter active contract") + continue + } + if string(router.PartyOwner) != partyId { + continue + } + addr := contracts.InstanceID(router.InstanceId).RawInstanceAddress(types.PARTY(partyId)).InstanceAddress() + cid := created.GetContractId() + if contracts.InstanceID(router.InstanceId) == preferredInstanceID { + return addr, cid, true, nil + } + if !hasFallback { + fallback = addr + fallbackCid = cid + hasFallback = true + } + } + if hasFallback { + return fallback, fallbackCid, true, nil + } + + return contracts.InstanceAddress{}, "", false, nil +} + +// findPerPartyRouterCidByParty resolves the ledger contract ID for a PerPartyRouter owned by partyId. +// PerPartyRouter is signed by ccipOwner, so instance-address lookup must use partyOwner (see OnRamp.daml). +func (c *Chain) findPerPartyRouterCidByParty(ctx context.Context, participant canton.Participant, partyId string) (string, error) { + routerInstanceID := instanceIDFromEnv("CANTON_ROUTER_INSTANCE_ID", "test-router") + _, cid, ok, err := c.findPerPartyRouterByParty(ctx, participant, partyId, routerInstanceID) + if err != nil { + return "", err + } + if !ok { + return "", fmt.Errorf("no active PerPartyRouter found for party %s", partyId) + } + return cid, 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, - clientParticipant.LedgerServices.State, - []string{partyOwner}, - ccipruntime.PerPartyRouter{}.GetTemplateID(), - routerAddress, - ) + 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 { - return contracts.InstanceAddress{}, fmt.Errorf( - "per-party router not found at %s for party %s after CreateRouter: %w", - routerAddress, partyOwner, err, - ) + 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) } - return routerAddress, nil + c.senderAddress = senderAddress + return senderAddress, nil } func (c *Chain) DeployCCIPReceiver(ctx context.Context, participant canton.Participant, partyId string, receiverFinality int64) (contracts.InstanceAddress, error) { + var unset contracts.InstanceAddress + if c.receiverAddress != unset { + return c.receiverAddress, nil + } + + instanceID := instanceIDFromEnv("CANTON_RECEIVER_INSTANCE_ID", "e2e-receiver") + receiverAddress := instanceID.RawInstanceAddress(types.PARTY(partyId)).InstanceAddress() + + if _, err := contract.FindActiveContractIDByInstanceAddress( + ctx, + participant.LedgerServices.State, + contract.LedgerQueryParties(participant), + ccipreceiver.CCIPReceiver{}.GetTemplateID(), + receiverAddress, + ); err == nil { + c.receiverAddress = receiverAddress + return receiverAddress, nil + } + finalityConfig, err := encodeReceiverFinalityConfig(receiverFinality) if err != nil { return contracts.InstanceAddress{}, fmt.Errorf("failed to encode receiver finality config: %w", err) } - // Deploy receiver contract - out, err := operations.ExecuteOperation(c.e.OperationsBundle, receiver.Deploy, c.chain, contract.DeployInput[ccipreceiver.CCIPReceiver]{ + _, err = operations.ExecuteOperation(c.e.OperationsBundle, receiver.Deploy, c.chain, contract.DeployInput[ccipreceiver.CCIPReceiver]{ Qualifier: nil, ParticipantIndex: c.clientParticipantIndex(), Template: ccipreceiver.CCIPReceiver{ + InstanceId: types.TEXT(instanceID), Owner: types.PARTY(partyId), RequiredCCVs: nil, OptionalCCVs: nil, @@ -114,10 +251,20 @@ func (c *Chain) DeployCCIPReceiver(ctx context.Context, participant canton.Parti OwnerParty: types.PARTY(partyId), }) if err != nil { + if _, findErr := contract.FindActiveContractIDByInstanceAddress( + ctx, + participant.LedgerServices.State, + contract.LedgerQueryParties(participant), + ccipreceiver.CCIPReceiver{}.GetTemplateID(), + receiverAddress, + ); findErr == nil { + c.receiverAddress = receiverAddress + return receiverAddress, nil + } return contracts.InstanceAddress{}, fmt.Errorf("failed to deploy receiver contract: %w", err) } - receiverAddress := contracts.HexToInstanceAddress(out.Output.Address) + c.receiverAddress = receiverAddress return receiverAddress, nil } @@ -158,12 +305,9 @@ func (c *Chain) ManuallyExecuteMessage(ctx context.Context, message protocol.Mes } encodedMessageHex := hex.EncodeToString(encodedMessage) - routerCid, err := contract.FindActiveContractIDByInstanceAddress(ctx, participant.LedgerServices.State, []string{participant.PartyID}, ccipruntime.PerPartyRouter{}.GetTemplateID(), routerAddress) + routerCid, err := c.findPerPartyRouterCidByParty(ctx, participant, executingParty) if err != nil { - return cciptestinterfaces.ExecutionStateChangedEvent{}, fmt.Errorf( - "per-party router not found for party %s at %s; call SetupReceive or SetupSend on the client participant before executing messages: %w", - executingParty, routerAddress, err, - ) + return cciptestinterfaces.ExecutionStateChangedEvent{}, fmt.Errorf("failed to get router contract ID: %w", err) } c.logger.Debug().Str("InstanceAddress", routerAddress.String()).Str("ContractId", routerCid).Msg("Resolved PerPartyRouter contract") @@ -393,10 +537,10 @@ func (c *Chain) lockForParty(party string) func() { func (c *Chain) findExistingExecutionState( ctx context.Context, sourceChainSelector, seqNo uint64, messageID protocol.Bytes32, ) (cciptestinterfaces.ExecutionStateChangedEvent, bool, error) { - participant, _, err := c.ClientParticipant() - if err != nil { - return cciptestinterfaces.ExecutionStateChangedEvent{}, false, fmt.Errorf("findExistingExecutionState: %w", err) + if len(c.chain.Participants) == 0 { + return cciptestinterfaces.ExecutionStateChangedEvent{}, false, fmt.Errorf("findExistingExecutionState: no participants on chain") } + participant := c.chain.Participants[0] templateID := contracts.TemplateIDFromBinding(core.ExecutionStateChanged{}).ToLedgerIdentifier() activeContracts, err := testhelpers.ListActiveContractsByTemplateId(ctx, participant, templateID) diff --git a/ccip/devenv/tests/constants.go b/ccip/devenv/tests/constants.go index 4d16a64a8..057d9c2c4 100644 --- a/ccip/devenv/tests/constants.go +++ b/ccip/devenv/tests/constants.go @@ -4,7 +4,7 @@ package tests const ( // CantonToEVMFeeAmount is the Canton CCIP send fee in Amulet units. - CantonToEVMFeeAmount int64 = 2_000 + CantonToEVMFeeAmount int64 = 50 // EVMDecimalsScale converts Canton token amounts to EVM 18-decimal balance units. EVMDecimalsScale int64 = 1_000_000_000_000_000_000 diff --git a/ccip/devenv/tests/e2e/canton2evm_e2e_test.go b/ccip/devenv/tests/e2e/canton2evm_e2e_test.go index 10839a0fa..b80ed4915 100644 --- a/ccip/devenv/tests/e2e/canton2evm_e2e_test.go +++ b/ccip/devenv/tests/e2e/canton2evm_e2e_test.go @@ -1,7 +1,6 @@ package canton import ( - "fmt" "math/big" "testing" "time" @@ -13,12 +12,10 @@ import ( "github.com/smartcontractkit/chainlink-ccv/protocol" "github.com/smartcontractkit/chainlink-common/pkg/utils/tests" "github.com/smartcontractkit/chainlink-deployments-framework/datastore" - "github.com/smartcontractkit/chainlink-testing-framework/framework" - "github.com/smartcontractkit/chainlink-testing-framework/framework/components/blockchain" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" - cantondevenv "github.com/smartcontractkit/chainlink-canton/ccip/devenv" + _ "github.com/smartcontractkit/chainlink-canton/ccip/devenv" // register Canton ImplFactory devenvtests "github.com/smartcontractkit/chainlink-canton/ccip/devenv/tests" canton_committee_verifier "github.com/smartcontractkit/chainlink-canton/deployment/operations/ccip/committee_verifier" "github.com/smartcontractkit/chainlink-canton/deployment/operations/ccip/executor" @@ -30,48 +27,26 @@ func TestCanton2EVM_Basic(t *testing.T) { t.Skip("skipping Canton2EVM_Basic test in short mode") } - configPath := "../../env-canton-evm-out.toml" - in, err := ccv.LoadOutput[ccv.Cfg](configPath) - require.NoError(t, err) - - lib, err := ccv.NewLibFromCCVEnv(&ccv.Plog, configPath) - require.NoError(t, err) + boot := devenvtests.BootstrapE2E(t, devenvtests.ParseEnvFromFlag(t)) ctx := ccv.Plog.WithContext(t.Context()) - chainMap, err := lib.ChainsMap(ctx) - require.NoError(t, err) - require.NoError(t, devenvtests.WireVerifierObservationFromLib(lib, chainMap)) - - evmChain := devenvtests.GetChainFromMap(t, blockchain.TypeAnvil, in, chainMap) - cantonChain := devenvtests.GetChainFromMap(t, blockchain.TypeCanton, in, chainMap) - cantonImpl, ok := cantonChain.(*cantondevenv.Chain) - require.True(t, ok, "Canton chain cantonImpl must be *devenv.Chain") - - t.Cleanup(func() { - _, err := framework.SaveContainerLogs(fmt.Sprintf("%s-%s", framework.DefaultCTFLogsDir, t.Name())) - require.NoError(t, err) - }) - 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)) + boot.SetupCantonSend(t, ctx, 0) + receiver := boot.ResolveEVMReceiver(t) - ds, err := lib.DataStore() - require.NoError(t, err) - receiver, err := evmChain.GetEOAReceiverAddress() + ds, err := boot.Lib.DataStore() require.NoError(t, err) ccvAddr := devenvtests.GetContractAddress( - t, ds, cantonChain.ChainSelector(), + t, ds, boot.Canton.ChainSelector(), datastore.ContractType(canton_committee_verifier.ContractType), canton_committee_verifier.Version.String(), devenvcommon.DefaultCommitteeVerifierQualifier, "canton committee verifier", ) executorAddr := devenvtests.GetContractAddress( - t, ds, cantonChain.ChainSelector(), + t, ds, boot.Canton.ChainSelector(), datastore.ContractType(executor.ContractType), executor.Version.String(), devenvcommon.DefaultExecutorQualifier, @@ -82,14 +57,14 @@ func TestCanton2EVM_Basic(t *testing.T) { receiver, ccvAddr, executorAddr, - cantonChain.ChainSelector(), - evmChain.ChainSelector(), + boot.Canton.ChainSelector(), + boot.EVM.ChainSelector(), ) t.Logf("Sending Canton -> EVM message") - sendMessageResult, err := cantonChain.SendMessage( + sendMessageResult, err := boot.Canton.SendMessage( subtestCtx, - evmChain.ChainSelector(), + boot.EVM.ChainSelector(), cciptestinterfaces.MessageFields{ Receiver: receiver, Data: []byte("canton2evm tcapi test"), @@ -110,79 +85,73 @@ func TestCanton2EVM_Basic(t *testing.T) { ) require.NoError(t, err) require.NotNil(t, sendMessageResult.Message) - // require.NotEmpty(t, sendMessageResult.ReceiptIssuers) seqNo := uint64(sendMessageResult.Message.SequenceNumber) - t.Logf( - "SendMessage accepted: seqNo=%d", - seqNo, - // len(sendMessageResult.ReceiptIssuers), - ) + t.Logf("SendMessage accepted: seqNo=%d", seqNo) - t.Logf("Waiting for CCIPMessageSent event: from=%d to=%d seq=%d", cantonChain.ChainSelector(), evmChain.ChainSelector(), seqNo) - sentEvent, err := cantonChain.ConfirmSendOnSource(subtestCtx, evmChain.ChainSelector(), cciptestinterfaces.MessageEventKey{SeqNum: seqNo}, 30*time.Second) + t.Logf("Waiting for CCIPMessageSent event: from=%d to=%d seq=%d", boot.Canton.ChainSelector(), boot.EVM.ChainSelector(), seqNo) + sentEvent, err := boot.Canton.ConfirmSendOnSource(subtestCtx, boot.EVM.ChainSelector(), cciptestinterfaces.MessageEventKey{SeqNum: seqNo}, 30*time.Second) require.NoError(t, err) t.Logf("CCIPMessageSent event: %+v", sentEvent) t.Logf("Asserting message propagated through aggregator/indexer: messageID=%x", sentEvent.MessageID[:]) - result := devenvtests.AssertSingleVerifierResult(t, subtestCtx, lib, sentEvent.MessageID) + result := devenvtests.AssertSingleVerifierResult(t, subtestCtx, boot.Lib, sentEvent.MessageID) t.Logf( "Message assertion succeeded: aggregated=true indexerResults=%+v", result.IndexedVerifications.Results, ) - t.Logf("Waiting for execution event on EVM: from=%d seq=%d", cantonChain.ChainSelector(), seqNo) - ev, err := evmChain.ConfirmExecOnDest(subtestCtx, cantonChain.ChainSelector(), cciptestinterfaces.MessageEventKey{SeqNum: seqNo}, tests.WaitTimeout(t)) + t.Logf("Waiting for execution event on EVM: from=%d seq=%d", boot.Canton.ChainSelector(), seqNo) + ev, err := boot.EVM.ConfirmExecOnDest(subtestCtx, boot.Canton.ChainSelector(), cciptestinterfaces.MessageEventKey{SeqNum: seqNo}, tests.WaitTimeout(t)) require.NoError(t, err) assert.Equal(t, cciptestinterfaces.ExecutionStateSuccess, ev.State) t.Logf("Execution event: %+v", ev) }) t.Run("EOA receiver and default committee verifier token transfer", func(t *testing.T) { + boot.SkipIfRemote(t, "token e2e not on prod-testnet") + 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()}) + lane := devenvtests.ResolveTokenLane(t, boot.Cfg, boot.Lib, boot.ChainMap, boot.Canton.ChainSelector(), []uint64{boot.EVM.ChainSelector()}) tokenTransferAmount := lane.TransferAmount.Uint64() - // Setup message send - require.NoError(t, cantonImpl.MintTokens(ctx, + require.NoError(t, boot.Canton.MintTokens(ctx, devenvtests.CantonToEVMTokenSequentialSends*uint64(devenvtests.CantonToEVMFeeAmount), - )) // Holdings for fee - require.NoError(t, cantonImpl.MintTokens(ctx, + )) + require.NoError(t, boot.Canton.MintTokens(ctx, devenvtests.CantonToEVMTokenSequentialSends*tokenTransferAmount, - )) // Holdings for token transfer - require.NoError(t, cantonImpl.SetupSend(ctx, uint64(devenvtests.CantonToEVMFeeAmount), tokenTransferAmount)) + )) + require.NoError(t, boot.Canton.SetupSend(ctx, uint64(devenvtests.CantonToEVMFeeAmount), tokenTransferAmount)) - ds, err := lib.DataStore() - require.NoError(t, err) - receiver, err := evmChain.GetEOAReceiverAddress() + receiver := boot.ResolveEVMReceiver(t) + + ds, err := boot.Lib.DataStore() require.NoError(t, err) ccvAddr := devenvtests.GetContractAddress( - t, ds, cantonChain.ChainSelector(), + t, ds, boot.Canton.ChainSelector(), datastore.ContractType(canton_committee_verifier.ContractType), canton_committee_verifier.Version.String(), devenvcommon.DefaultCommitteeVerifierQualifier, "canton committee verifier", ) executorAddr := devenvtests.GetContractAddress( - t, ds, cantonChain.ChainSelector(), + t, ds, boot.Canton.ChainSelector(), datastore.ContractType(executor.ContractType), executor.Version.String(), devenvcommon.DefaultExecutorQualifier, "source executor", ) - require.NoError(t, err) - destTokenAddress := lane.DestTokenBySelector[evmChain.ChainSelector()] - receiverBalanceBefore, err := evmChain.GetTokenBalance(subtestCtx, receiver, destTokenAddress) + destTokenAddress := lane.DestTokenBySelector[boot.EVM.ChainSelector()] + receiverBalanceBefore, err := boot.EVM.GetTokenBalance(subtestCtx, receiver, destTokenAddress) require.NoError(t, err) require.NotNil(t, receiverBalanceBefore) for sendIdx := range devenvtests.CantonToEVMTokenSequentialSends { t.Logf("Token transfer send %d/%d", sendIdx+1, devenvtests.CantonToEVMTokenSequentialSends) - sendMessageResult, err := cantonChain.SendMessage( + sendMessageResult, err := boot.Canton.SendMessage( subtestCtx, - evmChain.ChainSelector(), + boot.EVM.ChainSelector(), cciptestinterfaces.MessageFields{ Receiver: receiver, Data: []byte("canton2evm token transfer"), @@ -209,17 +178,17 @@ func TestCanton2EVM_Basic(t *testing.T) { require.NotNil(t, sendMessageResult.Message.TokenTransfer) seqNo := uint64(sendMessageResult.Message.SequenceNumber) - sentEvent, err := cantonChain.ConfirmSendOnSource(subtestCtx, evmChain.ChainSelector(), cciptestinterfaces.MessageEventKey{SeqNum: seqNo}, tests.WaitTimeout(t)) + sentEvent, err := boot.Canton.ConfirmSendOnSource(subtestCtx, boot.EVM.ChainSelector(), cciptestinterfaces.MessageEventKey{SeqNum: seqNo}, tests.WaitTimeout(t)) require.NoError(t, err) require.NotNil(t, sentEvent.Message) require.NotNil(t, sentEvent.Message.TokenTransfer) - ev, err := evmChain.ConfirmExecOnDest(subtestCtx, cantonChain.ChainSelector(), cciptestinterfaces.MessageEventKey{SeqNum: seqNo}, tests.WaitTimeout(t)) + ev, err := boot.EVM.ConfirmExecOnDest(subtestCtx, boot.Canton.ChainSelector(), cciptestinterfaces.MessageEventKey{SeqNum: seqNo}, tests.WaitTimeout(t)) require.NoError(t, err) require.Equal(t, cciptestinterfaces.ExecutionStateSuccess, ev.State) } - receiverBalanceAfter, err := evmChain.GetTokenBalance(subtestCtx, receiver, destTokenAddress) + receiverBalanceAfter, err := boot.EVM.GetTokenBalance(subtestCtx, receiver, destTokenAddress) require.NoError(t, err) require.NotNil(t, receiverBalanceAfter) diff --git a/ccip/devenv/tests/e2e/evm2canton_e2e_test.go b/ccip/devenv/tests/e2e/evm2canton_e2e_test.go index 69b45eb03..dcbee6dbb 100644 --- a/ccip/devenv/tests/e2e/evm2canton_e2e_test.go +++ b/ccip/devenv/tests/e2e/evm2canton_e2e_test.go @@ -1,7 +1,6 @@ package canton import ( - "fmt" "math/big" "testing" "time" @@ -11,17 +10,16 @@ import ( "github.com/smartcontractkit/chainlink-ccip/chains/evm/deployment/v2_0_0/versioned_verifier_resolver" ccv "github.com/smartcontractkit/chainlink-ccv/build/devenv" "github.com/smartcontractkit/chainlink-ccv/build/devenv/cciptestinterfaces" + ccldf "github.com/smartcontractkit/chainlink-ccv/build/devenv/cldf" "github.com/smartcontractkit/chainlink-ccv/build/devenv/common" _ "github.com/smartcontractkit/chainlink-ccv/build/devenv/evm" // register EVM ImplFactory "github.com/smartcontractkit/chainlink-ccv/protocol" utilstests "github.com/smartcontractkit/chainlink-common/pkg/utils/tests" + "github.com/smartcontractkit/chainlink-deployments-framework/chain/canton" "github.com/smartcontractkit/chainlink-deployments-framework/datastore" - "github.com/smartcontractkit/chainlink-testing-framework/framework" - "github.com/smartcontractkit/chainlink-testing-framework/framework/components/blockchain" "github.com/stretchr/testify/require" _ "github.com/smartcontractkit/chainlink-canton/ccip/devenv" // register Canton ImplFactory - cantondevenv "github.com/smartcontractkit/chainlink-canton/ccip/devenv" devenvtests "github.com/smartcontractkit/chainlink-canton/ccip/devenv/tests" "github.com/smartcontractkit/chainlink-canton/testhelpers" ) @@ -32,38 +30,22 @@ func TestEVM2Canton_Basic(t *testing.T) { t.Skip("skipping EVM2Canton_Basic test in short mode") } - configPath := "../../env-canton-evm-out.toml" - in, err := ccv.LoadOutput[ccv.Cfg](configPath) - require.NoError(t, err) - - lib, err := ccv.NewLibFromCCVEnv(&ccv.Plog, configPath) - require.NoError(t, err) + boot := devenvtests.BootstrapE2E(t, devenvtests.ParseEnvFromFlag(t)) - chainMap, err := lib.ChainsMap(t.Context()) - require.NoError(t, err) - require.NoError(t, devenvtests.WireVerifierObservationFromLib(lib, chainMap)) - - srcChain := devenvtests.GetChainFromMap(t, blockchain.TypeAnvil, in, chainMap) - dstChain := devenvtests.GetChainFromMap(t, blockchain.TypeCanton, in, chainMap) - cantonDest, ok := dstChain.(*cantondevenv.Chain) - require.True(t, ok, "Canton dest chain must be *devenv.Chain") - require.NoError(t, cantonDest.SetupReceive(ccv.Plog.WithContext(t.Context()))) - - t.Cleanup(func() { - _, err := framework.SaveContainerLogs(fmt.Sprintf("%s-%s", framework.DefaultCTFLogsDir, t.Name())) - require.NoError(t, err) - }) - - srcSelector := srcChain.ChainSelector() - dstSelector := dstChain.ChainSelector() - receiverParticipant, _, err := cantonDest.ClientParticipant() + srcSelector := boot.EVM.ChainSelector() + dstSelector := boot.Canton.ChainSelector() + _, opsEnv, err := ccldf.NewCLDFOperationsEnvironment(boot.Cfg.Blockchains, boot.Cfg.CLDF.DataStore) require.NoError(t, err) + var receiverParticipant canton.Participant + if chains := opsEnv.BlockChains.CantonChains(); len(chains[dstSelector].Participants) > 0 { + receiverParticipant = chains[dstSelector].Participants[0] + } require.NotEmpty(t, receiverParticipant.PartyID) - receiver, err := cantonDest.GetEOAReceiverAddress() + receiver, err := boot.Canton.GetEOAReceiverAddress() require.NoError(t, err) - ds, err := lib.DataStore() + ds, err := boot.Lib.DataStore() require.NoError(t, err) ccvAddr := devenvtests.GetContractAddress( t, ds, srcSelector, @@ -83,9 +65,9 @@ func TestEVM2Canton_Basic(t *testing.T) { t.Run("message transfer", func(t *testing.T) { subtestCtx := ccv.Plog.WithContext(t.Context()) - seqNo, err := srcChain.GetExpectedNextSequenceNumber(subtestCtx, dstSelector) + seqNo, err := boot.EVM.GetExpectedNextSequenceNumber(subtestCtx, dstSelector) require.NoError(t, err) - sendMessageResult, err := srcChain.SendMessage(subtestCtx, dstSelector, cciptestinterfaces.MessageFields{ + sendMessageResult, err := boot.EVM.SendMessage(subtestCtx, dstSelector, cciptestinterfaces.MessageFields{ Receiver: receiver, Data: []byte("Hello message transfer from EVM!"), }, cciptestinterfaces.MessageOptions{ @@ -103,19 +85,17 @@ func TestEVM2Canton_Basic(t *testing.T) { require.NoError(t, err) require.NotNil(t, sendMessageResult.Message) - sentEvent, err := srcChain.ConfirmSendOnSource(subtestCtx, dstSelector, cciptestinterfaces.MessageEventKey{SeqNum: seqNo}, 15*time.Second) + sentEvent, err := boot.EVM.ConfirmSendOnSource(subtestCtx, dstSelector, cciptestinterfaces.MessageEventKey{SeqNum: seqNo}, 15*time.Second) require.NoError(t, err) require.NotNil(t, sentEvent.Message) require.Nil(t, sentEvent.Message.TokenTransfer) execKey := cciptestinterfaces.MessageEventKey{SeqNum: seqNo, MessageID: sentEvent.MessageID} - executionStateChangedEvent, err := cantonDest.ConfirmExecOnDest(subtestCtx, srcSelector, execKey, utilstests.WaitTimeout(t)) + executionStateChangedEvent, err := boot.Canton.ConfirmExecOnDest(subtestCtx, srcSelector, execKey, utilstests.WaitTimeout(t)) require.NoError(t, err) require.Equal(t, cciptestinterfaces.ExecutionStateSuccess, executionStateChangedEvent.State) - // testing idempotency of ConfirmExecOnDest: a second call - // must return the same event without re-executing. - idempotentEvent, err := cantonDest.ConfirmExecOnDest(subtestCtx, srcSelector, execKey, utilstests.WaitTimeout(t)) + idempotentEvent, err := boot.Canton.ConfirmExecOnDest(subtestCtx, srcSelector, execKey, utilstests.WaitTimeout(t)) require.NoError(t, err) require.Equal(t, executionStateChangedEvent.State, idempotentEvent.State) require.Equal(t, executionStateChangedEvent.MessageNumber, idempotentEvent.MessageNumber) @@ -123,16 +103,17 @@ func TestEVM2Canton_Basic(t *testing.T) { }) t.Run("token transfer", func(t *testing.T) { + boot.SkipIfRemote(t, "token e2e not on prod-testnet") + 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, boot.Cfg, boot.Lib, boot.ChainMap, srcSelector, []uint64{dstSelector}) srcToken := lane.SrcToken - srcSender, err := srcChain.GetEOAReceiverAddress() + srcSender, err := boot.EVM.GetEOAReceiverAddress() require.NoError(t, err) - seqNo, err := srcChain.GetExpectedNextSequenceNumber(subtestCtx, dstSelector) + seqNo, err := boot.EVM.GetExpectedNextSequenceNumber(subtestCtx, dstSelector) require.NoError(t, err) - sendMessageResult, err := srcChain.SendMessage(subtestCtx, dstSelector, cciptestinterfaces.MessageFields{ + sendMessageResult, err := boot.EVM.SendMessage(subtestCtx, dstSelector, cciptestinterfaces.MessageFields{ Receiver: receiver, Data: []byte("Hello token transfer from EVM!"), TokenAmount: cciptestinterfaces.TokenAmount{ @@ -155,15 +136,12 @@ func TestEVM2Canton_Basic(t *testing.T) { require.NotNil(t, sendMessageResult.Message) require.NotNil(t, sendMessageResult.Message.TokenTransfer) - sentEvent, err := srcChain.ConfirmSendOnSource(subtestCtx, dstSelector, cciptestinterfaces.MessageEventKey{SeqNum: seqNo}, 15*time.Second) + sentEvent, err := boot.EVM.ConfirmSendOnSource(subtestCtx, dstSelector, cciptestinterfaces.MessageEventKey{SeqNum: seqNo}, 15*time.Second) require.NoError(t, err) require.NotNil(t, sentEvent.Message) require.NotNil(t, sentEvent.Message.TokenTransfer) - // Pre-exec assertions on the verifier result are kept here (cheap aggregator/indexer - // re-read) so the token transfer assertions stay co-located with the test body. - // ConfirmExecOnDest below performs its own fetch internally for execution. - result := devenvtests.AssertSingleVerifierResult(t, subtestCtx, lib, sentEvent.MessageID) + result := devenvtests.AssertSingleVerifierResult(t, subtestCtx, boot.Lib, sentEvent.MessageID) vr := result.IndexedVerifications.Results[0].VerifierResult require.NotNil(t, vr.Message.TokenTransfer) require.NotNil(t, vr.Message.TokenTransfer.Amount) @@ -171,7 +149,7 @@ func TestEVM2Canton_Basic(t *testing.T) { require.Positive(t, vr.Message.TokenTransfer.Amount.Cmp(big.NewInt(0)), "token transfer amount must be positive") execKey := cciptestinterfaces.MessageEventKey{SeqNum: seqNo, MessageID: sentEvent.MessageID} - executionStateChangedEvent, err := cantonDest.ConfirmExecOnDest(subtestCtx, srcSelector, execKey, utilstests.WaitTimeout(t)) + executionStateChangedEvent, err := boot.Canton.ConfirmExecOnDest(subtestCtx, srcSelector, execKey, utilstests.WaitTimeout(t)) require.NoError(t, err) require.Equal(t, cciptestinterfaces.ExecutionStateSuccess, executionStateChangedEvent.State) @@ -180,10 +158,10 @@ func TestEVM2Canton_Basic(t *testing.T) { totalHoldingsFloat, _ := new(big.Float).SetRat(totalHoldingsRat).Float64() t.Logf("Canton receiver total holdings after execute: %.10f", totalHoldingsFloat) - srcBalanceAfter, err := srcChain.GetTokenBalance(subtestCtx, srcSender, srcToken) + srcBalanceAfter, err := boot.EVM.GetTokenBalance(subtestCtx, srcSender, srcToken) require.NoError(t, err) require.NotNil(t, srcBalanceAfter) - dstBalanceAfter, err := cantonDest.GetTokenBalance(subtestCtx, receiver, nil) + dstBalanceAfter, err := boot.Canton.GetTokenBalance(subtestCtx, receiver, nil) require.NoError(t, err) require.NotNil(t, dstBalanceAfter) t.Logf("Token balances after execute: evm_sender=%s canton_receiver=%s", srcBalanceAfter.String(), dstBalanceAfter.String()) diff --git a/ccip/devenv/tests/e2e/main_test.go b/ccip/devenv/tests/e2e/main_test.go new file mode 100644 index 000000000..bf115df39 --- /dev/null +++ b/ccip/devenv/tests/e2e/main_test.go @@ -0,0 +1,12 @@ +package canton + +import ( + "flag" + "os" + "testing" +) + +func TestMain(m *testing.M) { + flag.Parse() + os.Exit(m.Run()) +} diff --git a/ccip/devenv/tests/env.go b/ccip/devenv/tests/env.go new file mode 100644 index 000000000..7e18f82bc --- /dev/null +++ b/ccip/devenv/tests/env.go @@ -0,0 +1,73 @@ +package tests + +import ( + "flag" + "fmt" + "os" + "strings" + "testing" + + "github.com/stretchr/testify/require" +) + +// CCIPEnv names a CCIP e2e target environment. +type CCIPEnv string + +const ( + EnvDevenv CCIPEnv = "devenv" + EnvProdTestnet CCIPEnv = "prod-testnet" +) + +var ccipEnvFlag = flag.String( + "ccip-env", + defaultFromEnv("CCIP_ENV", string(EnvDevenv)), + "CCIP e2e environment: devenv (default) or prod-testnet", +) + +func defaultFromEnv(key, fallback string) string { + if v := strings.TrimSpace(os.Getenv(key)); v != "" { + return v + } + return fallback +} + +// 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) + } +} + +// ConfigPath returns the CCV env output TOML filename under ccip/devenv. +func (e CCIPEnv) ConfigPath() string { + switch e { + case EnvDevenv: + return "env-canton-evm-out.toml" + case EnvProdTestnet: + return "env-prod-testnet-out.toml" + default: + return "" + } +} + +// IsRemote reports whether the environment targets live testnet infrastructure. +func (e CCIPEnv) IsRemote() bool { + return e == EnvProdTestnet +} + +// ParseEnvFromFlag reads the -ccip-env flag (defaulting from CCIP_ENV). +func ParseEnvFromFlag(t *testing.T) CCIPEnv { + t.Helper() + + env, err := ParseCCIPEnv(*ccipEnvFlag) + require.NoError(t, err) + + return env +} diff --git a/ccip/devenv/tests/helpers.go b/ccip/devenv/tests/helpers.go index 8818cab1c..db26d058b 100644 --- a/ccip/devenv/tests/helpers.go +++ b/ccip/devenv/tests/helpers.go @@ -2,10 +2,15 @@ package tests import ( "context" + "fmt" + "os" + "path/filepath" + "strings" "testing" "time" "github.com/Masterminds/semver/v3" + gethcrypto "github.com/ethereum/go-ethereum/crypto" chainsel "github.com/smartcontractkit/chain-selectors" ccv "github.com/smartcontractkit/chainlink-ccv/build/devenv" "github.com/smartcontractkit/chainlink-ccv/build/devenv/cciptestinterfaces" @@ -13,12 +18,104 @@ import ( "github.com/smartcontractkit/chainlink-ccv/protocol" utilstests "github.com/smartcontractkit/chainlink-common/pkg/utils/tests" "github.com/smartcontractkit/chainlink-deployments-framework/datastore" + "github.com/smartcontractkit/chainlink-testing-framework/framework" "github.com/smartcontractkit/chainlink-testing-framework/framework/components/blockchain" "github.com/stretchr/testify/require" cantondevenv "github.com/smartcontractkit/chainlink-canton/ccip/devenv" ) +// E2EBootstrap holds shared CCIP e2e setup for a selected environment. +type E2EBootstrap struct { + Env CCIPEnv + Cfg *ccv.Cfg + Lib ccv.Lib + ChainMap map[uint64]cciptestinterfaces.CCIP17 + Canton *cantondevenv.Chain + EVM cciptestinterfaces.CCIP17 +} + +// BootstrapE2E loads config, wires verifier observation, and resolves Canton + EVM chains. +func BootstrapE2E(t *testing.T, env CCIPEnv) E2EBootstrap { + t.Helper() + + if env.IsRemote() && os.Getenv("CANTON_GRPC_URL") == "" { + t.Skip("CANTON_GRPC_URL unset: not configured for remote Canton") + } + + configPath := filepath.Join("..", "..", env.ConfigPath()) + in, err := ccv.LoadOutput[ccv.Cfg](configPath) + require.NoError(t, err) + + lib, err := ccv.NewLibFromCCVEnv(&ccv.Plog, configPath) + require.NoError(t, err) + + ctx := t.Context() + chainMap, err := lib.ChainsMap(ctx) + require.NoError(t, err) + require.NoError(t, WireVerifierObservationFromLib(lib, chainMap)) + + evmChain := GetChainFromMap(t, blockchain.TypeAnvil, in, chainMap) + cantonChain := GetChainFromMap(t, blockchain.TypeCanton, in, chainMap) + cantonImpl, ok := cantonChain.(*cantondevenv.Chain) + require.True(t, ok, "Canton chain must be *devenv.Chain") + + if !env.IsRemote() { + t.Cleanup(func() { + _, err := framework.SaveContainerLogs(fmt.Sprintf("%s-%s", framework.DefaultCTFLogsDir, t.Name())) + require.NoError(t, err) + }) + } + + return E2EBootstrap{ + Env: env, + Cfg: in, + Lib: lib, + ChainMap: chainMap, + Canton: cantonImpl, + EVM: evmChain, + } +} + +// SetupCantonSend prepares Canton for send in both envs. +// devenv mints fee Amulet before SetupSend; prod-testnet assumes the party is already funded. +func (b E2EBootstrap) SetupCantonSend(t *testing.T, ctx context.Context, transferAmount uint64) { + t.Helper() + + fee := uint64(CantonToEVMFeeAmount) + if !b.Env.IsRemote() { + require.NoError(t, b.Canton.MintTokens(ctx, fee)) + } + require.NoError(t, b.Canton.SetupSend(ctx, fee, transferAmount)) +} + +// ResolveEVMReceiver returns the EVM-side message receiver for Canton→EVM sends. +func (b E2EBootstrap) ResolveEVMReceiver(t *testing.T) protocol.UnknownAddress { + t.Helper() + + if b.Env.IsRemote() { + pkHex := strings.TrimSpace(os.Getenv("PRIVATE_KEY")) + require.NotEmpty(t, pkHex, "PRIVATE_KEY required for prod-testnet EVM receiver") + pkHex = strings.TrimPrefix(pkHex, "0x") + pk, err := gethcrypto.HexToECDSA(pkHex) + require.NoError(t, err) + addr := gethcrypto.PubkeyToAddress(pk.PublicKey) + return protocol.UnknownAddress(addr.Bytes()) + } + + receiver, err := b.EVM.GetEOAReceiverAddress() + require.NoError(t, err) + return receiver +} + +// SkipIfRemote skips token subtests that are not supported on prod-testnet. +func (b E2EBootstrap) SkipIfRemote(t *testing.T, reason string) { + t.Helper() + if b.Env.IsRemote() { + t.Skip(reason) + } +} + func GetContractAddress( t *testing.T, ds datastore.DataStore, diff --git a/ccip/devenv/tests/integration/canton_prod_testnet_connection_test.go b/ccip/devenv/tests/integration/canton_prod_testnet_connection_test.go index 10cfa1b73..9dc01d172 100644 --- a/ccip/devenv/tests/integration/canton_prod_testnet_connection_test.go +++ b/ccip/devenv/tests/integration/canton_prod_testnet_connection_test.go @@ -3,6 +3,7 @@ package integration import ( "flag" "os" + "path/filepath" "testing" ccv "github.com/smartcontractkit/chainlink-ccv/build/devenv" @@ -13,16 +14,10 @@ import ( cantondevenv "github.com/smartcontractkit/chainlink-canton/ccip/devenv" _ "github.com/smartcontractkit/chainlink-canton/ccip/devenv" // register canton impl factory + devenvtests "github.com/smartcontractkit/chainlink-canton/ccip/devenv/tests" "github.com/smartcontractkit/chainlink-canton/testhelpers" ) -const ( - EnvCantonEvmOutToml = "../../env-canton-evm-out.toml" - EnvProdTestnetOutToml = "../../env-prod-testnet-out.toml" -) - -var cantonEnvOut = flag.String("canton-env-out", EnvProdTestnetOutToml, "path to CCV env output TOML") - func TestMain(m *testing.M) { flag.Parse() os.Exit(m.Run()) @@ -36,7 +31,8 @@ func TestIntegration_CantonProdTestnet_Connection(t *testing.T) { t.Skip("CANTON_GRPC_URL unset: not configured for real Canton testnet") } - configPath := *cantonEnvOut + env := devenvtests.ParseEnvFromFlag(t) + configPath := filepath.Join("../..", env.ConfigPath()) in, err := ccv.LoadOutput[ccv.Cfg](configPath) require.NoError(t, err) From 816925eeaacb6c73aedaee6de2eb40e29e923bf6 Mon Sep 17 00:00:00 2001 From: Rodrigo Date: Thu, 18 Jun 2026 13:31:48 -0300 Subject: [PATCH 4/9] feat(e2e): e2e tests connected to real env --- ccip/devenv/impl.go | 2 +- ccip/devenv/manual_execution.go | 24 +++++++++++++++----- ccip/devenv/tests/constants.go | 8 ++++++- ccip/devenv/tests/e2e/canton2evm_e2e_test.go | 5 ++-- ccip/devenv/tests/e2e/evm2canton_e2e_test.go | 2 ++ ccip/devenv/tests/helpers.go | 7 ++++++ ccip/devenv/tests/load/load_helpers.go | 5 ++-- 7 files changed, 41 insertions(+), 12 deletions(-) diff --git a/ccip/devenv/impl.go b/ccip/devenv/impl.go index c61f6b891..b9821de91 100644 --- a/ccip/devenv/impl.go +++ b/ccip/devenv/impl.go @@ -1021,7 +1021,7 @@ func (c *Chain) SetupSend( feeAmountPerMessage uint64, transferAmountPerMessage uint64, ) error { - participant, clientIdx, err := c.ClientParticipant() + participant, _, err := c.ClientParticipant() if err != nil { return fmt.Errorf("no canton participants configured: %w", err) } diff --git a/ccip/devenv/manual_execution.go b/ccip/devenv/manual_execution.go index 12bb81fe3..0a7e10c70 100644 --- a/ccip/devenv/manual_execution.go +++ b/ccip/devenv/manual_execution.go @@ -124,17 +124,17 @@ func (c *Chain) findPerPartyRouterByParty( if created == nil { continue } - router, err := bindings.UnmarshalCreatedEvent[ccipruntime.PerPartyRouter](created) - if err != nil { - c.logger.Debug().Err(err).Msg("Skipping unparseable PerPartyRouter active contract") + instanceId, partyOwner, ok := perPartyRouterFieldsFromCreated(created) + if !ok { + c.logger.Debug().Msg("Skipping PerPartyRouter active contract missing instanceId or partyOwner") continue } - if string(router.PartyOwner) != partyId { + if partyOwner != partyId { continue } - addr := contracts.InstanceID(router.InstanceId).RawInstanceAddress(types.PARTY(partyId)).InstanceAddress() + addr := contracts.InstanceID(instanceId).RawInstanceAddress(types.PARTY(partyId)).InstanceAddress() cid := created.GetContractId() - if contracts.InstanceID(router.InstanceId) == preferredInstanceID { + if contracts.InstanceID(instanceId) == preferredInstanceID { return addr, cid, true, nil } if !hasFallback { @@ -150,6 +150,18 @@ func (c *Chain) findPerPartyRouterByParty( return contracts.InstanceAddress{}, "", false, nil } +func perPartyRouterFieldsFromCreated(created *apiv2.CreatedEvent) (instanceId, partyOwner string, ok bool) { + for _, field := range created.GetCreateArguments().GetFields() { + switch field.GetLabel() { + case "instanceId": + instanceId = field.GetValue().GetText() + case "partyOwner": + partyOwner = field.GetValue().GetParty() + } + } + return instanceId, partyOwner, instanceId != "" && partyOwner != "" +} + // findPerPartyRouterCidByParty resolves the ledger contract ID for a PerPartyRouter owned by partyId. // PerPartyRouter is signed by ccipOwner, so instance-address lookup must use partyOwner (see OnRamp.daml). func (c *Chain) findPerPartyRouterCidByParty(ctx context.Context, participant canton.Participant, partyId string) (string, error) { diff --git a/ccip/devenv/tests/constants.go b/ccip/devenv/tests/constants.go index 057d9c2c4..35ab1a0a5 100644 --- a/ccip/devenv/tests/constants.go +++ b/ccip/devenv/tests/constants.go @@ -3,9 +3,15 @@ package tests // Shared devenv test constants (message and token paths). const ( - // CantonToEVMFeeAmount is the Canton CCIP send fee in Amulet units. + // 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 + // EVMDecimalsScale converts Canton token amounts to EVM 18-decimal balance units. EVMDecimalsScale int64 = 1_000_000_000_000_000_000 diff --git a/ccip/devenv/tests/e2e/canton2evm_e2e_test.go b/ccip/devenv/tests/e2e/canton2evm_e2e_test.go index b80ed4915..8d5634034 100644 --- a/ccip/devenv/tests/e2e/canton2evm_e2e_test.go +++ b/ccip/devenv/tests/e2e/canton2evm_e2e_test.go @@ -116,13 +116,14 @@ func TestCanton2EVM_Basic(t *testing.T) { lane := devenvtests.ResolveTokenLane(t, boot.Cfg, boot.Lib, boot.ChainMap, boot.Canton.ChainSelector(), []uint64{boot.EVM.ChainSelector()}) tokenTransferAmount := lane.TransferAmount.Uint64() + tokenFeePerSend := uint64(devenvtests.CantonToEVMTokenTransferFeeAmount) require.NoError(t, boot.Canton.MintTokens(ctx, - devenvtests.CantonToEVMTokenSequentialSends*uint64(devenvtests.CantonToEVMFeeAmount), + devenvtests.CantonToEVMTokenSequentialSends*tokenFeePerSend, )) require.NoError(t, boot.Canton.MintTokens(ctx, devenvtests.CantonToEVMTokenSequentialSends*tokenTransferAmount, )) - require.NoError(t, boot.Canton.SetupSend(ctx, uint64(devenvtests.CantonToEVMFeeAmount), tokenTransferAmount)) + require.NoError(t, boot.Canton.SetupSend(ctx, tokenFeePerSend, tokenTransferAmount)) receiver := boot.ResolveEVMReceiver(t) diff --git a/ccip/devenv/tests/e2e/evm2canton_e2e_test.go b/ccip/devenv/tests/e2e/evm2canton_e2e_test.go index dcbee6dbb..b36d9d39e 100644 --- a/ccip/devenv/tests/e2e/evm2canton_e2e_test.go +++ b/ccip/devenv/tests/e2e/evm2canton_e2e_test.go @@ -31,6 +31,8 @@ func TestEVM2Canton_Basic(t *testing.T) { } boot := devenvtests.BootstrapE2E(t, devenvtests.ParseEnvFromFlag(t)) + ctx := ccv.Plog.WithContext(t.Context()) + boot.SetupCantonReceive(t, ctx) srcSelector := boot.EVM.ChainSelector() dstSelector := boot.Canton.ChainSelector() diff --git a/ccip/devenv/tests/helpers.go b/ccip/devenv/tests/helpers.go index db26d058b..890e1f83a 100644 --- a/ccip/devenv/tests/helpers.go +++ b/ccip/devenv/tests/helpers.go @@ -89,6 +89,13 @@ func (b E2EBootstrap) SetupCantonSend(t *testing.T, ctx context.Context, transfe require.NoError(t, b.Canton.SetupSend(ctx, fee, transferAmount)) } +// SetupCantonReceive deploys the client party's PerPartyRouter before inbound messages +// are executed on Canton (e.g. EVM→Canton). +func (b E2EBootstrap) SetupCantonReceive(t *testing.T, ctx context.Context) { + t.Helper() + require.NoError(t, b.Canton.SetupReceive(ctx)) +} + // ResolveEVMReceiver returns the EVM-side message receiver for Canton→EVM sends. func (b E2EBootstrap) ResolveEVMReceiver(t *testing.T) protocol.UnknownAddress { t.Helper() diff --git a/ccip/devenv/tests/load/load_helpers.go b/ccip/devenv/tests/load/load_helpers.go index 4bf0b1399..8c65e6f1b 100644 --- a/ccip/devenv/tests/load/load_helpers.go +++ b/ccip/devenv/tests/load/load_helpers.go @@ -205,7 +205,8 @@ func setupCantonTokenLoadHoldings( t.Helper() estimated := estimateMessages(sched) - feeMint := estimated * uint64(devenvtests.CantonToEVMFeeAmount) + tokenFeePerSend := uint64(devenvtests.CantonToEVMTokenTransferFeeAmount) + feeMint := estimated * tokenFeePerSend transferMint := estimated * lane.TransferAmount.Uint64() t.Logf("Pre-mint: estimatedMessages=%d feeMint=%d transferMint=%d", estimated, feeMint, transferMint) @@ -213,7 +214,7 @@ func setupCantonTokenLoadHoldings( require.NoError(t, cantonImpl.MintTokens(ctx, transferMint)) require.NoError(t, cantonImpl.SetupSend( ctx, - uint64(devenvtests.CantonToEVMFeeAmount), + tokenFeePerSend, lane.TransferAmount.Uint64(), )) } From 2734f5a300d6d7dc911b5e9261c6c671a6bebd2a Mon Sep 17 00:00:00 2001 From: Rodrigo Date: Thu, 18 Jun 2026 22:56:53 -0300 Subject: [PATCH 5/9] feat(e2e): enable ftf config --- ccip/devenv/impl.go | 12 ++++--- ccip/devenv/tests/constants.go | 5 +++ ccip/devenv/tests/e2e/evm2canton_e2e_test.go | 20 +++-------- ccip/devenv/tests/helpers.go | 35 +++++++++++++++++-- ccip/devenv/tests/load/gun_evm2canton_test.go | 3 +- .../tests/load/gun_evm2canton_token_test.go | 3 +- ccip/devenv/tests/load/load_helpers.go | 9 +---- ccip/devenv/tests/token_transfer_config.toml | 17 ++++----- 8 files changed, 61 insertions(+), 43 deletions(-) diff --git a/ccip/devenv/impl.go b/ccip/devenv/impl.go index b9821de91..a5f5fdf3e 100644 --- a/ccip/devenv/impl.go +++ b/ccip/devenv/impl.go @@ -549,9 +549,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 } @@ -759,8 +760,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 } diff --git a/ccip/devenv/tests/constants.go b/ccip/devenv/tests/constants.go index 35ab1a0a5..6f036229e 100644 --- a/ccip/devenv/tests/constants.go +++ b/ccip/devenv/tests/constants.go @@ -1,8 +1,13 @@ package tests +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 diff --git a/ccip/devenv/tests/e2e/evm2canton_e2e_test.go b/ccip/devenv/tests/e2e/evm2canton_e2e_test.go index b36d9d39e..3c8a602f5 100644 --- a/ccip/devenv/tests/e2e/evm2canton_e2e_test.go +++ b/ccip/devenv/tests/e2e/evm2canton_e2e_test.go @@ -14,7 +14,6 @@ import ( "github.com/smartcontractkit/chainlink-ccv/build/devenv/common" _ "github.com/smartcontractkit/chainlink-ccv/build/devenv/evm" // register EVM ImplFactory "github.com/smartcontractkit/chainlink-ccv/protocol" - utilstests "github.com/smartcontractkit/chainlink-common/pkg/utils/tests" "github.com/smartcontractkit/chainlink-deployments-framework/chain/canton" "github.com/smartcontractkit/chainlink-deployments-framework/datastore" "github.com/stretchr/testify/require" @@ -72,18 +71,7 @@ func TestEVM2Canton_Basic(t *testing.T) { sendMessageResult, err := boot.EVM.SendMessage(subtestCtx, dstSelector, cciptestinterfaces.MessageFields{ Receiver: receiver, Data: []byte("Hello message transfer from EVM!"), - }, cciptestinterfaces.MessageOptions{ - ExecutionGasLimit: 200_000, - FinalityConfig: 0, - Executor: executorAddress, - CCVs: []protocol.CCV{ - { - CCVAddress: ccvAddr, - Args: []byte{}, - ArgsLen: 0, - }, - }, - }, 3) + }, devenvtests.EVMToCantonMessageOptions(200_000, executorAddress, ccvAddr), 3) require.NoError(t, err) require.NotNil(t, sendMessageResult.Message) @@ -93,11 +81,11 @@ func TestEVM2Canton_Basic(t *testing.T) { require.Nil(t, sentEvent.Message.TokenTransfer) execKey := cciptestinterfaces.MessageEventKey{SeqNum: seqNo, MessageID: sentEvent.MessageID} - executionStateChangedEvent, err := boot.Canton.ConfirmExecOnDest(subtestCtx, srcSelector, execKey, utilstests.WaitTimeout(t)) + executionStateChangedEvent, err := boot.Canton.ConfirmExecOnDest(subtestCtx, srcSelector, execKey, devenvtests.ConfirmExecTimeout(t)) require.NoError(t, err) require.Equal(t, cciptestinterfaces.ExecutionStateSuccess, executionStateChangedEvent.State) - idempotentEvent, err := boot.Canton.ConfirmExecOnDest(subtestCtx, srcSelector, execKey, utilstests.WaitTimeout(t)) + idempotentEvent, err := boot.Canton.ConfirmExecOnDest(subtestCtx, srcSelector, execKey, devenvtests.ConfirmExecTimeout(t)) require.NoError(t, err) require.Equal(t, executionStateChangedEvent.State, idempotentEvent.State) require.Equal(t, executionStateChangedEvent.MessageNumber, idempotentEvent.MessageNumber) @@ -151,7 +139,7 @@ func TestEVM2Canton_Basic(t *testing.T) { require.Positive(t, vr.Message.TokenTransfer.Amount.Cmp(big.NewInt(0)), "token transfer amount must be positive") execKey := cciptestinterfaces.MessageEventKey{SeqNum: seqNo, MessageID: sentEvent.MessageID} - executionStateChangedEvent, err := boot.Canton.ConfirmExecOnDest(subtestCtx, srcSelector, execKey, utilstests.WaitTimeout(t)) + executionStateChangedEvent, err := boot.Canton.ConfirmExecOnDest(subtestCtx, srcSelector, execKey, devenvtests.ConfirmExecTimeout(t)) require.NoError(t, err) require.Equal(t, cciptestinterfaces.ExecutionStateSuccess, executionStateChangedEvent.State) diff --git a/ccip/devenv/tests/helpers.go b/ccip/devenv/tests/helpers.go index 890e1f83a..e4bc64b0b 100644 --- a/ccip/devenv/tests/helpers.go +++ b/ccip/devenv/tests/helpers.go @@ -16,7 +16,6 @@ import ( "github.com/smartcontractkit/chainlink-ccv/build/devenv/cciptestinterfaces" "github.com/smartcontractkit/chainlink-ccv/build/devenv/tests/e2e/tcapi" "github.com/smartcontractkit/chainlink-ccv/protocol" - utilstests "github.com/smartcontractkit/chainlink-common/pkg/utils/tests" "github.com/smartcontractkit/chainlink-deployments-framework/datastore" "github.com/smartcontractkit/chainlink-testing-framework/framework" "github.com/smartcontractkit/chainlink-testing-framework/framework/components/blockchain" @@ -25,6 +24,38 @@ import ( cantondevenv "github.com/smartcontractkit/chainlink-canton/ccip/devenv" ) +const ( + envConfirmExecTimeout = "CANTON_CONFIRM_EXEC_TIMEOUT" + defaultConfirmExecTimeout = 5 * time.Minute +) + +// ConfirmExecTimeout returns the timeout for Canton ConfirmExecOnDest polling. +// Default is 5 minutes; override with CANTON_CONFIRM_EXEC_TIMEOUT (e.g. "10m"). +func ConfirmExecTimeout(t *testing.T) time.Duration { + t.Helper() + + timeout := defaultConfirmExecTimeout + if d := os.Getenv(envConfirmExecTimeout); d != "" { + parsed, err := time.ParseDuration(d) + require.NoError(t, err, "%s=%q invalid", envConfirmExecTimeout, d) + timeout = parsed + } + + return timeout +} + +// EVMToCantonMessageOptions returns standard message options for EVM→Canton sends with FTF. +func EVMToCantonMessageOptions(gasLimit uint32, executor, ccvAddr protocol.UnknownAddress) cciptestinterfaces.MessageOptions { + return cciptestinterfaces.MessageOptions{ + ExecutionGasLimit: gasLimit, + FinalityConfig: EVMToCantonFinalityConfig, + Executor: executor, + CCVs: []protocol.CCV{ + {CCVAddress: ccvAddr, Args: []byte{}, ArgsLen: 0}, + }, + } +} + // E2EBootstrap holds shared CCIP e2e setup for a selected environment. type E2EBootstrap struct { Env CCIPEnv @@ -233,7 +264,7 @@ func AssertSingleVerifierResult( result, err := cantondevenv.AssertMessageWithVerifierObservation(ctx, obs, messageID, tcapi.AssertMessageOptions{ TickInterval: time.Second, - Timeout: utilstests.WaitTimeout(t), + Timeout: ConfirmExecTimeout(t), ExpectedVerifierResults: 1, AssertVerifierLogs: false, AssertExecutorLogs: false, diff --git a/ccip/devenv/tests/load/gun_evm2canton_test.go b/ccip/devenv/tests/load/gun_evm2canton_test.go index 034633ae5..365820999 100644 --- a/ccip/devenv/tests/load/gun_evm2canton_test.go +++ b/ccip/devenv/tests/load/gun_evm2canton_test.go @@ -8,7 +8,6 @@ import ( chainsel "github.com/smartcontractkit/chain-selectors" ccv "github.com/smartcontractkit/chainlink-ccv/build/devenv" _ "github.com/smartcontractkit/chainlink-ccv/build/devenv/evm" // register EVM ImplFactory - utilstests "github.com/smartcontractkit/chainlink-common/pkg/utils/tests" "github.com/smartcontractkit/chainlink-testing-framework/framework" "github.com/smartcontractkit/chainlink-testing-framework/framework/components/blockchain" "github.com/stretchr/testify/require" @@ -66,7 +65,7 @@ func TestEVM2Canton_Load(t *testing.T) { []Destination{cantonDest}, ccvAddr, executorAddr, - utilstests.WaitTimeout(t), + devenvtests.ConfirmExecTimeout(t), ) require.NoError(t, err) diff --git a/ccip/devenv/tests/load/gun_evm2canton_token_test.go b/ccip/devenv/tests/load/gun_evm2canton_token_test.go index 426e29fed..beb80ccc6 100644 --- a/ccip/devenv/tests/load/gun_evm2canton_token_test.go +++ b/ccip/devenv/tests/load/gun_evm2canton_token_test.go @@ -9,7 +9,6 @@ import ( chainsel "github.com/smartcontractkit/chain-selectors" ccv "github.com/smartcontractkit/chainlink-ccv/build/devenv" _ "github.com/smartcontractkit/chainlink-ccv/build/devenv/evm" // register EVM ImplFactory - utilstests "github.com/smartcontractkit/chainlink-common/pkg/utils/tests" "github.com/smartcontractkit/chainlink-testing-framework/framework" "github.com/smartcontractkit/chainlink-testing-framework/framework/components/blockchain" "github.com/stretchr/testify/require" @@ -91,7 +90,7 @@ func TestEVM2Canton_TokenLoad(t *testing.T) { []Destination{cantonDest}, ccvAddr, executorAddr, - utilstests.WaitTimeout(t), + devenvtests.ConfirmExecTimeout(t), ) require.NoError(t, err) diff --git a/ccip/devenv/tests/load/load_helpers.go b/ccip/devenv/tests/load/load_helpers.go index 8c65e6f1b..23535b87b 100644 --- a/ccip/devenv/tests/load/load_helpers.go +++ b/ccip/devenv/tests/load/load_helpers.go @@ -285,14 +285,7 @@ func cantonLoadDestination(chain cciptestinterfaces.CCIP17, receiver protocol.Un return cciptestinterfaces.MessageFields{ Receiver: receiver, Data: fmt.Appendf(nil, "evm2canton load n=%d dest=%d", callNum, destSelector), - }, cciptestinterfaces.MessageOptions{ - ExecutionGasLimit: 200_000, - FinalityConfig: 0, - Executor: executorAddr, - CCVs: []protocol.CCV{ - {CCVAddress: ccvAddr, Args: []byte{}, ArgsLen: 0}, - }, - }, nil + }, devenvtests.EVMToCantonMessageOptions(200_000, executorAddr, ccvAddr), nil }, } } diff --git a/ccip/devenv/tests/token_transfer_config.toml b/ccip/devenv/tests/token_transfer_config.toml index c3bbfe7ee..002c0b51c 100644 --- a/ccip/devenv/tests/token_transfer_config.toml +++ b/ccip/devenv/tests/token_transfer_config.toml @@ -8,17 +8,18 @@ # Override the file path with the CANTON_TOKEN_TEST_CONFIG env var. [evm_to_canton] -pool_type = "BurnMintTokenPool" -pool_version = "2.0.0" +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" +transfer_amount = "100000000000" execution_gas_limit = 200000 -finality_config = 0 +# 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" +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 From 5de042054c4bbbbd905407a80981a78689d58dae Mon Sep 17 00:00:00 2001 From: Rodrigo Date: Thu, 18 Jun 2026 23:54:52 -0300 Subject: [PATCH 6/9] fix(e2e): evm2canton e2e prod-testnet working --- ccip/devenv/manual_execution.go | 33 ++++++++---- ccip/devenv/manual_execution_test.go | 38 ++++++++++++++ ccip/devenv/tests/e2e/canton2evm_e2e_test.go | 5 +- ccip/devenv/tests/e2e/evm2canton_e2e_test.go | 5 +- ccip/devenv/tests/helpers.go | 53 ++++++++++++++++++++ 5 files changed, 117 insertions(+), 17 deletions(-) create mode 100644 ccip/devenv/manual_execution_test.go diff --git a/ccip/devenv/manual_execution.go b/ccip/devenv/manual_execution.go index 0a7e10c70..aa3e4cd16 100644 --- a/ccip/devenv/manual_execution.go +++ b/ccip/devenv/manual_execution.go @@ -645,17 +645,28 @@ func encodeReceiverFinalityConfig(finality int64) (core.FinalityConfig, error) { } } -// hashInstanceAddress decodes a verifier result's VerifierDestAddress on Canton. -// On Canton the verifier result carries the raw instance address as a hex-encoded -// string (bytes); to look up its disclosure from EDS we need the hashed instance -// address. This helper is the non-test counterpart of getHashedInstanceAddress -// previously kept in evm2canton_e2e_test.go. -func hashInstanceAddress(rawInstanceAddressBytes protocol.UnknownAddress) (protocol.UnknownAddress, error) { - rawInstanceAddressStr := string(rawInstanceAddressBytes.Bytes()) - rawInstanceAddress, err := contracts.RawInstanceAddressFromString(rawInstanceAddressStr) - if err != nil { - return nil, fmt.Errorf("hashInstanceAddress: %w", err) +// hashInstanceAddress resolves a verifier result's VerifierDestAddress to the hashed +// Canton instance address used for EDS disclosure lookup. +// +// The indexer may return either format depending on environment: +// - devenv: raw instance address string bytes ("instanceId@owner") +// - prod-testnet: already-hashed 32-byte InstanceAddress (from verifier config) +func hashInstanceAddress(addr protocol.UnknownAddress) (protocol.UnknownAddress, error) { + if len(addr) == 0 { + return nil, fmt.Errorf("empty verifier dest address") + } + + if raw, err := contracts.RawInstanceAddressFromString(string(addr)); err == nil { + return protocol.UnknownAddress(raw.InstanceAddress().Bytes()), nil + } + + if len(addr) == contracts.InstanceAddressLength { + return addr, nil + } + + if hexAddr, err := protocol.NewUnknownAddressFromHex(string(addr)); err == nil && len(hexAddr) == contracts.InstanceAddressLength { + return hexAddr, nil } - return protocol.UnknownAddress(rawInstanceAddress.InstanceAddress().Bytes()), nil + return nil, fmt.Errorf("unrecognized verifier dest address format (len=%d)", len(addr)) } diff --git a/ccip/devenv/manual_execution_test.go b/ccip/devenv/manual_execution_test.go new file mode 100644 index 000000000..8875f8cef --- /dev/null +++ b/ccip/devenv/manual_execution_test.go @@ -0,0 +1,38 @@ +package devenv + +import ( + "testing" + + "github.com/smartcontractkit/chainlink-ccv/protocol" + "github.com/stretchr/testify/require" + + "github.com/smartcontractkit/chainlink-canton/contracts" +) + +func TestHashInstanceAddress_rawInstanceAddress(t *testing.T) { + t.Parallel() + + raw := "test-verifier@ccvOwner::1220abcd" + addr, err := hashInstanceAddress(protocol.UnknownAddress(raw)) + require.NoError(t, err) + + expectedRaw, err := contracts.RawInstanceAddressFromString(raw) + require.NoError(t, err) + require.Equal(t, protocol.UnknownAddress(expectedRaw.InstanceAddress().Bytes()), addr) +} + +func TestHashInstanceAddress_alreadyHashed(t *testing.T) { + t.Parallel() + + hashed := protocol.UnknownAddress(contracts.HexToInstanceAddress("0xec1e288bcf8bbf034ac2d31b67f9b15a3f1f828d086c5b9d8fc2866129cd02fe").Bytes()) + addr, err := hashInstanceAddress(hashed) + require.NoError(t, err) + require.Equal(t, hashed, addr) +} + +func TestHashInstanceAddress_empty(t *testing.T) { + t.Parallel() + + _, err := hashInstanceAddress(nil) + require.Error(t, err) +} diff --git a/ccip/devenv/tests/e2e/canton2evm_e2e_test.go b/ccip/devenv/tests/e2e/canton2evm_e2e_test.go index 8d5634034..82a60719c 100644 --- a/ccip/devenv/tests/e2e/canton2evm_e2e_test.go +++ b/ccip/devenv/tests/e2e/canton2evm_e2e_test.go @@ -3,7 +3,6 @@ package canton import ( "math/big" "testing" - "time" ccv "github.com/smartcontractkit/chainlink-ccv/build/devenv" "github.com/smartcontractkit/chainlink-ccv/build/devenv/cciptestinterfaces" @@ -89,7 +88,7 @@ func TestCanton2EVM_Basic(t *testing.T) { t.Logf("SendMessage accepted: seqNo=%d", seqNo) t.Logf("Waiting for CCIPMessageSent event: from=%d to=%d seq=%d", boot.Canton.ChainSelector(), boot.EVM.ChainSelector(), seqNo) - sentEvent, err := boot.Canton.ConfirmSendOnSource(subtestCtx, boot.EVM.ChainSelector(), cciptestinterfaces.MessageEventKey{SeqNum: seqNo}, 30*time.Second) + sentEvent, err := boot.Canton.ConfirmSendOnSource(subtestCtx, boot.EVM.ChainSelector(), cciptestinterfaces.MessageEventKey{SeqNum: seqNo}, devenvtests.ConfirmSendTimeout(t, boot.Env)) require.NoError(t, err) t.Logf("CCIPMessageSent event: %+v", sentEvent) @@ -179,7 +178,7 @@ func TestCanton2EVM_Basic(t *testing.T) { require.NotNil(t, sendMessageResult.Message.TokenTransfer) seqNo := uint64(sendMessageResult.Message.SequenceNumber) - sentEvent, err := boot.Canton.ConfirmSendOnSource(subtestCtx, boot.EVM.ChainSelector(), cciptestinterfaces.MessageEventKey{SeqNum: seqNo}, tests.WaitTimeout(t)) + sentEvent, err := boot.Canton.ConfirmSendOnSource(subtestCtx, boot.EVM.ChainSelector(), cciptestinterfaces.MessageEventKey{SeqNum: seqNo}, devenvtests.ConfirmSendTimeout(t, boot.Env)) require.NoError(t, err) require.NotNil(t, sentEvent.Message) require.NotNil(t, sentEvent.Message.TokenTransfer) diff --git a/ccip/devenv/tests/e2e/evm2canton_e2e_test.go b/ccip/devenv/tests/e2e/evm2canton_e2e_test.go index 3c8a602f5..c82907a56 100644 --- a/ccip/devenv/tests/e2e/evm2canton_e2e_test.go +++ b/ccip/devenv/tests/e2e/evm2canton_e2e_test.go @@ -3,7 +3,6 @@ package canton import ( "math/big" "testing" - "time" "github.com/smartcontractkit/chainlink-ccip/chains/evm/deployment/v2_0_0/operations/proxy" "github.com/smartcontractkit/chainlink-ccip/chains/evm/deployment/v2_0_0/sequences" @@ -75,7 +74,7 @@ func TestEVM2Canton_Basic(t *testing.T) { require.NoError(t, err) require.NotNil(t, sendMessageResult.Message) - sentEvent, err := boot.EVM.ConfirmSendOnSource(subtestCtx, dstSelector, cciptestinterfaces.MessageEventKey{SeqNum: seqNo}, 15*time.Second) + sentEvent, err := boot.ConfirmEVMSendOnSource(t, subtestCtx, dstSelector, seqNo, sendMessageResult) require.NoError(t, err) require.NotNil(t, sentEvent.Message) require.Nil(t, sentEvent.Message.TokenTransfer) @@ -126,7 +125,7 @@ func TestEVM2Canton_Basic(t *testing.T) { require.NotNil(t, sendMessageResult.Message) require.NotNil(t, sendMessageResult.Message.TokenTransfer) - sentEvent, err := boot.EVM.ConfirmSendOnSource(subtestCtx, dstSelector, cciptestinterfaces.MessageEventKey{SeqNum: seqNo}, 15*time.Second) + sentEvent, err := boot.ConfirmEVMSendOnSource(t, subtestCtx, dstSelector, seqNo, sendMessageResult) require.NoError(t, err) require.NotNil(t, sentEvent.Message) require.NotNil(t, sentEvent.Message.TokenTransfer) diff --git a/ccip/devenv/tests/helpers.go b/ccip/devenv/tests/helpers.go index e4bc64b0b..4dfc813ea 100644 --- a/ccip/devenv/tests/helpers.go +++ b/ccip/devenv/tests/helpers.go @@ -27,6 +27,8 @@ import ( const ( envConfirmExecTimeout = "CANTON_CONFIRM_EXEC_TIMEOUT" defaultConfirmExecTimeout = 5 * time.Minute + + envConfirmSendTimeout = "CANTON_CONFIRM_SEND_TIMEOUT" ) // ConfirmExecTimeout returns the timeout for Canton ConfirmExecOnDest polling. @@ -44,6 +46,30 @@ func ConfirmExecTimeout(t *testing.T) time.Duration { return timeout } +// ConfirmSendTimeout returns the timeout for ConfirmSendOnSource polling. +// Defaults: devenv 15s, prod-testnet 10m; override with CANTON_CONFIRM_SEND_TIMEOUT (e.g. "30s"). +func ConfirmSendTimeout(t *testing.T, env CCIPEnv) time.Duration { + t.Helper() + + var timeout time.Duration + switch env { + case EnvDevenv: + timeout = 15 * time.Second + case EnvProdTestnet: + timeout = 10 * time.Minute + default: + timeout = 15 * time.Second + } + + if d := os.Getenv(envConfirmSendTimeout); d != "" { + parsed, err := time.ParseDuration(d) + require.NoError(t, err, "%s=%q invalid", envConfirmSendTimeout, d) + timeout = parsed + } + + return timeout +} + // EVMToCantonMessageOptions returns standard message options for EVM→Canton sends with FTF. func EVMToCantonMessageOptions(gasLimit uint32, executor, ccvAddr protocol.UnknownAddress) cciptestinterfaces.MessageOptions { return cciptestinterfaces.MessageOptions{ @@ -146,6 +172,33 @@ func (b E2EBootstrap) ResolveEVMReceiver(t *testing.T) protocol.UnknownAddress { return receiver } +// ConfirmEVMSendOnSource confirms an EVM-side CCIP send after SendMessage. +// +// On devenv we poll CCIPMessageSent via ConfirmSendOnSource (local Anvil, small block range). +// On prod-testnet we use sendResult from SendMessage (tx receipt) only: ConfirmSendOnSource +// should be used here but is broken in chainlink-ccv devenv — the event poller scans from +// block 1 to latest on public RPCs and times out with 504 Gateway Timeout. +func (b E2EBootstrap) ConfirmEVMSendOnSource( + t *testing.T, + ctx context.Context, + destSelector uint64, + seqNo uint64, + sendResult cciptestinterfaces.MessageSentEvent, +) (cciptestinterfaces.MessageSentEvent, error) { + t.Helper() + + if b.Env.IsRemote() { + return sendResult, nil + } + + return b.EVM.ConfirmSendOnSource( + ctx, + destSelector, + cciptestinterfaces.MessageEventKey{SeqNum: seqNo}, + ConfirmSendTimeout(t, b.Env), + ) +} + // SkipIfRemote skips token subtests that are not supported on prod-testnet. func (b E2EBootstrap) SkipIfRemote(t *testing.T, reason string) { t.Helper() From 8cb808407974a354e3617a950754eb8b8a1513d2 Mon Sep 17 00:00:00 2001 From: Rodrigo Date: Fri, 19 Jun 2026 00:24:53 -0300 Subject: [PATCH 7/9] feat(load): evm2canton prod-testnet arb message --- Makefile | 4 ++ ccip/devenv/README.md | 27 ++++++++- ccip/devenv/tests/helpers.go | 18 ++++++ ccip/devenv/tests/load/gun.go | 36 ++++++++---- ccip/devenv/tests/load/gun_canton2evm_test.go | 49 +++++----------- .../tests/load/gun_canton2evm_token_test.go | 51 +++++------------ ccip/devenv/tests/load/gun_evm2canton_test.go | 51 +++++------------ .../tests/load/gun_evm2canton_token_test.go | 57 ++++++------------- ccip/devenv/tests/load/load_helpers.go | 39 ++++++++++--- 9 files changed, 165 insertions(+), 167 deletions(-) diff --git a/Makefile b/Makefile index db4a766cf..2f0d26a94 100644 --- a/Makefile +++ b/Makefile @@ -108,6 +108,10 @@ run-canton2evm-load: ## Canton→EVM WASP load (requires running devenv + env-ca run-evm2canton-load: ## EVM→Canton WASP load (requires running devenv + env-canton-evm-out.toml). cd ccip/devenv/tests/load && go test -timeout 15m -v -count 1 -run '^TestEVM2Canton_Load$$' +.PHONY: run-evm2canton-load-prod +run-evm2canton-load-prod: ## EVM→Canton WASP load on prod-testnet (Canton TestNet + Sepolia; set CANTON_* and PRIVATE_KEY). + cd ccip/devenv/tests/load && go test -timeout 45m -v -count 1 -ccip-env=prod-testnet -run '^TestEVM2Canton_Load$$' + .PHONY: run-canton2evm-token-load run-canton2evm-token-load: ## Canton→EVM token WASP load (requires running devenv + env-canton-evm-out.toml). cd ccip/devenv/tests/load && go test -timeout 20m -v -count 1 -run '^TestCanton2EVM_TokenLoad$$' diff --git a/ccip/devenv/README.md b/ccip/devenv/README.md index e0e1c9ff3..e9564320c 100644 --- a/ccip/devenv/README.md +++ b/ccip/devenv/README.md @@ -117,9 +117,11 @@ Equivalent: cd ccip/devenv/tests/load && go test -timeout 20m -v -count 1 -run '^TestCanton2EVM_TokenLoad$' ``` -### EVM → Canton load (requires devenv) +### EVM → Canton load -Sequential EVM→Canton messages against the Canton destination in the env file. Uses the same schedule env vars as Canton→EVM (`CANTON_LOAD_MESSAGE_RATE`, `CANTON_LOAD_DURATION`). EVM accounts are pre-funded by devenv; no Canton pre-mint. +Sequential EVM→Canton messages against the Canton destination in the env file. Uses the same schedule env vars as Canton→EVM (`CANTON_LOAD_MESSAGE_RATE`, `CANTON_LOAD_DURATION`). + +**Devenv** (requires running devenv + `env-canton-evm-out.toml`): EVM accounts are pre-funded by devenv; no Canton pre-mint. Example — 1 message every 20 seconds for 10 minutes: @@ -138,6 +140,27 @@ Equivalent: cd ccip/devenv/tests/load && go test -timeout 15m -v -count 1 -run '^TestEVM2Canton_Load$' ``` +**Prod-testnet** (Canton TestNet + Sepolia): message-only load with full per-message confirmation (send → receipt on EVM → `ConfirmExecOnDest` on Canton). Use a conservative rate — each iteration is synchronous (~30–60s end-to-end on prod), so WASP RPS=1 is effectively bounded by confirm latency. Budget for Sepolia gas per send plus Canton execution fees. Token load and Canton→EVM load remain devenv-only. + +Prerequisites: `CANTON_GRPC_URL`, `CANTON_PARTY_ID`, `CANTON_AUTH_*`, `PRIVATE_KEY` (Sepolia sender/receiver wallet), and a pre-funded Canton party. + +```bash +CCIP_ENV=prod-testnet \ +CANTON_GRPC_URL=... CANTON_PARTY_ID=... CANTON_AUTH_CLIENT_ID=... CANTON_AUTH_CLIENT_SECRET=... \ +PRIVATE_KEY=0x... \ +CANTON_LOAD_MESSAGE_RATE=1/30s \ +CANTON_LOAD_DURATION=5m \ +CANTON_CONFIRM_EXEC_TIMEOUT=10m \ +go test -timeout 45m -v -count=1 -ccip-env=prod-testnet \ + -run '^TestEVM2Canton_Load$' ./ccip/devenv/tests/load/ +``` + +Or from repo root: + +```bash +make run-evm2canton-load-prod +``` + ### EVM → Canton token load (requires devenv) Separate test from message-only load: `TestEVM2Canton_TokenLoad`. Resolves the token lane declared in [`token_transfer_config.toml`](./tests/token_transfer_config.toml) (see [Token lane configuration](#token-lane-configuration)), logs EVM sender balance vs estimated transfer need (devenv pre-funds sender), runs WASP, logs Canton holdings post-run. diff --git a/ccip/devenv/tests/helpers.go b/ccip/devenv/tests/helpers.go index 4dfc813ea..735853fab 100644 --- a/ccip/devenv/tests/helpers.go +++ b/ccip/devenv/tests/helpers.go @@ -199,6 +199,24 @@ func (b E2EBootstrap) ConfirmEVMSendOnSource( ) } +// ConfirmCantonSendOnSource confirms a Canton-side CCIP send after SendMessage. +// Canton tracks the last sent event in memory and polls by sequence number when needed. +func (b E2EBootstrap) ConfirmCantonSendOnSource( + t *testing.T, + ctx context.Context, + destSelector uint64, + seqNo uint64, +) (cciptestinterfaces.MessageSentEvent, error) { + t.Helper() + + return b.Canton.ConfirmSendOnSource( + ctx, + destSelector, + cciptestinterfaces.MessageEventKey{SeqNum: seqNo}, + ConfirmSendTimeout(t, b.Env), + ) +} + // SkipIfRemote skips token subtests that are not supported on prod-testnet. func (b E2EBootstrap) SkipIfRemote(t *testing.T, reason string) { t.Helper() diff --git a/ccip/devenv/tests/load/gun.go b/ccip/devenv/tests/load/gun.go index afddfb8df..2d39b4ebc 100644 --- a/ccip/devenv/tests/load/gun.go +++ b/ccip/devenv/tests/load/gun.go @@ -1,9 +1,11 @@ package load import ( + "context" "fmt" "sync" "sync/atomic" + "testing" "time" "github.com/stretchr/testify/require" @@ -15,6 +17,21 @@ import ( devenvtests "github.com/smartcontractkit/chainlink-canton/ccip/devenv/tests" ) +// ConfirmSendFunc confirms a CCIP send on the source chain after SendMessage returns. +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 +} + type loadMessageBuilder func( source cciptestinterfaces.CCIP17, callNum int64, @@ -63,7 +80,7 @@ type CCIPLoadGun struct { ccvAddr protocol.UnknownAddress executorAddr protocol.UnknownAddress - confirmSendTimeout time.Duration + confirmSend ConfirmSendFunc confirmExecTimeout time.Duration } @@ -72,11 +89,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,6 +115,7 @@ 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 } @@ -104,7 +125,7 @@ func NewCCIPLoadGun( destinations: destinations, ccvAddr: ccvAddr, executorAddr: executorAddr, - confirmSendTimeout: 30 * time.Second, + confirmSend: opts.ConfirmSend, confirmExecTimeout: confirmExecTimeout, }, nil } @@ -169,14 +190,9 @@ func (g *CCIPLoadGun) Call(gen *wasp.Generator) *wasp.Response { } seqNo := uint64(sendRes.Message.SequenceNumber) - sentEvent, err := g.source.ConfirmSendOnSource( - subtestCtx, - destSelector, - cciptestinterfaces.MessageEventKey{SeqNum: seqNo}, - g.confirmSendTimeout, - ) + sentEvent, err := g.confirmSend(t, subtestCtx, destSelector, seqNo, sendRes) if err != nil { - return &wasp.Response{Failed: true, Error: fmt.Sprintf("ConfirmSendOnSource (dest=%d): %v", destSelector, err), Duration: time.Since(start)} + return &wasp.Response{Failed: true, Error: fmt.Sprintf("ConfirmSend (dest=%d): %v", destSelector, err), Duration: time.Since(start)} } ev, err := dest.Chain.ConfirmExecOnDest( diff --git a/ccip/devenv/tests/load/gun_canton2evm_test.go b/ccip/devenv/tests/load/gun_canton2evm_test.go index 0233a7b49..aeca88613 100644 --- a/ccip/devenv/tests/load/gun_canton2evm_test.go +++ b/ccip/devenv/tests/load/gun_canton2evm_test.go @@ -1,19 +1,14 @@ package load import ( - "fmt" - "os" "testing" - chainsel "github.com/smartcontractkit/chain-selectors" ccv "github.com/smartcontractkit/chainlink-ccv/build/devenv" _ "github.com/smartcontractkit/chainlink-ccv/build/devenv/evm" // register EVM ImplFactory utilstests "github.com/smartcontractkit/chainlink-common/pkg/utils/tests" - "github.com/smartcontractkit/chainlink-testing-framework/framework" - "github.com/smartcontractkit/chainlink-testing-framework/framework/components/blockchain" "github.com/stretchr/testify/require" - cantondevenv "github.com/smartcontractkit/chainlink-canton/ccip/devenv" // registers Canton via init + _ "github.com/smartcontractkit/chainlink-canton/ccip/devenv" // registers Canton via init devenvtests "github.com/smartcontractkit/chainlink-canton/ccip/devenv/tests" ) @@ -39,39 +34,18 @@ func TestCanton2EVM_Load(t *testing.T) { t.Skip("skipping Canton→EVM load test in short mode") } - configPath := "../../env-canton-evm-out.toml" - if _, err := os.Stat(configPath); err != nil { - t.Skipf("skipping Canton→EVM load test: %v (start devenv to generate %s)", err, configPath) - } - - in, err := ccv.LoadOutput[ccv.Cfg](configPath) - require.NoError(t, err) - + env := devenvtests.ParseEnvFromFlag(t) + boot := devenvtests.BootstrapE2E(t, env) ctx := ccv.Plog.WithContext(t.Context()) - lib, err := ccv.NewLibFromCCVEnv(&ccv.Plog, configPath, chainsel.FamilyEVM, chainsel.FamilyCanton) - require.NoError(t, err) - chainMap, err := lib.ChainsMap(ctx) - require.NoError(t, err) - require.NoError(t, devenvtests.WireVerifierObservationFromLib(lib, chainMap)) - - cantonChain := devenvtests.GetChainFromMap(t, blockchain.TypeCanton, in, chainMap) - cantonImpl, ok := cantonChain.(*cantondevenv.Chain) - require.True(t, ok, "Canton chain must be *cantondevenv.Chain") - - destinations := discoverEVMDestinations(t, in, chainMap) + destinations := discoverEVMDestinations(t, boot.Cfg, boot.ChainMap) require.NotEmpty(t, destinations, "need at least one EVM destination in the env file") t.Logf("Canton→EVM load destinations: %d EVM chain(s)", len(destinations)) for _, d := range destinations { t.Logf(" - selector=%d receiver=%x", d.Chain.ChainSelector(), d.Receiver) } - t.Cleanup(func() { - _, err := framework.SaveContainerLogs(fmt.Sprintf("%s-%s", framework.DefaultCTFLogsDir, t.Name())) - require.NoError(t, err) - }) - - ccvAddr, executorAddr := resolveCantonSourceAddrs(t, lib, cantonChain.ChainSelector()) + ccvAddr, executorAddr := resolveCantonSourceAddrs(t, boot.Lib, boot.Canton.ChainSelector()) sched := loadSchedule(t) @@ -82,15 +56,20 @@ func TestCanton2EVM_Load(t *testing.T) { mintAmount := estimatedMessages * uint64(devenvtests.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)) + if !boot.Env.IsRemote() { + require.NoError(t, boot.Canton.MintTokens(ctx, mintAmount)) + } + require.NoError(t, boot.Canton.SetupSend(ctx, uint64(devenvtests.CantonToEVMFeeAmount), 0)) gun, err := NewCCIPLoadGun( - cantonChain, + boot.Canton, destinations, ccvAddr, executorAddr, - utilstests.WaitTimeout(t), + LoadGunOptions{ + ConfirmSend: CantonSourceConfirmSend(boot), + ConfirmExecTimeout: utilstests.WaitTimeout(t), + }, ) require.NoError(t, err) diff --git a/ccip/devenv/tests/load/gun_canton2evm_token_test.go b/ccip/devenv/tests/load/gun_canton2evm_token_test.go index df3a80bc9..6e1a43fbb 100644 --- a/ccip/devenv/tests/load/gun_canton2evm_token_test.go +++ b/ccip/devenv/tests/load/gun_canton2evm_token_test.go @@ -1,26 +1,21 @@ package load import ( - "fmt" "math/big" - "os" "testing" - chainsel "github.com/smartcontractkit/chain-selectors" ccv "github.com/smartcontractkit/chainlink-ccv/build/devenv" _ "github.com/smartcontractkit/chainlink-ccv/build/devenv/evm" // register EVM ImplFactory utilstests "github.com/smartcontractkit/chainlink-common/pkg/utils/tests" - "github.com/smartcontractkit/chainlink-testing-framework/framework" - "github.com/smartcontractkit/chainlink-testing-framework/framework/components/blockchain" "github.com/stretchr/testify/require" - cantondevenv "github.com/smartcontractkit/chainlink-canton/ccip/devenv" // registers Canton via init + _ "github.com/smartcontractkit/chainlink-canton/ccip/devenv" // registers Canton via init devenvtests "github.com/smartcontractkit/chainlink-canton/ccip/devenv/tests" ) // TestCanton2EVM_TokenLoad runs WASP RPS=1 against the Canton→EVM token transfer path. // -// Requires a running devenv and ../../env-canton-evm-out.toml. +// Requires a running devenv and env-canton-evm-out.toml (devenv only). // //nolint:paralleltest // Canton holdings must stay 1-wide; shares env with e2e. func TestCanton2EVM_TokenLoad(t *testing.T) { @@ -28,32 +23,18 @@ func TestCanton2EVM_TokenLoad(t *testing.T) { t.Skip("skipping Canton→EVM token load test in short mode") } - configPath := "../../env-canton-evm-out.toml" - if _, err := os.Stat(configPath); err != nil { - t.Skipf("skipping Canton→EVM token load test: %v (start devenv to generate %s)", err, configPath) - } - - in, err := ccv.LoadOutput[ccv.Cfg](configPath) - require.NoError(t, err) + env := devenvtests.ParseEnvFromFlag(t) + boot := devenvtests.BootstrapE2E(t, env) + boot.SkipIfRemote(t, "token load not on prod-testnet") ctx := ccv.Plog.WithContext(t.Context()) - lib, err := ccv.NewLibFromCCVEnv(&ccv.Plog, configPath, chainsel.FamilyEVM, chainsel.FamilyCanton) - require.NoError(t, err) - chainMap, err := lib.ChainsMap(ctx) - require.NoError(t, err) - require.NoError(t, devenvtests.WireVerifierObservationFromLib(lib, chainMap)) - - cantonChain := devenvtests.GetChainFromMap(t, blockchain.TypeCanton, in, chainMap) - cantonImpl, ok := cantonChain.(*cantondevenv.Chain) - require.True(t, ok, "Canton chain must be *cantondevenv.Chain") - - evmSelectors := discoverEVMTokenSelectors(t, in) + evmSelectors := discoverEVMTokenSelectors(t, boot.Cfg) 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, boot.Cfg, boot.Lib, boot.ChainMap, boot.Canton.ChainSelector(), evmSelectors) t.Logf("Token lane: pool=%s transfer=%s", lane.PoolRef.Qualifier, lane.TransferAmount.String()) - destinations := discoverEVMTokenDestinations(t, in, chainMap, lane) + destinations := discoverEVMTokenDestinations(t, boot.Cfg, boot.ChainMap, lane) require.NotEmpty(t, destinations, "need at least one EVM token destination in the env file") t.Logf("Canton→EVM token load destinations: %d EVM chain(s)", len(destinations)) @@ -63,22 +44,20 @@ func TestCanton2EVM_TokenLoad(t *testing.T) { require.NoError(t, err) require.NotNil(t, receiverBalanceBefore) - t.Cleanup(func() { - _, err := framework.SaveContainerLogs(fmt.Sprintf("%s-%s", framework.DefaultCTFLogsDir, t.Name())) - require.NoError(t, err) - }) - - ccvAddr, executorAddr := resolveCantonSourceAddrs(t, lib, cantonChain.ChainSelector()) + ccvAddr, executorAddr := resolveCantonSourceAddrs(t, boot.Lib, boot.Canton.ChainSelector()) sched := loadSchedule(t) - setupCantonTokenLoadHoldings(t, ctx, cantonImpl, sched, lane) + setupCantonTokenLoadHoldings(t, ctx, boot.Canton, sched, lane) gun, err := NewCCIPLoadGun( - cantonChain, + boot.Canton, destinations, ccvAddr, executorAddr, - utilstests.WaitTimeout(t), + LoadGunOptions{ + ConfirmSend: CantonSourceConfirmSend(boot), + ConfirmExecTimeout: utilstests.WaitTimeout(t), + }, ) 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 365820999..dd6fccdd6 100644 --- a/ccip/devenv/tests/load/gun_evm2canton_test.go +++ b/ccip/devenv/tests/load/gun_evm2canton_test.go @@ -1,26 +1,21 @@ package load import ( - "fmt" - "os" "testing" - chainsel "github.com/smartcontractkit/chain-selectors" ccv "github.com/smartcontractkit/chainlink-ccv/build/devenv" _ "github.com/smartcontractkit/chainlink-ccv/build/devenv/evm" // register EVM ImplFactory - "github.com/smartcontractkit/chainlink-testing-framework/framework" - "github.com/smartcontractkit/chainlink-testing-framework/framework/components/blockchain" "github.com/stretchr/testify/require" _ "github.com/smartcontractkit/chainlink-canton/ccip/devenv" // registers Canton via init - cantondevenv "github.com/smartcontractkit/chainlink-canton/ccip/devenv" devenvtests "github.com/smartcontractkit/chainlink-canton/ccip/devenv/tests" ) // TestEVM2Canton_Load runs WASP RPS=1 against the real EVM→Canton path (message-only). // -// Requires a running devenv and ../../env-canton-evm-out.toml (same as the basic e2e test). -// EVM source accounts are pre-funded by devenv; no Canton MintTokens/SetupSend. +// Devenv: requires a running devenv and env-canton-evm-out.toml; EVM accounts are pre-funded. +// Prod-testnet: set CANTON_GRPC_URL, CANTON_PARTY_ID, CANTON_AUTH_*, and PRIVATE_KEY; Canton +// party must already be funded (no MintTokens on this path). // //nolint:paralleltest // single-flight exec on Canton dest; shares env with e2e. func TestEVM2Canton_Load(t *testing.T) { @@ -28,44 +23,26 @@ func TestEVM2Canton_Load(t *testing.T) { t.Skip("skipping EVM→Canton load test in short mode") } - configPath := "../../env-canton-evm-out.toml" - if _, err := os.Stat(configPath); err != nil { - t.Skipf("skipping EVM→Canton load test: %v (start devenv to generate %s)", err, configPath) - } - - in, err := ccv.LoadOutput[ccv.Cfg](configPath) - require.NoError(t, err) - + env := devenvtests.ParseEnvFromFlag(t) + boot := devenvtests.BootstrapE2E(t, env) ctx := ccv.Plog.WithContext(t.Context()) - lib, err := ccv.NewLibFromCCVEnv(&ccv.Plog, configPath, chainsel.FamilyEVM, chainsel.FamilyCanton) - require.NoError(t, err) - - chainMap, err := lib.ChainsMap(ctx) - require.NoError(t, err) - require.NoError(t, devenvtests.WireVerifierObservationFromLib(lib, chainMap)) + boot.SetupCantonReceive(t, ctx) - evmChain := devenvtests.GetChainFromMap(t, blockchain.TypeAnvil, in, chainMap) - cantonChain := devenvtests.GetChainFromMap(t, blockchain.TypeCanton, in, chainMap) - cantonImpl, ok := cantonChain.(*cantondevenv.Chain) - require.True(t, ok, "Canton dest chain must be *devenv.Chain") - require.NoError(t, cantonImpl.SetupReceive(ctx)) - cantonDest := discoverCantonDest(t, in, chainMap) + cantonDest := discoverCantonDestFromBoot(t, boot) t.Logf("EVM→Canton load: source=%d dest=%d receiver=%x", - evmChain.ChainSelector(), cantonDest.Chain.ChainSelector(), cantonDest.Receiver) - - t.Cleanup(func() { - _, err := framework.SaveContainerLogs(fmt.Sprintf("%s-%s", framework.DefaultCTFLogsDir, t.Name())) - require.NoError(t, err) - }) + boot.EVM.ChainSelector(), cantonDest.Chain.ChainSelector(), cantonDest.Receiver) - ccvAddr, executorAddr := resolveEVMSourceAddrs(t, lib, evmChain.ChainSelector()) + ccvAddr, executorAddr := resolveEVMSourceAddrs(t, boot.Lib, boot.EVM.ChainSelector()) gun, err := NewCCIPLoadGun( - evmChain, + boot.EVM, []Destination{cantonDest}, ccvAddr, executorAddr, - devenvtests.ConfirmExecTimeout(t), + LoadGunOptions{ + ConfirmSend: EVMSourceConfirmSend(boot), + ConfirmExecTimeout: devenvtests.ConfirmExecTimeout(t), + }, ) require.NoError(t, err) diff --git a/ccip/devenv/tests/load/gun_evm2canton_token_test.go b/ccip/devenv/tests/load/gun_evm2canton_token_test.go index beb80ccc6..fa254679f 100644 --- a/ccip/devenv/tests/load/gun_evm2canton_token_test.go +++ b/ccip/devenv/tests/load/gun_evm2canton_token_test.go @@ -1,27 +1,21 @@ package load import ( - "fmt" "math/big" - "os" "testing" - chainsel "github.com/smartcontractkit/chain-selectors" ccv "github.com/smartcontractkit/chainlink-ccv/build/devenv" _ "github.com/smartcontractkit/chainlink-ccv/build/devenv/evm" // register EVM ImplFactory - "github.com/smartcontractkit/chainlink-testing-framework/framework" - "github.com/smartcontractkit/chainlink-testing-framework/framework/components/blockchain" "github.com/stretchr/testify/require" _ "github.com/smartcontractkit/chainlink-canton/ccip/devenv" // registers Canton via init - cantondevenv "github.com/smartcontractkit/chainlink-canton/ccip/devenv" devenvtests "github.com/smartcontractkit/chainlink-canton/ccip/devenv/tests" "github.com/smartcontractkit/chainlink-canton/testhelpers" ) // TestEVM2Canton_TokenLoad runs WASP RPS=1 against the EVM→Canton token transfer path. // -// Requires a running devenv and ../../env-canton-evm-out.toml. +// Requires a running devenv and env-canton-evm-out.toml (devenv only). // //nolint:paralleltest // single-flight exec on Canton dest; shares env with e2e. func TestEVM2Canton_TokenLoad(t *testing.T) { @@ -29,47 +23,32 @@ func TestEVM2Canton_TokenLoad(t *testing.T) { t.Skip("skipping EVM→Canton token load test in short mode") } - configPath := "../../env-canton-evm-out.toml" - if _, err := os.Stat(configPath); err != nil { - t.Skipf("skipping EVM→Canton token load test: %v (start devenv to generate %s)", err, configPath) - } - - in, err := ccv.LoadOutput[ccv.Cfg](configPath) - require.NoError(t, err) + env := devenvtests.ParseEnvFromFlag(t) + boot := devenvtests.BootstrapE2E(t, env) + boot.SkipIfRemote(t, "token load not on prod-testnet") ctx := ccv.Plog.WithContext(t.Context()) - lib, err := ccv.NewLibFromCCVEnv(&ccv.Plog, configPath, chainsel.FamilyEVM, chainsel.FamilyCanton) - require.NoError(t, err) + boot.SetupCantonReceive(t, ctx) - chainMap, err := lib.ChainsMap(ctx) - require.NoError(t, err) - require.NoError(t, devenvtests.WireVerifierObservationFromLib(lib, chainMap)) - - evmChain := devenvtests.GetChainFromMap(t, blockchain.TypeAnvil, in, chainMap) - cantonChain := devenvtests.GetChainFromMap(t, blockchain.TypeCanton, in, chainMap) - cantonImpl, ok := cantonChain.(*cantondevenv.Chain) - 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, boot.Cfg, boot.Lib, boot.ChainMap, boot.EVM.ChainSelector(), []uint64{boot.Canton.ChainSelector()}) t.Logf("Token lane: pool=%s transfer=%s srcToken=%x", lane.PoolRef.Qualifier, lane.TransferAmount.String(), lane.SrcToken) - receiverParticipant, _, err := cantonImpl.ClientParticipant() + receiverParticipant, _, err := boot.Canton.ClientParticipant() require.NoError(t, err) require.NotEmpty(t, receiverParticipant.PartyID) - receiver, err := cantonChain.GetEOAReceiverAddress() + receiver, err := boot.Canton.GetEOAReceiverAddress() require.NoError(t, err) - cantonDest := cantonTokenLoadDestination(cantonChain, receiver, lane) + cantonDest := cantonTokenLoadDestination(boot.Canton, receiver, lane) sched := loadSchedule(t) estimatedMessages := estimateMessages(sched) - evmSender, err := evmChain.GetEOAReceiverAddress() + evmSender, err := boot.EVM.GetEOAReceiverAddress() require.NoError(t, err) - senderBalance, err := evmChain.GetTokenBalance(ctx, evmSender, lane.SrcToken) + senderBalance, err := boot.EVM.GetTokenBalance(ctx, evmSender, lane.SrcToken) require.NoError(t, err) requiredBalance := new(big.Int).Mul(lane.TransferAmount, big.NewInt(int64(estimatedMessages))) t.Logf("EVM sender token balance=%s requiredForRun=%s (estimatedMessages=%d; devenv pre-funds sender)", @@ -78,19 +57,17 @@ func TestEVM2Canton_TokenLoad(t *testing.T) { t.Logf("warning: EVM sender balance may be insufficient for full run") } - t.Cleanup(func() { - _, err := framework.SaveContainerLogs(fmt.Sprintf("%s-%s", framework.DefaultCTFLogsDir, t.Name())) - require.NoError(t, err) - }) - - ccvAddr, executorAddr := resolveEVMSourceAddrs(t, lib, evmChain.ChainSelector()) + ccvAddr, executorAddr := resolveEVMSourceAddrs(t, boot.Lib, boot.EVM.ChainSelector()) gun, err := NewCCIPLoadGun( - evmChain, + boot.EVM, []Destination{cantonDest}, ccvAddr, executorAddr, - devenvtests.ConfirmExecTimeout(t), + LoadGunOptions{ + ConfirmSend: EVMSourceConfirmSend(boot), + ConfirmExecTimeout: devenvtests.ConfirmExecTimeout(t), + }, ) require.NoError(t, err) diff --git a/ccip/devenv/tests/load/load_helpers.go b/ccip/devenv/tests/load/load_helpers.go index 23535b87b..4f77a25d4 100644 --- a/ccip/devenv/tests/load/load_helpers.go +++ b/ccip/devenv/tests/load/load_helpers.go @@ -266,14 +266,39 @@ func evmTokenLoadDestination(chain cciptestinterfaces.CCIP17, receiver protocol. } } -func discoverCantonDest(t *testing.T, in *ccv.Cfg, chainMap map[uint64]cciptestinterfaces.CCIP17) Destination { +func discoverCantonDestFromBoot(t *testing.T, boot devenvtests.E2EBootstrap) Destination { t.Helper() - chain := devenvtests.GetChainFromMap(t, blockchain.TypeCanton, in, chainMap) - receiver, err := chain.GetEOAReceiverAddress() + receiver, err := boot.Canton.GetEOAReceiverAddress() require.NoError(t, err) - return cantonLoadDestination(chain, receiver) + return cantonLoadDestination(boot.Canton, receiver) +} + +// EVMSourceConfirmSend returns a ConfirmSendFunc that delegates to BootstrapE2E.ConfirmEVMSendOnSource. +func EVMSourceConfirmSend(boot devenvtests.E2EBootstrap) ConfirmSendFunc { + return func( + t *testing.T, + ctx context.Context, + destSelector uint64, + seqNo uint64, + sendResult cciptestinterfaces.MessageSentEvent, + ) (cciptestinterfaces.MessageSentEvent, error) { + return boot.ConfirmEVMSendOnSource(t, ctx, destSelector, seqNo, sendResult) + } +} + +// CantonSourceConfirmSend returns a ConfirmSendFunc that delegates to BootstrapE2E.ConfirmCantonSendOnSource. +func CantonSourceConfirmSend(boot devenvtests.E2EBootstrap) ConfirmSendFunc { + return func( + t *testing.T, + ctx context.Context, + destSelector uint64, + seqNo uint64, + _ cciptestinterfaces.MessageSentEvent, + ) (cciptestinterfaces.MessageSentEvent, error) { + return boot.ConfirmCantonSendOnSource(t, ctx, destSelector, seqNo) + } } func cantonLoadDestination(chain cciptestinterfaces.CCIP17, receiver protocol.UnknownAddress) Destination { @@ -283,9 +308,9 @@ func cantonLoadDestination(chain cciptestinterfaces.CCIP17, receiver protocol.Un Receiver: receiver, buildMessage: func(_ cciptestinterfaces.CCIP17, callNum int64, ccvAddr, executorAddr protocol.UnknownAddress) (cciptestinterfaces.MessageFields, cciptestinterfaces.MessageOptions, error) { return cciptestinterfaces.MessageFields{ - Receiver: receiver, - Data: fmt.Appendf(nil, "evm2canton load n=%d dest=%d", callNum, destSelector), - }, devenvtests.EVMToCantonMessageOptions(200_000, executorAddr, ccvAddr), nil + Receiver: receiver, + Data: fmt.Appendf(nil, "evm2canton load n=%d dest=%d", callNum, destSelector), + }, devenvtests.EVMToCantonMessageOptions(200_000, executorAddr, ccvAddr), nil }, } } From b615faa243487d0617859704e7d07909d8126f45 Mon Sep 17 00:00:00 2001 From: Rodrigo Date: Fri, 19 Jun 2026 00:49:28 -0300 Subject: [PATCH 8/9] feat(load): canton2evm prod-testnet arb message --- Makefile | 5 ++ ccip/devenv/README.md | 29 ++++++++-- ccip/devenv/tests/load/gun.go | 11 ++++ ccip/devenv/tests/load/gun_canton2evm_test.go | 41 ++++++++------ .../tests/load/gun_canton2evm_token_test.go | 6 +-- ccip/devenv/tests/load/gun_evm2canton_test.go | 3 +- .../tests/load/gun_evm2canton_token_test.go | 3 +- ccip/devenv/tests/load/load_helpers.go | 53 +++++++++++++++++-- 8 files changed, 121 insertions(+), 30 deletions(-) diff --git a/Makefile b/Makefile index 2f0d26a94..10fa33701 100644 --- a/Makefile +++ b/Makefile @@ -112,6 +112,11 @@ run-evm2canton-load: ## EVM→Canton WASP load (requires running devenv + env-ca run-evm2canton-load-prod: ## EVM→Canton WASP load on prod-testnet (Canton TestNet + Sepolia; set CANTON_* and PRIVATE_KEY). cd ccip/devenv/tests/load && go test -timeout 45m -v -count 1 -ccip-env=prod-testnet -run '^TestEVM2Canton_Load$$' +.PHONY: run-canton2evm-load-prod +run-canton2evm-load-prod: ## Canton→EVM WASP load on prod-testnet (send-only; set CANTON_* and PRIVATE_KEY). + cd ccip/devenv/tests/load && CANTON_LOAD_SKIP_EXEC_CONFIRM=true go test -timeout 30m -v -count=1 \ + -ccip-env=prod-testnet -run '^TestCanton2EVM_Load$$' + .PHONY: run-canton2evm-token-load run-canton2evm-token-load: ## Canton→EVM token WASP load (requires running devenv + env-canton-evm-out.toml). cd ccip/devenv/tests/load && go test -timeout 20m -v -count 1 -run '^TestCanton2EVM_TokenLoad$$' diff --git a/ccip/devenv/README.md b/ccip/devenv/README.md index e9564320c..35979aba6 100644 --- a/ccip/devenv/README.md +++ b/ccip/devenv/README.md @@ -73,9 +73,11 @@ Token e2e subtests are skipped on prod-testnet. A second prod run reuses existin Load tests live in `ccip/devenv/tests/load`. They use [WASP](https://pkg.go.dev/github.com/smartcontractkit/chainlink-testing-framework/wasp) and run sequentially (RPS=1) because Canton holdings are single-flight. -### Canton → EVM load (requires devenv) +### Canton → EVM load -Sequential Canton→EVM messages round-robined across every EVM destination in the env file. Requires `make start-devenv` so `ccip/devenv/env-canton-evm-out.toml` exists. +Sequential Canton→EVM messages round-robined across every EVM destination in the env file. + +**Devenv** (requires `make start-devenv` so `ccip/devenv/env-canton-evm-out.toml` exists): pre-mints fee holdings and calls `SetupSend` once before WASP starts. Full send + EVM exec confirmation per message. Schedule is configured via env vars (defaults are `1/1s` for 90s): @@ -103,6 +105,27 @@ cd ccip/devenv/tests/load && go test -timeout 15m -v -count 1 -run '^TestCanton2 If the out file is missing the test skips with a hint. +**Prod-testnet** (Canton TestNet + Sepolia): send-only message load — Canton send + confirm send, no `ConfirmExecOnDest` on EVM (executor not available on prod). Set `CANTON_LOAD_SKIP_EXEC_CONFIRM=true`. Verify delivery via indexer/CCIP ops; the test does not assert EVM execution. + +Prerequisites: `CANTON_GRPC_URL`, `CANTON_PARTY_ID`, `CANTON_AUTH_*`, `PRIVATE_KEY` (EVM message receiver wallet), and a pre-funded Canton party (~50 Amulet per message at `CantonToEVMFeeAmount`). + +```bash +CCIP_ENV=prod-testnet \ +CANTON_LOAD_SKIP_EXEC_CONFIRM=true \ +CANTON_GRPC_URL=... CANTON_PARTY_ID=... CANTON_AUTH_CLIENT_ID=... CANTON_AUTH_CLIENT_SECRET=... \ +PRIVATE_KEY=0x... \ +CANTON_LOAD_MESSAGE_RATE=1/10s \ +CANTON_LOAD_DURATION=5m \ +go test -timeout 30m -v -count=1 -ccip-env=prod-testnet \ + -run '^TestCanton2EVM_Load$' ./ccip/devenv/tests/load/ +``` + +Or from repo root: + +```bash +make run-canton2evm-load-prod +``` + ### Canton → EVM token load (requires devenv) Separate test from message-only load: `TestCanton2EVM_TokenLoad`. Resolves the token lane declared in [`token_transfer_config.toml`](./tests/token_transfer_config.toml) (see [Token lane configuration](#token-lane-configuration)) against the source chain's `GetTokenTransferConfigs`, validating every destination has the lane. Pre-mints Canton fee + transfer holdings, runs WASP, then asserts EVM receiver token balance delta. @@ -140,7 +163,7 @@ Equivalent: cd ccip/devenv/tests/load && go test -timeout 15m -v -count 1 -run '^TestEVM2Canton_Load$' ``` -**Prod-testnet** (Canton TestNet + Sepolia): message-only load with full per-message confirmation (send → receipt on EVM → `ConfirmExecOnDest` on Canton). Use a conservative rate — each iteration is synchronous (~30–60s end-to-end on prod), so WASP RPS=1 is effectively bounded by confirm latency. Budget for Sepolia gas per send plus Canton execution fees. Token load and Canton→EVM load remain devenv-only. +**Prod-testnet** (Canton TestNet + Sepolia): message-only load with full per-message confirmation (send → receipt on EVM → `ConfirmExecOnDest` on Canton). Use a conservative rate — each iteration is synchronous (~30–60s end-to-end on prod), so WASP RPS=1 is effectively bounded by confirm latency. Budget for Sepolia gas per send plus Canton execution fees. Token load remains devenv-only; Canton→EVM message-only prod load is supported send-only (see above). Prerequisites: `CANTON_GRPC_URL`, `CANTON_PARTY_ID`, `CANTON_AUTH_*`, `PRIVATE_KEY` (Sepolia sender/receiver wallet), and a pre-funded Canton party. diff --git a/ccip/devenv/tests/load/gun.go b/ccip/devenv/tests/load/gun.go index 2d39b4ebc..76f83285e 100644 --- a/ccip/devenv/tests/load/gun.go +++ b/ccip/devenv/tests/load/gun.go @@ -30,6 +30,7 @@ type ConfirmSendFunc func( type LoadGunOptions struct { ConfirmSend ConfirmSendFunc ConfirmExecTimeout time.Duration + SkipExecConfirm bool } type loadMessageBuilder func( @@ -82,6 +83,7 @@ type CCIPLoadGun struct { confirmSend ConfirmSendFunc confirmExecTimeout time.Duration + skipExecConfirm bool } // NewCCIPLoadGun wires a CCIP source with one or more destinations for load testing. @@ -127,6 +129,7 @@ func NewCCIPLoadGun( executorAddr: executorAddr, confirmSend: opts.ConfirmSend, confirmExecTimeout: confirmExecTimeout, + skipExecConfirm: opts.SkipExecConfirm, }, nil } @@ -195,6 +198,14 @@ func (g *CCIPLoadGun) Call(gen *wasp.Generator) *wasp.Response { return &wasp.Response{Failed: true, Error: fmt.Sprintf("ConfirmSend (dest=%d): %v", destSelector, err), Duration: time.Since(start)} } + if g.skipExecConfirm { + return &wasp.Response{ + Failed: false, + StatusCode: "200", + Duration: time.Since(start), + } + } + ev, err := dest.Chain.ConfirmExecOnDest( subtestCtx, g.source.ChainSelector(), diff --git a/ccip/devenv/tests/load/gun_canton2evm_test.go b/ccip/devenv/tests/load/gun_canton2evm_test.go index aeca88613..509130da8 100644 --- a/ccip/devenv/tests/load/gun_canton2evm_test.go +++ b/ccip/devenv/tests/load/gun_canton2evm_test.go @@ -5,7 +5,6 @@ import ( ccv "github.com/smartcontractkit/chainlink-ccv/build/devenv" _ "github.com/smartcontractkit/chainlink-ccv/build/devenv/evm" // register EVM ImplFactory - utilstests "github.com/smartcontractkit/chainlink-common/pkg/utils/tests" "github.com/stretchr/testify/require" _ "github.com/smartcontractkit/chainlink-canton/ccip/devenv" // registers Canton via init @@ -22,11 +21,13 @@ const ( // TestCanton2EVM_Load runs WASP RPS=1 against the real Canton→EVM path (message-only), // round-robining across every EVM destination found in the env file. // -// Requires a running devenv and ../../env-canton-evm-out.toml (same as the basic e2e test). +// Devenv: requires a running devenv and env-canton-evm-out.toml; pre-mints fee holdings +// and calls SetupSend once before WASP starts. // -// Devenv-specific: this test pre-mints fee holdings and calls SetupSend once before WASP -// starts. The CCIPLoadGun itself is environment-agnostic so the same gun can be reused by -// a future staging/prod runner that assumes pre-funded accounts. +// Prod-testnet: send-only load (Canton send + confirm send, no ConfirmExecOnDest on EVM). +// Set CANTON_GRPC_URL, CANTON_PARTY_ID, CANTON_AUTH_*, PRIVATE_KEY (EVM message receiver), +// CANTON_LOAD_SKIP_EXEC_CONFIRM=true, and pre-fund the Canton party (~50 Amulet per message). +// Verify delivery via indexer/CCIP ops — the test does not assert EVM execution on prod. // //nolint:paralleltest // Canton holdings must stay 1-wide; shares env with e2e. func TestCanton2EVM_Load(t *testing.T) { @@ -38,7 +39,12 @@ func TestCanton2EVM_Load(t *testing.T) { boot := devenvtests.BootstrapE2E(t, env) ctx := ccv.Plog.WithContext(t.Context()) - destinations := discoverEVMDestinations(t, boot.Cfg, boot.ChainMap) + skipExec := loadSkipExecConfirm(t) + if boot.Env.IsRemote() && !skipExec { + t.Skip("prod-testnet requires CANTON_LOAD_SKIP_EXEC_CONFIRM=true (EVM executor not available)") + } + + destinations := discoverEVMDestinationsFromBoot(t, boot) require.NotEmpty(t, destinations, "need at least one EVM destination in the env file") t.Logf("Canton→EVM load destinations: %d EVM chain(s)", len(destinations)) for _, d := range destinations { @@ -49,15 +55,15 @@ func TestCanton2EVM_Load(t *testing.T) { sched := loadSchedule(t) - estimatedMessages := uint64(sched.rate) * uint64(sched.duration/sched.rateUnit) - if estimatedMessages == 0 { - estimatedMessages = 1 - } - mintAmount := estimatedMessages * uint64(devenvtests.CantonToEVMFeeAmount) * mintBufferNumerator / mintBufferDenominator - t.Logf("Pre-mint: estimatedMessages=%d feePerMessage=%d totalFeeMint=%d", - estimatedMessages, devenvtests.CantonToEVMFeeAmount, mintAmount) - if !boot.Env.IsRemote() { - require.NoError(t, boot.Canton.MintTokens(ctx, mintAmount)) + estimatedMessages := estimateMessages(sched) + requiredAmulet := estimatedMessages * uint64(devenvtests.CantonToEVMFeeAmount) * mintBufferNumerator / mintBufferDenominator + if boot.Env.IsRemote() { + t.Logf("Prod: ensure Canton party holds at least %d Amulet (estimatedMessages=%d feePerMessage=%d)", + requiredAmulet, estimatedMessages, devenvtests.CantonToEVMFeeAmount) + } else { + t.Logf("Pre-mint: estimatedMessages=%d feePerMessage=%d totalFeeMint=%d", + estimatedMessages, devenvtests.CantonToEVMFeeAmount, requiredAmulet) + require.NoError(t, boot.Canton.MintTokens(ctx, requiredAmulet)) } require.NoError(t, boot.Canton.SetupSend(ctx, uint64(devenvtests.CantonToEVMFeeAmount), 0)) @@ -68,10 +74,11 @@ func TestCanton2EVM_Load(t *testing.T) { executorAddr, LoadGunOptions{ ConfirmSend: CantonSourceConfirmSend(boot), - ConfirmExecTimeout: utilstests.WaitTimeout(t), + ConfirmExecTimeout: devenvtests.ConfirmExecTimeout(t), + SkipExecConfirm: skipExec, }, ) require.NoError(t, err) - runWASP(t, gun, "canton-load-canton2evm", sched, "message_only") + runWASP(t, gun, "canton-load-canton2evm", sched, "message_only", skipExec) } diff --git a/ccip/devenv/tests/load/gun_canton2evm_token_test.go b/ccip/devenv/tests/load/gun_canton2evm_token_test.go index 6e1a43fbb..7a0c6ba24 100644 --- a/ccip/devenv/tests/load/gun_canton2evm_token_test.go +++ b/ccip/devenv/tests/load/gun_canton2evm_token_test.go @@ -6,7 +6,6 @@ import ( ccv "github.com/smartcontractkit/chainlink-ccv/build/devenv" _ "github.com/smartcontractkit/chainlink-ccv/build/devenv/evm" // register EVM ImplFactory - utilstests "github.com/smartcontractkit/chainlink-common/pkg/utils/tests" "github.com/stretchr/testify/require" _ "github.com/smartcontractkit/chainlink-canton/ccip/devenv" // registers Canton via init @@ -56,12 +55,13 @@ func TestCanton2EVM_TokenLoad(t *testing.T) { executorAddr, LoadGunOptions{ ConfirmSend: CantonSourceConfirmSend(boot), - ConfirmExecTimeout: utilstests.WaitTimeout(t), + ConfirmExecTimeout: devenvtests.ConfirmExecTimeout(t), + SkipExecConfirm: false, }, ) require.NoError(t, err) - runWASP(t, gun, "canton-load-canton2evm-token", sched, "token_transfer") + runWASP(t, gun, "canton-load-canton2evm-token", sched, "token_transfer", false) 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 dd6fccdd6..e1e4970fa 100644 --- a/ccip/devenv/tests/load/gun_evm2canton_test.go +++ b/ccip/devenv/tests/load/gun_evm2canton_test.go @@ -42,9 +42,10 @@ func TestEVM2Canton_Load(t *testing.T) { LoadGunOptions{ ConfirmSend: EVMSourceConfirmSend(boot), ConfirmExecTimeout: devenvtests.ConfirmExecTimeout(t), + SkipExecConfirm: false, }, ) require.NoError(t, err) - runWASP(t, gun, "canton-load-evm2canton", loadSchedule(t), "message_only") + runWASP(t, gun, "canton-load-evm2canton", loadSchedule(t), "message_only", false) } diff --git a/ccip/devenv/tests/load/gun_evm2canton_token_test.go b/ccip/devenv/tests/load/gun_evm2canton_token_test.go index fa254679f..3d1562319 100644 --- a/ccip/devenv/tests/load/gun_evm2canton_token_test.go +++ b/ccip/devenv/tests/load/gun_evm2canton_token_test.go @@ -67,11 +67,12 @@ func TestEVM2Canton_TokenLoad(t *testing.T) { LoadGunOptions{ ConfirmSend: EVMSourceConfirmSend(boot), ConfirmExecTimeout: devenvtests.ConfirmExecTimeout(t), + SkipExecConfirm: false, }, ) require.NoError(t, err) - runWASP(t, gun, "canton-load-evm2canton-token", sched, "token_transfer") + runWASP(t, gun, "canton-load-evm2canton-token", sched, "token_transfer", false) 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 4f77a25d4..cdf7e160c 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" @@ -29,10 +30,11 @@ 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" + envLoadSkipExecConfirm = "CANTON_LOAD_SKIP_EXEC_CONFIRM" + defaultMessageRate = "1/1s" + defaultLoadDuration = 90 * time.Second ) type scheduleConfig struct { @@ -70,7 +72,19 @@ func loadSchedule(t *testing.T) scheduleConfig { } } -func runWASP(t *testing.T, gun *CCIPLoadGun, genName string, sched scheduleConfig, scenario string) { +func loadSkipExecConfirm(t *testing.T) bool { + t.Helper() + + v := strings.TrimSpace(os.Getenv(envLoadSkipExecConfirm)) + switch strings.ToLower(v) { + case "1", "true", "yes": + return true + default: + return false + } +} + +func runWASP(t *testing.T, gun *CCIPLoadGun, genName string, sched scheduleConfig, scenario string, skipExecConfirm bool) { t.Helper() labels := map[string]string{ @@ -83,6 +97,9 @@ func runWASP(t *testing.T, gun *CCIPLoadGun, genName string, sched scheduleConfi if scenario != "" { labels["scenario"] = scenario } + if skipExecConfirm { + labels["skip_exec_confirm"] = "true" + } p := wasp.NewProfile().Add(wasp.NewGenerator(&wasp.Config{ T: t, @@ -133,6 +150,32 @@ func discoverEVMDestinations(t *testing.T, in *ccv.Cfg, chainMap map[uint64]ccip return dests } +func discoverEVMDestinationsFromBoot(t *testing.T, boot devenvtests.E2EBootstrap) []Destination { + t.Helper() + + receiver := boot.ResolveEVMReceiver(t) + + dests := make([]Destination, 0) + seen := make(map[uint64]struct{}) + for _, bc := range boot.Cfg.Blockchains { + if bc.Type != blockchain.TypeAnvil { + continue + } + details, err := chainsel.GetChainDetailsByChainIDAndFamily(bc.ChainID, chainsel.FamilyEVM) + require.NoError(t, err, "resolve chain selector for chainID=%s", bc.ChainID) + if _, dup := seen[details.ChainSelector]; dup { + continue + } + chain, ok := boot.ChainMap[details.ChainSelector] + require.True(t, ok, "EVM chain %d not in harness chain map", details.ChainSelector) + + dests = append(dests, evmLoadDestination(chain, receiver)) + seen[details.ChainSelector] = struct{}{} + } + + return dests +} + // discoverEVMTokenSelectors returns the chain selectors of every EVM chain in the // env file. Callers resolve the token lane over these selectors before building // destinations. From 51acbc32d6bdc0f01618f97145b9c6b520eb3a4d Mon Sep 17 00:00:00 2001 From: Rodrigo Date: Fri, 19 Jun 2026 02:44:52 -0300 Subject: [PATCH 9/9] feat(load): log messageIDs | fix concurrency issues --- ccip/devenv/impl.go | 6 +- ccip/devenv/tests/load/gun.go | 77 +++++++++++++------ 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 | 54 ++++++++++++- 7 files changed, 117 insertions(+), 28 deletions(-) diff --git a/ccip/devenv/impl.go b/ccip/devenv/impl.go index a5f5fdf3e..c0f5f7da8 100644 --- a/ccip/devenv/impl.go +++ b/ccip/devenv/impl.go @@ -1379,7 +1379,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{ @@ -1422,6 +1421,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 76f83285e..805de1b0a 100644 --- a/ccip/devenv/tests/load/gun.go +++ b/ccip/devenv/tests/load/gun.go @@ -8,8 +8,7 @@ import ( "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" @@ -70,9 +69,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 @@ -122,7 +123,7 @@ func NewCCIPLoadGun( confirmExecTimeout = 5 * time.Minute } - return &CCIPLoadGun{ + g := &CCIPLoadGun{ source: source, destinations: destinations, ccvAddr: ccvAddr, @@ -130,7 +131,36 @@ func NewCCIPLoadGun( confirmSend: opts.ConfirmSend, confirmExecTimeout: confirmExecTimeout, skipExecConfirm: opts.SkipExecConfirm, - }, nil + } + 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 { @@ -148,26 +178,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() @@ -198,6 +212,16 @@ func (g *CCIPLoadGun) Call(gen *wasp.Generator) *wasp.Response { 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") + if g.skipExecConfirm { return &wasp.Response{ Failed: false, @@ -242,3 +266,12 @@ func (g *CCIPLoadGun) MaxConcurrentObserved() int32 { func (g *CCIPLoadGun) CallCount() int64 { return g.calls.Load() } + +// 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 509130da8..f2c5002dc 100644 --- a/ccip/devenv/tests/load/gun_canton2evm_test.go +++ b/ccip/devenv/tests/load/gun_canton2evm_test.go @@ -80,5 +80,5 @@ func TestCanton2EVM_Load(t *testing.T) { ) require.NoError(t, err) - runWASP(t, gun, "canton-load-canton2evm", sched, "message_only", skipExec) + runWASP(t, gun, "canton-load-canton2evm", sched, "message_only", skipExec, boot.Cfg.IndexerEndpoints) } diff --git a/ccip/devenv/tests/load/gun_canton2evm_token_test.go b/ccip/devenv/tests/load/gun_canton2evm_token_test.go index 7a0c6ba24..2ba5fb96e 100644 --- a/ccip/devenv/tests/load/gun_canton2evm_token_test.go +++ b/ccip/devenv/tests/load/gun_canton2evm_token_test.go @@ -61,7 +61,7 @@ func TestCanton2EVM_TokenLoad(t *testing.T) { ) require.NoError(t, err) - runWASP(t, gun, "canton-load-canton2evm-token", sched, "token_transfer", false) + runWASP(t, gun, "canton-load-canton2evm-token", sched, "token_transfer", false, boot.Cfg.IndexerEndpoints) 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 e1e4970fa..7d57f5d1f 100644 --- a/ccip/devenv/tests/load/gun_evm2canton_test.go +++ b/ccip/devenv/tests/load/gun_evm2canton_test.go @@ -47,5 +47,5 @@ func TestEVM2Canton_Load(t *testing.T) { ) require.NoError(t, err) - runWASP(t, gun, "canton-load-evm2canton", loadSchedule(t), "message_only", false) + runWASP(t, gun, "canton-load-evm2canton", loadSchedule(t), "message_only", false, boot.Cfg.IndexerEndpoints) } diff --git a/ccip/devenv/tests/load/gun_evm2canton_token_test.go b/ccip/devenv/tests/load/gun_evm2canton_token_test.go index 3d1562319..7cdb3dc39 100644 --- a/ccip/devenv/tests/load/gun_evm2canton_token_test.go +++ b/ccip/devenv/tests/load/gun_evm2canton_token_test.go @@ -72,7 +72,7 @@ func TestEVM2Canton_TokenLoad(t *testing.T) { ) require.NoError(t, err) - runWASP(t, gun, "canton-load-evm2canton-token", sched, "token_transfer", false) + runWASP(t, gun, "canton-load-evm2canton-token", sched, "token_transfer", false, boot.Cfg.IndexerEndpoints) 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 cdf7e160c..266ff8962 100644 --- a/ccip/devenv/tests/load/load_helpers.go +++ b/ccip/devenv/tests/load/load_helpers.go @@ -33,8 +33,11 @@ const ( envMessageRate = "CANTON_LOAD_MESSAGE_RATE" envLoadDuration = "CANTON_LOAD_DURATION" envLoadSkipExecConfirm = "CANTON_LOAD_SKIP_EXEC_CONFIRM" + envLoadCallTimeout = "CANTON_LOAD_CALL_TIMEOUT" defaultMessageRate = "1/1s" defaultLoadDuration = 90 * time.Second + defaultLoadCallPadding = 2 * time.Minute + defaultSendOnlyCallBudget = 5 * time.Minute ) type scheduleConfig struct { @@ -84,9 +87,57 @@ func loadSkipExecConfirm(t *testing.T) bool { } } -func runWASP(t *testing.T, gun *CCIPLoadGun, genName string, sched scheduleConfig, scenario string, skipExecConfirm bool) { +func waspCallTimeout(t *testing.T, gun *CCIPLoadGun, sched scheduleConfig, skipExecConfirm bool) 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 + } + if skipExecConfirm { + return defaultSendOnlyCallBudget + sched.rateUnit + } + + return gun.ConfirmExecTimeout() + sched.rateUnit + defaultLoadCallPadding +} + +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, skipExecConfirm bool, indexerEndpoints []string) { + t.Helper() + defer logLoadMessageSummary(t, gun, indexerEndpoints) + + callTimeout := waspCallTimeout(t, gun, sched, skipExecConfirm) + ccv.Plog.Info(). + Str("messageRate", sched.messageRate). + Dur("rateUnit", sched.rateUnit). + Dur("loadDuration", sched.duration). + Dur("callTimeout", callTimeout). + Dur("confirmExecTimeout", gun.ConfirmExecTimeout()). + Bool("skipExecConfirm", skipExecConfirm). + Msg("WASP load schedule") + labels := map[string]string{ "go_test_name": genName, "branch": "test", @@ -109,6 +160,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,