diff --git a/.github/actions/ccip-load-test/action.yml b/.github/actions/ccip-load-test/action.yml index 18ededfb7..d85c38c4b 100644 --- a/.github/actions/ccip-load-test/action.yml +++ b/.github/actions/ccip-load-test/action.yml @@ -1,7 +1,8 @@ name: Run CCIP load test description: >- - Shared setup and execution for CCIP WASP load tests against local devenv or - prod-testnet infrastructure. + Shared execution for CCIP WASP load tests against local devenv or prod-testnet + infrastructure. Call setup-ccip-devenv and upload-ccip-devenv-logs separately + when you want distinct workflow steps (see ccip-load-tests.yml). inputs: ccip_env: @@ -77,16 +78,6 @@ inputs: runs: using: composite steps: - - name: Setup CCIP devenv - if: inputs.ccip_env == 'devenv' - uses: ./.github/actions/setup-ccip-devenv - with: - canton-ref: ${{ inputs.canton_ref }} - canton-path: ${{ inputs.canton_path }} - ccv-iam-role: ${{ inputs.ccv-iam-role }} - jd-registry: ${{ inputs.jd-registry }} - jd-image: ${{ inputs.jd-image }} - - name: Install Go (prod-testnet) if: inputs.ccip_env == 'prod-testnet' uses: actions/setup-go@v6 @@ -116,7 +107,7 @@ runs: evm2canton-token) TEST_RUN='^TestEVM2Canton_TokenLoad$' ;; *) echo "unknown direction: ${{ inputs.direction }}" >&2; exit 1 ;; esac - go test -timeout "${{ inputs.test_timeout }}" -v -count 1 -ccip-env=devenv -run "$TEST_RUN" + go test -timeout "${{ inputs.test_timeout }}" -v -count 1 -run "$TEST_RUN" - name: Run load tests (prod-testnet) if: inputs.ccip_env == 'prod-testnet' @@ -144,13 +135,4 @@ runs: evm2canton-token) TEST_RUN='^TestEVM2Canton_TokenLoad$' ;; *) echo "unknown direction: ${{ inputs.direction }}" >&2; exit 1 ;; esac - go test -timeout "${{ inputs.test_timeout }}" -v -count 1 -ccip-env=prod-testnet -run "$TEST_RUN" - - - name: Upload devenv logs - if: always() && inputs.ccip_env == 'devenv' - uses: ./.github/actions/upload-ccip-devenv-logs - with: - canton-path: ${{ inputs.canton_path }} - test-package-dir: load - log-dump-suffix: ccip-load-tests - artifact-name: container-logs-ccip-load-tests + go test -timeout "${{ inputs.test_timeout }}" -v -count 1 -run "$TEST_RUN" diff --git a/.github/workflows/ccip-load-command.yml b/.github/workflows/ccip-load-command.yml index 1016f0022..cfcf4db12 100644 --- a/.github/workflows/ccip-load-command.yml +++ b/.github/workflows/ccip-load-command.yml @@ -79,6 +79,15 @@ jobs: echo "test_timeout=30m" >> "$GITHUB_OUTPUT" fi + - name: Setup CCIP devenv + if: steps.params.outputs.ccip_env == 'devenv' + uses: ./.github/actions/setup-ccip-devenv + with: + canton-path: . + ccv-iam-role: ${{ secrets.CCV_IAM_ROLE }} + jd-registry: ${{ secrets.JD_REGISTRY }} + jd-image: ${{ secrets.JD_IMAGE }} + - name: Run CCIP load test uses: ./.github/actions/ccip-load-test with: @@ -98,6 +107,15 @@ jobs: CANTON_OKTA_CLIENT_SECRET_TESTNET: ${{ secrets.CANTON_OKTA_CLIENT_SECRET_TESTNET }} CCIP_PROD_TESTNET_PRIVATE_KEY: ${{ secrets.CCIP_PROD_TESTNET_PRIVATE_KEY }} + - name: Upload devenv logs + if: always() && steps.params.outputs.ccip_env == 'devenv' + uses: ./.github/actions/upload-ccip-devenv-logs + with: + canton-path: . + test-package-dir: load + log-dump-suffix: ccip-load-tests + artifact-name: container-logs-ccip-load-tests + - name: Update comment if: always() uses: mshick/add-pr-comment@b8f338c590a895d50bcbfa6c5859251edc8952fc # v2.8.2 diff --git a/.github/workflows/ccip-load-tests.yml b/.github/workflows/ccip-load-tests.yml index 77e9b655b..8fd9e3656 100644 --- a/.github/workflows/ccip-load-tests.yml +++ b/.github/workflows/ccip-load-tests.yml @@ -104,23 +104,60 @@ jobs: echo "test_timeout=${{ inputs.test_timeout }}" >> "$GITHUB_OUTPUT" fi - - name: Run CCIP load test - uses: ./.github/actions/ccip-load-test + - name: Setup CCIP devenv + if: inputs.ccip_env == 'devenv' + uses: ./.github/actions/setup-ccip-devenv with: - ccip_env: ${{ inputs.ccip_env }} - direction: ${{ inputs.direction }} - message_rate: ${{ steps.prod_defaults.outputs.message_rate }} - load_duration: ${{ steps.prod_defaults.outputs.load_duration }} - test_timeout: ${{ steps.prod_defaults.outputs.test_timeout }} - config_file: ${{ inputs.config_file }} - canton_path: ${{ steps.prod_defaults.outputs.canton_path }} - canton_ref: ${{ inputs.canton_ref }} - skip_exec_confirm: ${{ inputs.skip_exec_confirm }} - confirm_exec_timeout: ${{ inputs.confirm_exec_timeout }} - CANTON_OKTA_AUTHORIZER_TESTNET: ${{ secrets.CANTON_OKTA_AUTHORIZER_TESTNET }} - CANTON_OKTA_CLIENT_ID_TESTNET: ${{ secrets.CANTON_OKTA_CLIENT_ID_TESTNET }} - CANTON_OKTA_CLIENT_SECRET_TESTNET: ${{ secrets.CANTON_OKTA_CLIENT_SECRET_TESTNET }} - CCIP_PROD_TESTNET_PRIVATE_KEY: ${{ secrets.CCIP_PROD_TESTNET_PRIVATE_KEY }} + canton-ref: ${{ inputs.canton_ref }} + canton-path: ${{ steps.prod_defaults.outputs.canton_path }} ccv-iam-role: ${{ secrets.CCV_IAM_ROLE }} jd-registry: ${{ secrets.JD_REGISTRY }} jd-image: ${{ secrets.JD_IMAGE }} + + - name: Install Go (prod-testnet) + if: inputs.ccip_env == 'prod-testnet' + uses: actions/setup-go@v6 + with: + cache: true + go-version-file: ${{ steps.prod_defaults.outputs.canton_path }}/go.mod + cache-dependency-path: ${{ steps.prod_defaults.outputs.canton_path }}/go.sum + + - name: Download Go dependencies (prod-testnet) + if: inputs.ccip_env == 'prod-testnet' + working-directory: ${{ steps.prod_defaults.outputs.canton_path }} + run: go mod download + + - name: Run CCIP load test (${{ inputs.direction }}) + working-directory: ${{ steps.prod_defaults.outputs.canton_path }}/ccip/devenv/tests/load + env: + CANTON_LOAD_MESSAGE_RATE: ${{ steps.prod_defaults.outputs.message_rate }} + CANTON_LOAD_DURATION: ${{ steps.prod_defaults.outputs.load_duration }} + CCIP_ENV: ${{ inputs.ccip_env == 'prod-testnet' && 'prod-testnet' || '' }} + CCIP_CONFIG_FILE: ${{ inputs.config_file }} + CANTON_AUTH_TYPE: ${{ inputs.ccip_env == 'prod-testnet' && 'clientCredentials' || '' }} + CANTON_AUTH_URL: ${{ inputs.ccip_env == 'prod-testnet' && secrets.CANTON_OKTA_AUTHORIZER_TESTNET || '' }} + CANTON_CLIENT_ID: ${{ inputs.ccip_env == 'prod-testnet' && secrets.CANTON_OKTA_CLIENT_ID_TESTNET || '' }} + CANTON_CLIENT_SECRET: ${{ inputs.ccip_env == 'prod-testnet' && secrets.CANTON_OKTA_CLIENT_SECRET_TESTNET || '' }} + CANTON_PARTY_ID: ${{ inputs.ccip_env == 'prod-testnet' && 'u_d53a15c42af6::1220c250c23c55120f7c758bccc5cbc739629015ab921594e1c29656981f985bffa7' || '' }} + CANTON_GRPC_URL: ${{ inputs.ccip_env == 'prod-testnet' && 'testnet.cv1.bcy-v.metalhosts.com:443' || '' }} + CANTON_CONFIRM_EXEC_TIMEOUT: ${{ inputs.confirm_exec_timeout }} + CANTON_LOAD_SKIP_EXEC_CONFIRM: ${{ inputs.skip_exec_confirm }} + PRIVATE_KEY: ${{ inputs.ccip_env == 'prod-testnet' && secrets.CCIP_PROD_TESTNET_PRIVATE_KEY || '' }} + run: | + case "${{ inputs.direction }}" in + canton2evm) TEST_RUN='^TestCanton2EVM_Load$' ;; + evm2canton) TEST_RUN='^TestEVM2Canton_Load$' ;; + canton2evm-token) TEST_RUN='^TestCanton2EVM_TokenLoad$' ;; + evm2canton-token) TEST_RUN='^TestEVM2Canton_TokenLoad$' ;; + *) echo "unknown direction: ${{ inputs.direction }}" >&2; exit 1 ;; + esac + go test -timeout "${{ steps.prod_defaults.outputs.test_timeout }}" -v -count 1 -run "$TEST_RUN" + + - name: Upload devenv logs + if: always() && inputs.ccip_env == 'devenv' + uses: ./.github/actions/upload-ccip-devenv-logs + with: + canton-path: ${{ steps.prod_defaults.outputs.canton_path }} + test-package-dir: load + log-dump-suffix: ccip-load-tests + artifact-name: container-logs-ccip-load-tests diff --git a/ccip/devenv/constants.go b/ccip/devenv/constants.go new file mode 100644 index 000000000..c0234b457 --- /dev/null +++ b/ccip/devenv/constants.go @@ -0,0 +1,26 @@ +package devenv + +import "github.com/smartcontractkit/chainlink-ccv/protocol" + +// Shared devenv test constants (message and token paths). + +const ( + // EVMToCantonFinalityConfig is the minimum block-depth FTF (1 confirmation). + EVMToCantonFinalityConfig = protocol.Finality(1) + + // CantonToEVMFeeAmount is the per-message CCIP fee budget in Amulet units for + // message-only sends (200k gas). Kept low for prod-testnet compatibility. + CantonToEVMFeeAmount int64 = 50 + + // CantonToEVMTokenTransferFeeAmount is the per-message fee budget for token transfers + // (500k execution gas), which quote ~127 Amulet per send in devenv. Used only by + // token e2e/load tests; must cover one send and leave enough change for sequential sends. + CantonToEVMTokenTransferFeeAmount int64 = 130 + + // Canton token amounts use 10-decimal fixed point (e.g. 1_000_000_000 = 0.1). + CantonFixedPointScale int64 = 10_000_000_000 + CantonFixedPointToEVMScale int64 = 100_000_000 // fixedPoint * this = EVM wei + + // CantonToEVMTokenSequentialSends is how many token transfers the Canton→EVM e2e subtest sends. + CantonToEVMTokenSequentialSends = 2 +) diff --git a/ccip/devenv/impl.go b/ccip/devenv/impl.go index 2bd2fba58..51f63077e 100644 --- a/ccip/devenv/impl.go +++ b/ccip/devenv/impl.go @@ -70,8 +70,9 @@ import ( ) // TODO: move this to share between devenv and integration tests -// and define a const for LINK instrument const AMTInstrument = types.TEXT("Amulet") +const LINKInstrument = types.TEXT("LINK") + const amuletTransferPreapprovalTemplateID = "#splice-amulet:Splice.AmuletRules:TransferPreapproval" var _ cciptestinterfaces.CCIP17 = &Chain{} @@ -222,9 +223,11 @@ type Chain struct { validatorAPIClients validatorAPIClients feeTokenInstrument splice_api_token_holding_v1.InstrumentId + feeAmountPerMessage uint64 // per-message fee budget; used when rotating fee holdings after send nextFeeCID string // holding CID to be used as fee on next message send transferTokenInstrument *splice_api_token_holding_v1.InstrumentId nextTransferCID string // holding CID to be used as transfer on next message send + sendsLeft uint64 // remaining sends in current setup batch; 0 = always rotate // verifierObs is injected post-construction by test runners (see SetVerifierObservation). // Required by ConfirmExecOnDest to fetch verifier results from aggregator/indexer. @@ -551,9 +554,10 @@ func (c *Chain) GetTokenExpansionConfigs( "instrument-id:Amulet", ), }, - PoolType: string(poolRef.Type), - TokenPoolQualifier: poolRef.Qualifier, - AllowedFinalityConfig: finality.Config{WaitForFinality: true}, + PoolType: string(poolRef.Type), + TokenPoolQualifier: poolRef.Qualifier, + // BlockDepth 1: minimum FTF allowed by pool; messages may request finality>=1 via extra args. + AllowedFinalityConfig: finality.Config{BlockDepth: 1}, }, }}, nil } @@ -761,8 +765,9 @@ func (c *Chain) GetTokenTransferConfigs( Type: datastore.ContractType(token_admin_registry.ContractType), Version: token_admin_registry.Version, }, - RemoteChains: remoteChains, - AllowedFinalityConfig: finality.Config{WaitForFinality: true}, + RemoteChains: remoteChains, + // BlockDepth 1: pool must allow message FTF; WaitForFinality-only rejects BlockDepth requests. + AllowedFinalityConfig: finality.Config{BlockDepth: 1}, }}, nil } @@ -1013,39 +1018,33 @@ func (c *Chain) SetupReceive(ctx context.Context) error { // SetupSend sets up Canton sender specific prerequisites for sending a message. // eg: deploy per-party router, deploy ccipsender contract... +// +// feePerMessage is stored and used as the per-send fee budget in SendMessage and holding rotation. +// transferPerMessage is the per-send transfer amount (nil or ≤0 → message-only). Initial holding +// selection uses perMessage × sendsLeft when SetSequentialSends was called with sends > 0. +// +// transferInstrument defaults to Amulet under registryAdmin when omitted or Admin is empty. func (c *Chain) SetupSend( ctx context.Context, - feeAmountPerMessage uint64, - transferAmountPerMessage uint64, + feePerMessage uint64, + transferPerMessage *big.Rat, + transferInstrument ...splice_api_token_holding_v1.InstrumentId, ) error { - participant, clientIdx, err := c.ClientParticipant() + participant, _, err := c.ClientParticipant() if err != nil { return fmt.Errorf("no canton participants configured: %w", err) } party := participant.PartyID - // Deploy contracts // - // Router for sender party. routerAddress, err := c.DeployPerPartyRouter(ctx, participant, party) if err != nil { return fmt.Errorf("failed to deploy per-party router: %w", err) } - // Deploy a sender-owned CCIPSender contract. - senderInstanceID := contracts.MustNewInstanceID("devenv-ccipsender") - out, err := operations.ExecuteOperation(c.e.OperationsBundle, sender.Deploy, c.chain, contract.DeployInput[ccipsender.CCIPSender]{ - Qualifier: nil, - ParticipantIndex: clientIdx, - Template: ccipsender.CCIPSender{ - InstanceId: types.TEXT(senderInstanceID), - Owner: types.PARTY(party), - }, - OwnerParty: types.PARTY(party), - }) + senderAddress, err := c.DeployCCIPSender(ctx, participant, party) if err != nil { return fmt.Errorf("failed to deploy ccip sender contract: %w", err) } - senderAddress := contracts.HexToInstanceAddress(out.Output.Address) registryAdmin, err := testhelpers.ResolveRegistryAdmin(ctx, participant) if err != nil { return fmt.Errorf("resolve registry admin: %w", err) @@ -1054,14 +1053,11 @@ func (c *Chain) SetupSend( c.routerAddress = routerAddress c.senderAddress = senderAddress - // Clients setup // _, err = c.getValidatorAPIClients() if err != nil { return fmt.Errorf("get validator API clients: %w", err) } - // Tokens setup // - // Setup fee holding feeTokenInstrument := splice_api_token_holding_v1.InstrumentId{ Admin: types.PARTY(registryAdmin), Id: AMTInstrument, @@ -1076,49 +1072,70 @@ func (c *Chain) SetupSend( if err != nil { return fmt.Errorf("list fee holdings for setup: %w", err) } - feeMin := new(big.Rat).SetUint64(feeAmountPerMessage) - selectedFee, err := testhelpers.SelectHoldingsForInstrument(feeRows, []*big.Rat{feeMin}) - if err != nil || len(selectedFee) == 0 { + feeMin := new(big.Rat).SetUint64(feePerMessage) + if c.sendsLeft > 0 { + feeMin.Mul(feeMin, new(big.Rat).SetUint64(c.sendsLeft)) + } + selectedFee, err := c.selectHolding("setup-fee", feeTokenInstrument, feeRows, feeMin) + if err != nil { return fmt.Errorf("select fee holding for setup: %w", err) } c.feeTokenInstrument = feeTokenInstrument - c.nextFeeCID = selectedFee[0].ContractID + c.feeAmountPerMessage = feePerMessage + c.nextFeeCID = selectedFee.ContractID - // End setup, no transfer holdings needed - if transferAmountPerMessage == 0 { + if transferPerMessage == nil || transferPerMessage.Sign() <= 0 { + c.transferTokenInstrument = nil + c.nextTransferCID = "" return nil } - // Setup transfer holdings - // TODO: add support for LINK instrument transferTokenInstrument := &splice_api_token_holding_v1.InstrumentId{ Admin: types.PARTY(registryAdmin), Id: AMTInstrument, } + if len(transferInstrument) > 0 && transferInstrument[0].Admin != "" { + transferTokenInstrument = &transferInstrument[0] + } - // Select transfer holding using another call here because fee/token might use different instruments - filters := append(baseFilters, testhelpers.ExcludeCIDs([]string{c.nextFeeCID})) // this filter is here in case fee and transfer use the same instrument + filters := append(baseFilters, testhelpers.ExcludeCIDs([]string{c.nextFeeCID})) transferRows, err := testhelpers.ListHoldingsForInstrument(ctx, participant, transferTokenInstrument, filters...) if err != nil { return fmt.Errorf("list transfer holdings for setup: %w", err) } - transferMin := new(big.Rat).SetUint64(transferAmountPerMessage) - selectedTransfer, err := testhelpers.SelectHoldingsForInstrument(transferRows, []*big.Rat{transferMin}) - if err != nil || len(selectedTransfer) == 0 { + transferMin := new(big.Rat).Set(transferPerMessage) + if c.sendsLeft > 0 { + transferMin.Mul(transferMin, new(big.Rat).SetUint64(c.sendsLeft)) + } + selectedTransfer, err := c.selectHolding("setup-transfer", *transferTokenInstrument, transferRows, transferMin) + if err != nil { return fmt.Errorf("select transfer holding for setup: %w", err) } c.transferTokenInstrument = transferTokenInstrument - c.nextTransferCID = selectedTransfer[0].ContractID + c.nextTransferCID = selectedTransfer.ContractID return nil } +// SetSequentialSends limits holding rotation to the next sends messages in this setup batch. +// After the send whose on-chain seq equals nextSeq+sends, setNextHoldings is skipped. +func (c *Chain) SetSequentialSends(sends int) { + if sends <= 0 { + c.sendsLeft = 0 + return + } + c.sendsLeft = uint64(sends) +} + // MintTokens mint tokens for transfer and fees. To be used on devenv tests only. // this method won't work in staging/prod tests -// TODO: add support for LINK instrument -func (c *Chain) MintTokens(ctx context.Context, amount uint64) error { +func (c *Chain) MintTokens(ctx context.Context, amount *big.Rat) error { + if amount == nil || amount.Sign() <= 0 { + return nil + } + participant, _, err := c.ClientParticipant() if err != nil { return fmt.Errorf("no canton participants configured: %w", err) @@ -1137,7 +1154,7 @@ func (c *Chain) MintTokens(ctx context.Context, amount uint64) error { validatorAPIClients.transferClient, validatorAPIClients.scanClient, party, - strconv.FormatUint(amount, 10), + amount.FloatString(10), ) if err != nil { return fmt.Errorf("failed to mint tokens: %w", err) @@ -1166,7 +1183,8 @@ func (c *Chain) SendMessage(ctx context.Context, dest uint64, fields cciptestint return cciptestinterfaces.MessageSentEvent{}, fmt.Errorf("canton SendMessage: token transfer amount must be positive") } - hasTokenTransfer := c.transferTokenInstrument != nil && fields.TokenAmount.Amount != nil && fields.TokenAmount.Amount.Sign() > 0 + hasTokenTransfer := fields.TokenAmount.Amount != nil && + fields.TokenAmount.Amount.Sign() > 0 participant, clientIdx, err := c.ClientParticipant() if err != nil { @@ -1250,9 +1268,8 @@ func (c *Chain) SendMessage(ctx context.Context, dest uint64, fields cciptestint Receiver: hex.EncodeToString(fields.Receiver), } if hasTokenTransfer { - // TODO: move this to the other line also branching by tokenTransfer outgoingMessage.TokenTransfer = &oapiCommon.TokenTransfer{ - Amount: fields.TokenAmount.Amount.String(), + Amount: new(big.Rat).SetFrac(fields.TokenAmount.Amount, big.NewInt(CantonFixedPointScale)).FloatString(10), Token: oapiCommon.InstrumentId{ Admin: oapiCommon.PartyId(c.transferTokenInstrument.Admin), Id: string(c.transferTokenInstrument.Id), @@ -1300,8 +1317,7 @@ func (c *Chain) SendMessage(ctx context.Context, dest uint64, fields cciptestint // Token Pool var tokenPoolRequiredCCVs []string if hasTokenTransfer { - // Get Token Pool - token := contracts.EncodeInstrumentID(c.feeTokenInstrument) + token := contracts.EncodeInstrumentID(*c.transferTokenInstrument) tokenPoolAddress, err := c.GetTokenPoolForToken(ctx, token) if err != nil { return cciptestinterfaces.MessageSentEvent{}, fmt.Errorf("get token pool for token %s: %w", token.String(), err) @@ -1450,12 +1466,6 @@ func (c *Chain) SendMessage(ctx context.Context, dest uint64, fields cciptestint Uint64("seqNo", parsedSend.seqNo). Msg("CCIP Send executed") - // Set next holdings - err = c.setNextHoldings(update.GetTransaction().GetEvents(), hasTokenTransfer, fields.TokenAmount.Amount) - if err != nil { - return cciptestinterfaces.MessageSentEvent{}, fmt.Errorf("set next holdings: %w", err) - } - event := cciptestinterfaces.MessageSentEvent{ MessageID: parsedSend.messageID, ReceiptIssuers: nil, // TODO: add them later, not currently needed @@ -1468,6 +1478,24 @@ func (c *Chain) SendMessage(ctx context.Context, dest uint64, fields cciptestint c.lastSentSeq = parsedSend.seqNo c.lastSentEvent = event + c.sendsLeft-- + if c.sendsLeft == 0 { + c.logger.Info(). + Uint64("sendsLeft", c.sendsLeft). + Msg("Skipping holding rotation after last planned send in batch") + + return event, nil + } + + var tokenAmount *big.Rat + if hasTokenTransfer { + tokenAmount = new(big.Rat).SetFrac(fields.TokenAmount.Amount, big.NewInt(CantonFixedPointScale)) + } + err = c.setNextHoldings(update.GetTransaction().GetEvents(), hasTokenTransfer, tokenAmount) + if err != nil { + return cciptestinterfaces.MessageSentEvent{}, fmt.Errorf("set next holdings: %w", err) + } + return event, nil } @@ -1570,7 +1598,7 @@ func parseFirstCCIPMessageSentFromLedgerEvents(events []*apiv2.Event, previousSe // Created events from the send transaction. When no qualifying holding appears in those // events, the corresponding next CID is cleared (empty means no next holding available); // the current send already succeeded and a future SendMessage will fail its holding checks. -func (c *Chain) setNextHoldings(events []*apiv2.Event, hasTokenTransfer bool, tokenAmount *big.Int) error { +func (c *Chain) setNextHoldings(events []*apiv2.Event, hasTokenTransfer bool, tokenAmount *big.Rat) error { participant, _, err := c.ClientParticipant() if err != nil { return fmt.Errorf("no canton participants configured: %w", err) @@ -1596,18 +1624,18 @@ func (c *Chain) setNextHoldings(events []*apiv2.Event, hasTokenTransfer bool, to testhelpers.ExcludeCIDs(spentCIDs), } - nextFeeCID, exhaustion, err := pickNextHolding( + feeMin := new(big.Rat).SetUint64(c.feeAmountPerMessage) + nextFeeCID, feeExhausted, err := c.pickNextHolding( events, c.feeTokenInstrument, - big.NewRat(0, 1), // TODO: we'll have to consider real fee here once we go load tests + feeMin, refreshFilters..., ) - if err != nil && !exhaustion { + if err != nil && !feeExhausted { return fmt.Errorf("refresh next fee holding from update: %w", err) } - if !exhaustion { + if !feeExhausted { c.nextFeeCID = nextFeeCID - c.logger.Info().Str("NextFeeCID", c.nextFeeCID).Msg("Selected next fee holding") } else { c.nextFeeCID = "" c.logger.Info().Msg("No next fee holding available after send; clearing for future sends") @@ -1621,19 +1649,18 @@ func (c *Chain) setNextHoldings(events []*apiv2.Event, hasTokenTransfer bool, to slices.Clone(refreshFilters), testhelpers.ExcludeCIDs(append(spentCIDs, c.nextFeeCID)), ) - transferValue := new(big.Rat).SetInt(tokenAmount) - nextTransferCID, exhaustion, err := pickNextHolding( + transferValue := new(big.Rat).Set(tokenAmount) + nextTransferCID, transferExhausted, err := c.pickNextHolding( events, *c.transferTokenInstrument, transferValue, transferFilters..., ) - if err != nil && !exhaustion { + if err != nil && !transferExhausted { return fmt.Errorf("refresh next transfer holding from update: %w", err) } - if !exhaustion { + if !transferExhausted { c.nextTransferCID = nextTransferCID - c.logger.Info().Str("NextTransferCID", c.nextTransferCID).Msg("Selected next transfer holding") } else { c.nextTransferCID = "" c.logger.Info().Msg("No next transfer holding available after send; clearing for future sends") @@ -1644,27 +1671,93 @@ func (c *Chain) setNextHoldings(events []*apiv2.Event, hasTokenTransfer bool, to // pickNextHolding chooses an unused holding for the next send from Created events in the // send transaction only. Returns an empty CID when nothing meets minAmount (not an error). -func pickNextHolding( +func (c *Chain) pickNextHolding( events []*apiv2.Event, instrument splice_api_token_holding_v1.InstrumentId, minAmount *big.Rat, filters ...testhelpers.Filter, ) (string, bool, error) { - minAmounts := []*big.Rat{minAmount} - fromEvents, err := testhelpers.ListedHoldingsFromTransactionEventsForInstrument(events, instrument, filters...) if err != nil { return "", false, err } if len(fromEvents) == 0 { - return "", true, nil // no qualifying Created events → exhaustion, next send will fail + c.logger.Info(). + Str("Source", "rotate"). + Str("InstrumentAdmin", string(instrument.Admin)). + Str("InstrumentId", string(instrument.Id)). + Str("MinRequired", minAmount.FloatString(10)). + Int("Candidates", 0). + Msg("No qualifying holding in send events") + + return "", true, nil + } + + picked, err := c.selectHolding("rotate", instrument, fromEvents, minAmount) + if err != nil { + return "", true, err } - picked, err := testhelpers.SelectHoldingsForInstrument(fromEvents, minAmounts) + + return picked.ContractID, false, nil +} + +func (c *Chain) selectHolding( + source string, + instrument splice_api_token_holding_v1.InstrumentId, + rows []testhelpers.ListedHolding, + minAmount *big.Rat, +) (testhelpers.ListedHolding, error) { + if minAmount == nil { + minAmount = big.NewRat(0, 1) + } + + picked, err := testhelpers.SelectHoldingsForInstrument(rows, []*big.Rat{minAmount}) if err != nil || len(picked) == 0 { - return "", true, fmt.Errorf("refresh next fee holding from update: %w", err) + c.logHoldingSelectionFailure(source, instrument, minAmount, rows, err) + if err != nil { + return testhelpers.ListedHolding{}, err + } + + return testhelpers.ListedHolding{}, fmt.Errorf("no qualifying holding for %s", source) } - return picked[0].ContractID, false, nil + c.logger.Info(). + Str("Source", source). + Str("ContractID", picked[0].ContractID). + Str("Amount", picked[0].Amount.FloatString(10)). + Str("InstrumentAdmin", string(instrument.Admin)). + Str("InstrumentId", string(instrument.Id)). + Str("MinRequired", minAmount.FloatString(10)). + Int("Candidates", len(rows)). + Msg("Selected holding") + + return picked[0], nil +} + +func (c *Chain) logHoldingSelectionFailure( + source string, + instrument splice_api_token_holding_v1.InstrumentId, + minAmount *big.Rat, + rows []testhelpers.ListedHolding, + selectErr error, +) { + logEvent := c.logger.Info(). + Str("Source", source). + Str("InstrumentAdmin", string(instrument.Admin)). + Str("InstrumentId", string(instrument.Id)). + Str("MinRequired", minAmount.FloatString(10)). + Int("Candidates", len(rows)) + if selectErr != nil { + logEvent = logEvent.Err(selectErr) + } + logEvent.Msg("No qualifying holding selected") + for _, row := range rows { + c.logger.Debug(). + Str("Source", source). + Str("ContractID", row.ContractID). + Str("Amount", row.Amount.FloatString(10)). + Msg("Holding candidate") + } } func (c *Chain) waitOneSentEventBySeqNo(ctx context.Context, to, seq uint64, timeout time.Duration) (cciptestinterfaces.MessageSentEvent, error) { diff --git a/ccip/devenv/manual_execution.go b/ccip/devenv/manual_execution.go index 5126be778..df58d74d9 100644 --- a/ccip/devenv/manual_execution.go +++ b/ccip/devenv/manual_execution.go @@ -5,6 +5,7 @@ import ( "encoding/hex" "fmt" "math/big" + "os" "strings" "sync" "time" @@ -24,15 +25,25 @@ import ( "github.com/smartcontractkit/chainlink-canton/bindings/generated/latest/ccip/ccipruntime" "github.com/smartcontractkit/chainlink-canton/bindings/generated/latest/ccip/events" ccipreceiver "github.com/smartcontractkit/chainlink-canton/bindings/generated/latest/ccip/receiver" + ccipsender "github.com/smartcontractkit/chainlink-canton/bindings/generated/latest/ccip/sender" "github.com/smartcontractkit/chainlink-canton/contracts" "github.com/smartcontractkit/chainlink-canton/deployment/operations/ccip/per_party_router_factory" "github.com/smartcontractkit/chainlink-canton/deployment/operations/ccip/receiver" + "github.com/smartcontractkit/chainlink-canton/deployment/operations/ccip/sender" "github.com/smartcontractkit/chainlink-canton/deployment/utils/operations/contract" "github.com/smartcontractkit/chainlink-canton/testhelpers" ) const perPartyRouterInstanceID = "test-router" +func instanceIDFromEnv(key, defaultID string) contracts.InstanceID { + if v := strings.TrimSpace(os.Getenv(key)); v != "" { + return contracts.InstanceID(v) + } + + return contracts.InstanceID(defaultID) +} + // DeployPerPartyRouter uses the PerPartyRouterFactory to create a new PerPartyRouter instance for the given party. // partyOwner (client participant) exercises CreateRouter with factory disclosures from EDS. // It returns the instance address of the router. If a router already exists for the party, it returns the existing address. @@ -96,6 +107,55 @@ func (c *Chain) DeployPerPartyRouter(ctx context.Context, clientParticipant cant return routerAddress, nil } +// DeployCCIPSender returns a sender-owned CCIPSender instance address, deploying only when missing. +func (c *Chain) DeployCCIPSender(ctx context.Context, participant canton.Participant, partyId string) (contracts.InstanceAddress, error) { + var unset contracts.InstanceAddress + if c.senderAddress != unset { + return c.senderAddress, nil + } + + instanceID := instanceIDFromEnv("CANTON_SENDER_INSTANCE_ID", "e2e-ccipsender") + senderAddress := instanceID.RawInstanceAddress(types.PARTY(partyId)).InstanceAddress() + + if _, err := contract.FindActiveContractIDByInstanceAddress( + ctx, + participant.LedgerServices.State, + contract.LedgerQueryParties(participant), + ccipsender.CCIPSender{}.GetTemplateID(), + senderAddress, + ); err == nil { + c.senderAddress = senderAddress + return senderAddress, nil + } + + _, err := operations.ExecuteOperation(c.e.OperationsBundle, sender.Deploy, c.chain, contract.DeployInput[ccipsender.CCIPSender]{ + Qualifier: nil, + ParticipantIndex: c.clientParticipantIndex(), + Template: ccipsender.CCIPSender{ + InstanceId: types.TEXT(instanceID), + Owner: types.PARTY(partyId), + }, + OwnerParty: types.PARTY(partyId), + }) + if err != nil { + if _, findErr := contract.FindActiveContractIDByInstanceAddress( + ctx, + participant.LedgerServices.State, + contract.LedgerQueryParties(participant), + ccipsender.CCIPSender{}.GetTemplateID(), + senderAddress, + ); findErr == nil { + c.senderAddress = senderAddress + return senderAddress, nil + } + + return contracts.InstanceAddress{}, fmt.Errorf("failed to deploy ccip sender contract: %w", err) + } + c.senderAddress = senderAddress + + return senderAddress, nil +} + func (c *Chain) DeployCCIPReceiver(ctx context.Context, participant canton.Participant, partyId string, receiverFinality int64) (contracts.InstanceAddress, error) { finalityConfig, err := encodeReceiverFinalityConfig(receiverFinality) if err != nil { diff --git a/ccip/devenv/tests/constants.go b/ccip/devenv/tests/constants.go deleted file mode 100644 index 4d16a64a8..000000000 --- a/ccip/devenv/tests/constants.go +++ /dev/null @@ -1,14 +0,0 @@ -package tests - -// Shared devenv test constants (message and token paths). - -const ( - // CantonToEVMFeeAmount is the Canton CCIP send fee in Amulet units. - CantonToEVMFeeAmount int64 = 2_000 - - // EVMDecimalsScale converts Canton token amounts to EVM 18-decimal balance units. - EVMDecimalsScale int64 = 1_000_000_000_000_000_000 - - // CantonToEVMTokenSequentialSends is how many token transfers the Canton→EVM e2e subtest sends. - CantonToEVMTokenSequentialSends = 2 -) diff --git a/ccip/devenv/tests/e2e/canton2evm_e2e_test.go b/ccip/devenv/tests/e2e/canton2evm_e2e_test.go index ca7550055..3d14fb393 100644 --- a/ccip/devenv/tests/e2e/canton2evm_e2e_test.go +++ b/ccip/devenv/tests/e2e/canton2evm_e2e_test.go @@ -54,14 +54,15 @@ func TestCanton2EVM_Basic(t *testing.T) { // TODO: currently minting 2 holdings of 2k for all the e2e tests. Otherwise one holding might conflict with the other. // the tests need to be hardened to not rely on this and instead correctly pick the holding that suffices. - require.NoError(t, cantonImpl.MintTokens(ctx, uint64(devenvtests.CantonToEVMFeeAmount)*100)) - require.NoError(t, cantonImpl.MintTokens(ctx, uint64(devenvtests.CantonToEVMFeeAmount)*100)) + require.NoError(t, cantonImpl.MintTokens(ctx, new(big.Rat).SetUint64(uint64(cantondevenv.CantonToEVMFeeAmount)*10000))) + require.NoError(t, cantonImpl.MintTokens(ctx, new(big.Rat).SetUint64(uint64(cantondevenv.CantonToEVMFeeAmount)*10000))) t.Run("EOA receiver and default committee verifier", func(t *testing.T) { subtestCtx := ccv.Plog.WithContext(t.Context()) // Setup message send - require.NoError(t, cantonImpl.SetupSend(ctx, uint64(devenvtests.CantonToEVMFeeAmount), 0)) + cantonImpl.SetSequentialSends(1) + require.NoError(t, cantonImpl.SetupSend(ctx, uint64(cantondevenv.CantonToEVMFeeAmount), nil)) ds, err := lib.DataStore() require.NoError(t, err) @@ -146,11 +147,17 @@ func TestCanton2EVM_Basic(t *testing.T) { subtestCtx := ccv.Plog.WithContext(t.Context()) // Send params (transfer amount, gas limit, finality) come from token_transfer_config.toml. - lane := devenvtests.ResolveTokenLane(t, in, lib, chainMap, cantonChain.ChainSelector(), []uint64{evmChain.ChainSelector()}) - tokenTransferAmount := lane.TransferAmount.Uint64() - - // Setup message send - require.NoError(t, cantonImpl.SetupSend(ctx, uint64(devenvtests.CantonToEVMFeeAmount), tokenTransferAmount)) + lane := devenvtests.ResolveTokenLane(t, devenvtests.EnvDevenv, in, lib, chainMap, cantonChain.ChainSelector(), []uint64{evmChain.ChainSelector()}) + + fee := uint64(cantondevenv.CantonToEVMTokenTransferFeeAmount) + sends := cantondevenv.CantonToEVMTokenSequentialSends + transferPerSend := new(big.Rat).SetFrac(lane.TransferAmount, big.NewInt(cantondevenv.CantonFixedPointScale)) + cantonImpl.SetSequentialSends(sends) + if lane.TransferInstrument.Admin != "" { + require.NoError(t, cantonImpl.SetupSend(ctx, fee, transferPerSend, lane.TransferInstrument)) + } else { + require.NoError(t, cantonImpl.SetupSend(ctx, fee, transferPerSend)) + } ds, err := lib.DataStore() require.NoError(t, err) @@ -176,8 +183,8 @@ func TestCanton2EVM_Basic(t *testing.T) { require.NoError(t, err) require.NotNil(t, receiverBalanceBefore) - for sendIdx := range devenvtests.CantonToEVMTokenSequentialSends { - t.Logf("Token transfer send %d/%d", sendIdx+1, devenvtests.CantonToEVMTokenSequentialSends) + for sendIdx := range cantondevenv.CantonToEVMTokenSequentialSends { + t.Logf("Token transfer send %d/%d", sendIdx+1, cantondevenv.CantonToEVMTokenSequentialSends) sendMessageResult, err := cantonChain.SendMessage( subtestCtx, evmChain.ChainSelector(), @@ -221,8 +228,8 @@ func TestCanton2EVM_Basic(t *testing.T) { require.NoError(t, err) require.NotNil(t, receiverBalanceAfter) - expectedTransferPerMessage := new(big.Int).Mul(lane.TransferAmount, big.NewInt(devenvtests.EVMDecimalsScale)) - totalExpectedTransfer := new(big.Int).Mul(expectedTransferPerMessage, big.NewInt(devenvtests.CantonToEVMTokenSequentialSends)) + expectedTransferPerMessage := new(big.Int).Mul(lane.TransferAmount, big.NewInt(cantondevenv.CantonFixedPointToEVMScale)) + totalExpectedTransfer := new(big.Int).Mul(expectedTransferPerMessage, big.NewInt(cantondevenv.CantonToEVMTokenSequentialSends)) expectedReceiverBalanceAfter := new(big.Int).Add(new(big.Int).Set(receiverBalanceBefore), totalExpectedTransfer) t.Logf("EVM receiver token balance: before=%s after=%s totalExpectedTransfer=%s", receiverBalanceBefore.String(), receiverBalanceAfter.String(), totalExpectedTransfer.String()) require.Equal(t, expectedReceiverBalanceAfter, receiverBalanceAfter) @@ -232,10 +239,16 @@ func TestCanton2EVM_Basic(t *testing.T) { t.Run("token transfer with default extraArgs", func(t *testing.T) { subtestCtx := ccv.Plog.WithContext(t.Context()) - lane := devenvtests.ResolveTokenLane(t, in, lib, chainMap, cantonChain.ChainSelector(), []uint64{evmChain.ChainSelector()}) - tokenTransferAmount := lane.TransferAmount.Uint64() + lane := devenvtests.ResolveTokenLane(t, devenvtests.EnvDevenv, in, lib, chainMap, cantonChain.ChainSelector(), []uint64{evmChain.ChainSelector()}) - require.NoError(t, cantonImpl.SetupSend(ctx, uint64(devenvtests.CantonToEVMFeeAmount), tokenTransferAmount)) + fee := uint64(cantondevenv.CantonToEVMTokenTransferFeeAmount) + transferPerSend := new(big.Rat).SetFrac(lane.TransferAmount, big.NewInt(cantondevenv.CantonFixedPointScale)) + cantonImpl.SetSequentialSends(1) + if lane.TransferInstrument.Admin != "" { + require.NoError(t, cantonImpl.SetupSend(ctx, fee, transferPerSend, lane.TransferInstrument)) + } else { + require.NoError(t, cantonImpl.SetupSend(ctx, fee, transferPerSend)) + } receiver, err := evmChain.GetEOAReceiverAddress() require.NoError(t, err) @@ -274,10 +287,16 @@ func TestCanton2EVM_Basic(t *testing.T) { t.Run("token transfer with zero gas limit", func(t *testing.T) { subtestCtx := ccv.Plog.WithContext(t.Context()) - lane := devenvtests.ResolveTokenLane(t, in, lib, chainMap, cantonChain.ChainSelector(), []uint64{evmChain.ChainSelector()}) - tokenTransferAmount := lane.TransferAmount.Uint64() + lane := devenvtests.ResolveTokenLane(t, devenvtests.EnvDevenv, in, lib, chainMap, cantonChain.ChainSelector(), []uint64{evmChain.ChainSelector()}) - require.NoError(t, cantonImpl.SetupSend(ctx, uint64(devenvtests.CantonToEVMFeeAmount), tokenTransferAmount)) + fee := uint64(cantondevenv.CantonToEVMTokenTransferFeeAmount) + transferPerSend := new(big.Rat).SetFrac(lane.TransferAmount, big.NewInt(cantondevenv.CantonFixedPointScale)) + cantonImpl.SetSequentialSends(1) + if lane.TransferInstrument.Admin != "" { + require.NoError(t, cantonImpl.SetupSend(ctx, fee, transferPerSend, lane.TransferInstrument)) + } else { + require.NoError(t, cantonImpl.SetupSend(ctx, fee, transferPerSend)) + } ds, err := lib.DataStore() require.NoError(t, err) diff --git a/ccip/devenv/tests/e2e/canton2evm_send_validation_test.go b/ccip/devenv/tests/e2e/canton2evm_send_validation_test.go index 60d57ba07..62458a55c 100644 --- a/ccip/devenv/tests/e2e/canton2evm_send_validation_test.go +++ b/ccip/devenv/tests/e2e/canton2evm_send_validation_test.go @@ -59,8 +59,8 @@ func setupCanton2EVMSendFixtureWithHoldings(t *testing.T, withFeeHoldings bool) require.True(t, ok) if withFeeHoldings { - require.NoError(t, cantonImpl.MintTokens(ctx, uint64(devenvtests.CantonToEVMFeeAmount))) - require.NoError(t, cantonImpl.SetupSend(ctx, uint64(devenvtests.CantonToEVMFeeAmount), 0)) + require.NoError(t, cantonImpl.MintTokens(ctx, new(big.Rat).SetUint64(uint64(cantondevenv.CantonToEVMFeeAmount)))) + require.NoError(t, cantonImpl.SetupSend(ctx, uint64(cantondevenv.CantonToEVMFeeAmount), nil)) } ds, err := lib.DataStore() @@ -229,8 +229,8 @@ func TestCanton2EVM_SendValidation(t *testing.T) { t.Run("Unsupported token on lane", func(t *testing.T) { f := setupCanton2EVMSendFixture(t) - require.NoError(t, f.cantonImpl.MintTokens(f.ctx, 1000)) - require.NoError(t, f.cantonImpl.SetupSend(f.ctx, uint64(devenvtests.CantonToEVMFeeAmount), 1000)) + require.NoError(t, f.cantonImpl.MintTokens(f.ctx, big.NewRat(1000, 1))) + require.NoError(t, f.cantonImpl.SetupSend(f.ctx, uint64(cantondevenv.CantonToEVMFeeAmount), big.NewRat(1000, 1))) f.cantonImpl.SetTransferTokenInstrumentForTest(splice_api_token_holding_v1.InstrumentId{ Admin: types.PARTY("unsupported-token-admin"), Id: types.TEXT("UnsupportedToken"), @@ -252,8 +252,8 @@ func TestCanton2EVM_SendValidation(t *testing.T) { t.Run("Zero token amount", func(t *testing.T) { f := setupCanton2EVMSendFixture(t) - require.NoError(t, f.cantonImpl.MintTokens(f.ctx, 1000)) - require.NoError(t, f.cantonImpl.SetupSend(f.ctx, uint64(devenvtests.CantonToEVMFeeAmount), 1000)) + require.NoError(t, f.cantonImpl.MintTokens(f.ctx, big.NewRat(1000, 1))) + require.NoError(t, f.cantonImpl.SetupSend(f.ctx, uint64(cantondevenv.CantonToEVMFeeAmount), big.NewRat(1000, 1))) err := f.send( cciptestinterfaces.MessageFields{ diff --git a/ccip/devenv/tests/e2e/evm2canton_e2e_test.go b/ccip/devenv/tests/e2e/evm2canton_e2e_test.go index 69b45eb03..23af4ac8f 100644 --- a/ccip/devenv/tests/e2e/evm2canton_e2e_test.go +++ b/ccip/devenv/tests/e2e/evm2canton_e2e_test.go @@ -126,7 +126,7 @@ func TestEVM2Canton_Basic(t *testing.T) { subtestCtx := ccv.Plog.WithContext(t.Context()) // Send params (transfer amount, gas limit, finality) come from token_transfer_config.toml. - lane := devenvtests.ResolveTokenLane(t, in, lib, chainMap, srcSelector, []uint64{dstSelector}) + lane := devenvtests.ResolveTokenLane(t, devenvtests.EnvDevenv, in, lib, chainMap, srcSelector, []uint64{dstSelector}) srcToken := lane.SrcToken srcSender, err := srcChain.GetEOAReceiverAddress() require.NoError(t, err) diff --git a/ccip/devenv/tests/env.go b/ccip/devenv/tests/env.go new file mode 100644 index 000000000..f414d8076 --- /dev/null +++ b/ccip/devenv/tests/env.go @@ -0,0 +1,33 @@ +package tests + +import ( + "fmt" + "strings" +) + +// CCIPEnv names a CCIP e2e target environment. +type CCIPEnv string + +const ( + EnvDevenv CCIPEnv = "devenv" + EnvProdTestnet CCIPEnv = "prod-testnet" +) + +// ParseCCIPEnv validates and returns a CCIPEnv from its string form. +func ParseCCIPEnv(s string) (CCIPEnv, error) { + switch CCIPEnv(strings.TrimSpace(s)) { + case EnvDevenv, EnvProdTestnet: + return CCIPEnv(strings.TrimSpace(s)), nil + case "staging": + return "", fmt.Errorf("ccip env %q is reserved but not yet supported", s) + case "mainnet": + return "", fmt.Errorf("ccip env %q is reserved but not yet supported", s) + default: + return "", fmt.Errorf("unknown ccip env %q: want devenv or prod-testnet", s) + } +} + +// IsRemote reports whether the environment targets live testnet infrastructure. +func (e CCIPEnv) IsRemote() bool { + return e == EnvProdTestnet +} diff --git a/ccip/devenv/tests/load/gun_canton2evm_test.go b/ccip/devenv/tests/load/gun_canton2evm_test.go index 29060312c..ccf6998d8 100644 --- a/ccip/devenv/tests/load/gun_canton2evm_test.go +++ b/ccip/devenv/tests/load/gun_canton2evm_test.go @@ -2,6 +2,7 @@ package load import ( "fmt" + "math/big" "os" "testing" @@ -79,11 +80,11 @@ func TestCanton2EVM_Load(t *testing.T) { if estimatedMessages == 0 { estimatedMessages = 1 } - mintAmount := estimatedMessages * uint64(devenvtests.CantonToEVMFeeAmount) * mintBufferNumerator / mintBufferDenominator + mintAmount := estimatedMessages * uint64(cantondevenv.CantonToEVMFeeAmount) * mintBufferNumerator / mintBufferDenominator t.Logf("Pre-mint: estimatedMessages=%d feePerMessage=%d totalFeeMint=%d", - estimatedMessages, devenvtests.CantonToEVMFeeAmount, mintAmount) - require.NoError(t, cantonImpl.MintTokens(ctx, mintAmount)) - require.NoError(t, cantonImpl.SetupSend(ctx, uint64(devenvtests.CantonToEVMFeeAmount), 0)) + estimatedMessages, cantondevenv.CantonToEVMFeeAmount, mintAmount) + require.NoError(t, cantonImpl.MintTokens(ctx, new(big.Rat).SetUint64(mintAmount))) + require.NoError(t, cantonImpl.SetupSend(ctx, uint64(cantondevenv.CantonToEVMFeeAmount), nil)) gun, err := NewCCIPLoadGun( cantonChain, diff --git a/ccip/devenv/tests/load/gun_canton2evm_token_test.go b/ccip/devenv/tests/load/gun_canton2evm_token_test.go index 291426a30..2c69b6d72 100644 --- a/ccip/devenv/tests/load/gun_canton2evm_token_test.go +++ b/ccip/devenv/tests/load/gun_canton2evm_token_test.go @@ -50,7 +50,7 @@ func TestCanton2EVM_TokenLoad(t *testing.T) { evmSelectors := discoverEVMTokenSelectors(t, in) require.NotEmpty(t, evmSelectors, "need at least one EVM token destination in the env file") - lane := devenvtests.ResolveTokenLane(t, in, lib, chainMap, cantonChain.ChainSelector(), evmSelectors) + lane := devenvtests.ResolveTokenLane(t, devenvtests.EnvDevenv, in, lib, chainMap, cantonChain.ChainSelector(), evmSelectors) t.Logf("Token lane: pool=%s transfer=%s", lane.PoolRef.Qualifier, lane.TransferAmount.String()) destinations := discoverEVMTokenDestinations(t, in, chainMap, lane) @@ -91,7 +91,7 @@ func TestCanton2EVM_TokenLoad(t *testing.T) { require.NoError(t, err) require.NotNil(t, receiverBalanceAfter) - expectedPerMessage := new(big.Int).Mul(lane.TransferAmount, big.NewInt(devenvtests.EVMDecimalsScale)) + expectedPerMessage := new(big.Int).Mul(lane.TransferAmount, big.NewInt(cantondevenv.CantonFixedPointToEVMScale)) expectedDelta := new(big.Int).Mul(expectedPerMessage, big.NewInt(gun.CallCount())) expectedBalance := new(big.Int).Add(new(big.Int).Set(receiverBalanceBefore), expectedDelta) t.Logf("EVM receiver token balance: before=%s after=%s expectedDelta=%s calls=%d", diff --git a/ccip/devenv/tests/load/gun_evm2canton_token_test.go b/ccip/devenv/tests/load/gun_evm2canton_token_test.go index 4ad00269d..eb44409e6 100644 --- a/ccip/devenv/tests/load/gun_evm2canton_token_test.go +++ b/ccip/devenv/tests/load/gun_evm2canton_token_test.go @@ -52,7 +52,7 @@ func TestEVM2Canton_TokenLoad(t *testing.T) { require.True(t, ok, "Canton dest chain must be *devenv.Chain") require.NoError(t, cantonImpl.SetupReceive(ctx)) - lane := devenvtests.ResolveTokenLane(t, in, lib, chainMap, evmChain.ChainSelector(), []uint64{cantonChain.ChainSelector()}) + lane := devenvtests.ResolveTokenLane(t, devenvtests.EnvDevenv, in, lib, chainMap, evmChain.ChainSelector(), []uint64{cantonChain.ChainSelector()}) t.Logf("Token lane: pool=%s transfer=%s srcToken=%x", lane.PoolRef.Qualifier, lane.TransferAmount.String(), diff --git a/ccip/devenv/tests/load/load_helpers.go b/ccip/devenv/tests/load/load_helpers.go index 0ba7e3daa..10641fddf 100644 --- a/ccip/devenv/tests/load/load_helpers.go +++ b/ccip/devenv/tests/load/load_helpers.go @@ -3,6 +3,7 @@ package load import ( "context" "fmt" + "math" "math/big" "os" "strings" @@ -265,17 +266,22 @@ func setupCantonTokenLoadHoldings( t.Helper() estimated := estimateMessages(sched) - feeMint := estimated * uint64(devenvtests.CantonToEVMFeeAmount) - transferMint := estimated * lane.TransferAmount.Uint64() - t.Logf("Pre-mint: estimatedMessages=%d feeMint=%d transferMint=%d", - estimated, feeMint, transferMint) - require.NoError(t, cantonImpl.MintTokens(ctx, feeMint)) - require.NoError(t, cantonImpl.MintTokens(ctx, transferMint)) - require.NoError(t, cantonImpl.SetupSend( - ctx, - uint64(devenvtests.CantonToEVMFeeAmount), - lane.TransferAmount.Uint64(), - )) + fee := uint64(cantondevenv.CantonToEVMTokenTransferFeeAmount) + feeTotal := new(big.Rat).SetUint64(estimated * fee) + transferTotalFP := new(big.Int).Mul(lane.TransferAmount, new(big.Int).SetUint64(estimated)) + transferTotal := new(big.Rat).SetFrac(transferTotalFP, big.NewInt(cantondevenv.CantonFixedPointScale)) + transferPerSend := new(big.Rat).SetFrac(lane.TransferAmount, big.NewInt(cantondevenv.CantonFixedPointScale)) + t.Logf("Pre-mint: estimatedMessages=%d feeTotal=%s transferTotal=%s", + estimated, feeTotal.FloatString(10), transferTotal.FloatString(10)) + require.NoError(t, cantonImpl.MintTokens(ctx, feeTotal)) + require.NoError(t, cantonImpl.MintTokens(ctx, transferTotal)) + require.LessOrEqual(t, estimated, uint64(math.MaxInt)) + cantonImpl.SetSequentialSends(int(estimated)) //nolint:gosec // bounded by require above + if lane.TransferInstrument.Admin != "" { + require.NoError(t, cantonImpl.SetupSend(ctx, fee, transferPerSend, lane.TransferInstrument)) + } else { + require.NoError(t, cantonImpl.SetupSend(ctx, fee, transferPerSend)) + } } func evmLoadDestination(chain cciptestinterfaces.CCIP17, receiver protocol.UnknownAddress) Destination { diff --git a/ccip/devenv/tests/token_lane.go b/ccip/devenv/tests/token_lane.go index 6ca23d332..85a9f4e37 100644 --- a/ccip/devenv/tests/token_lane.go +++ b/ccip/devenv/tests/token_lane.go @@ -21,7 +21,10 @@ import ( "github.com/smartcontractkit/chainlink-ccv/protocol" "github.com/smartcontractkit/chainlink-deployments-framework/datastore" "github.com/smartcontractkit/chainlink-deployments-framework/deployment" + "github.com/smartcontractkit/go-daml/pkg/types" "github.com/stretchr/testify/require" + + splice_api_token_holding_v1 "github.com/smartcontractkit/chainlink-canton/bindings/generated/latest/splice/splice_api_token_holding_v1" ) const ( @@ -34,8 +37,12 @@ const ( // TokenLane describes a resolved token pool pairing for load or e2e tests. type TokenLane struct { PoolRef datastore.AddressRef - // TransferAmount is the per-message token transfer amount. + // TransferAmount is the per-message token transfer amount (wei for EVM→Canton, 10^10 fixed-point for Canton→EVM). TransferAmount *big.Int + // TransferInstrumentID is a TOML hint only (e.g. Amulet, LINK); prod resolution uses TransferInstrument. + TransferInstrumentID string + // TransferInstrument is the resolved Canton transfer instrument when Canton is the source. + TransferInstrument splice_api_token_holding_v1.InstrumentId // ExecutionGasLimit is the per-message execution gas limit. ExecutionGasLimit uint32 // FinalityConfig is the per-message finality configuration. @@ -49,23 +56,42 @@ type TokenLane struct { } type tokenConfigTOML struct { + Devenv tokenDirectionsTOML `toml:"devenv"` + ProdTestnet tokenDirectionsTOML `toml:"prod-testnet"` +} + +type tokenDirectionsTOML struct { EVMToCanton tokenDirectionTOML `toml:"evm_to_canton"` CantonToEVM tokenDirectionTOML `toml:"canton_to_evm"` } type tokenDirectionTOML struct { - PoolType string `toml:"pool_type"` - PoolVersion string `toml:"pool_version"` - PoolQualifier string `toml:"pool_qualifier"` - TransferAmount string `toml:"transfer_amount"` - ExecutionGasLimit uint32 `toml:"execution_gas_limit"` - FinalityConfig uint32 `toml:"finality_config"` + PoolType string `toml:"pool_type"` + PoolVersion string `toml:"pool_version"` + PoolQualifier string `toml:"pool_qualifier"` + TransferAmount string `toml:"transfer_amount"` + ExecutionGasLimit uint32 `toml:"execution_gas_limit"` + FinalityConfig uint32 `toml:"finality_config"` + RemotePoolType string `toml:"remote_pool_type"` + RemotePoolVersion string `toml:"remote_pool_version"` + RemotePoolQualifier string `toml:"remote_pool_qualifier"` + TransferInstrumentID string `toml:"transfer_instrument_id"` +} + +type tokenDirectionParsed struct { + PoolRef datastore.AddressRef + RemotePoolRef *datastore.AddressRef + TransferAmount *big.Int + TransferInstrumentID string + ExecutionGasLimit uint32 + FinalityConfig protocol.Finality } // ResolveTokenLane loads send params from TOML, matches the pool on the source chain, // and resolves source/destination token addresses for the requested destinations. func ResolveTokenLane( t *testing.T, + env CCIPEnv, in *ccv.Cfg, lib ccv.Lib, chainMap map[uint64]cciptestinterfaces.CCIP17, @@ -74,68 +100,64 @@ func ResolveTokenLane( ) TokenLane { t.Helper() - // Load the CLDF environment and datastore used to resolve on-chain addresses. - env, err := lib.CLDFEnvironment() + cldfEnv, err := lib.CLDFEnvironment() require.NoError(t, err) - require.NotNil(t, env) + require.NotNil(t, cldfEnv) srcChain, ok := chainMap[srcSelector] require.True(t, ok, "source chain %d not in harness chain map", srcSelector) - // Pick the TOML block from the source chain family (Canton<->EVM only). direction := directionEVMToCanton if isCantonSelector(srcSelector) { direction = directionCantonToEVM } - // Read pool identity and per-message send params from token_transfer_config.toml. - poolRef, transferAmount, gasLimit, finalityConfig := loadTokenDirection(t, direction) + dir := loadTokenDirection(t, env, direction) - // List token transfer configs deployed on the source chain for the requested destinations. srcProvider := tokenConfigProvider(srcChain) - cfgs, err := srcProvider.GetTokenTransferConfigs(env, srcSelector, destSelectors, in.EnvironmentTopology) + cfgs, err := srcProvider.GetTokenTransferConfigs(cldfEnv, srcSelector, destSelectors, in.EnvironmentTopology) require.NoError(t, err, "get token transfer configs for source chain %d", srcSelector) - // Require exactly one config whose pool ref matches the TOML declaration. - cfg := selectTokenConfig(t, cfgs, poolRef, srcSelector) + cfg, matched := trySelectTokenConfig(cfgs, dir.PoolRef) + if !matched { + t.Fatalf("no token transfer config on chain %d matches pool %s (have %s)", + srcSelector, poolRefString(dir.PoolRef), poolRefsString(cfgs)) + } - // Fail fast if any requested destination is missing from that lane's RemoteChains. for _, sel := range destSelectors { if _, present := cfg.RemoteChains[sel]; !present { t.Fatalf("destination %d not configured for pool %s on chain %d (have %v)", - sel, poolRefString(poolRef), srcSelector, sortedRemoteSelectors(cfg)) + sel, poolRefString(dir.PoolRef), srcSelector, sortedRemoteSelectors(cfg)) } } - // Assemble the lane with TOML send params; token addresses are filled in below. lane := TokenLane{ - PoolRef: poolRef, - TransferAmount: transferAmount, - ExecutionGasLimit: gasLimit, - FinalityConfig: finalityConfig, - DestTokenBySelector: make(map[uint64]protocol.UnknownAddress, len(destSelectors)), + PoolRef: dir.PoolRef, + TransferAmount: dir.TransferAmount, + TransferInstrumentID: dir.TransferInstrumentID, + ExecutionGasLimit: dir.ExecutionGasLimit, + FinalityConfig: dir.FinalityConfig, + DestTokenBySelector: make(map[uint64]protocol.UnknownAddress, len(destSelectors)), } - // EVM source: resolve the ERC-20 from the matched config's TokenRef in the datastore. - // Canton source: instrument is chosen in SetupSend, so SrcToken stays empty. if !isCantonSelector(srcSelector) { - srcToken, err := resolveTokenRef(env.DataStore, srcSelector, cfg.TokenRef) + srcToken, err := resolveTokenRef(cldfEnv.DataStore, srcSelector, cfg.TokenRef) require.NoError(t, err, "resolve source token on chain %d", srcSelector) lane.SrcToken = srcToken + } else { + lane.TransferInstrument = resolveCantonTransferInstrument(t, cldfEnv.DataStore, srcSelector, cfg.TokenRef, dir.PoolRef, dir.TransferInstrumentID) } - // EVM destinations: look up each dest chain's config for the remote pool and resolve TokenRef. - // Canton destinations are skipped (no EVM-style token address for balance checks). for _, sel := range destSelectors { if isCantonSelector(sel) { continue } - lane.DestTokenBySelector[sel] = resolveDestToken(t, env, in, chainMap, srcSelector, sel, cfg.RemoteChains[sel], poolRef) + lane.DestTokenBySelector[sel] = resolveDestToken(t, cldfEnv, in, chainMap, srcSelector, sel, cfg.RemoteChains[sel], dir.PoolRef) } return lane } -func loadTokenDirection(t *testing.T, direction string) (poolRef datastore.AddressRef, transferAmount *big.Int, gasLimit uint32, finalityConfig protocol.Finality) { +func loadTokenDirection(t *testing.T, env CCIPEnv, direction string) tokenDirectionParsed { t.Helper() path := os.Getenv(envTokenTestConfig) @@ -147,40 +169,90 @@ func loadTokenDirection(t *testing.T, direction string) (poolRef datastore.Addre _, err := toml.DecodeFile(path, &cfg) require.NoError(t, err, "decode token transfer config %q (set %s to override)", path, envTokenTestConfig) + dirs, err := tokenDirectionsForEnv(cfg, env) + require.NoError(t, err, "%s: no token transfer config for env %q", path, env) + var dir tokenDirectionTOML switch direction { case directionEVMToCanton: - dir = cfg.EVMToCanton + dir = dirs.EVMToCanton case directionCantonToEVM: - dir = cfg.CantonToEVM + dir = dirs.CantonToEVM default: t.Fatalf("unknown token transfer direction %q (expected %q or %q)", direction, directionEVMToCanton, directionCantonToEVM) } - require.NotEmpty(t, dir.PoolType, "%s: pool_type is required for direction %q", path, direction) - require.NotEmpty(t, dir.PoolVersion, "%s: pool_version is required for direction %q", path, direction) - require.NotEmpty(t, dir.PoolQualifier, "%s: pool_qualifier is required for direction %q", path, direction) + require.NotEmpty(t, dir.PoolType, "%s: pool_type is required for env %q direction %q", path, env, direction) + require.NotEmpty(t, dir.PoolVersion, "%s: pool_version is required for env %q direction %q", path, env, direction) + require.NotEmpty(t, dir.PoolQualifier, "%s: pool_qualifier is required for env %q direction %q", path, env, direction) version, err := semver.NewVersion(dir.PoolVersion) - require.NoError(t, err, "%s: invalid pool_version %q for direction %q", path, dir.PoolVersion, direction) + require.NoError(t, err, "%s: invalid pool_version %q for env %q direction %q", path, dir.PoolVersion, env, direction) + + amountStr := strings.TrimSpace(dir.TransferAmount) + intAmount, ok := new(big.Int).SetString(amountStr, 10) + switch direction { + case directionEVMToCanton: + require.True(t, ok && intAmount.Sign() > 0, "%s: transfer_amount %q must be a positive integer wei for env %q direction %q", path, dir.TransferAmount, env, direction) + case directionCantonToEVM: + require.True(t, ok && intAmount.Sign() > 0, "%s: transfer_amount %q must be a positive integer fixed-point (10^10 scale) for env %q direction %q", path, dir.TransferAmount, env, direction) + default: + t.Fatalf("unknown token transfer direction %q", direction) + } + + require.NotZero(t, dir.ExecutionGasLimit, "%s: execution_gas_limit is required for env %q direction %q", path, env, direction) + + parsed := tokenDirectionParsed{ + PoolRef: datastore.AddressRef{ + Type: datastore.ContractType(dir.PoolType), + Version: version, + Qualifier: dir.PoolQualifier, + }, + TransferAmount: intAmount, + TransferInstrumentID: strings.TrimSpace(dir.TransferInstrumentID), + ExecutionGasLimit: dir.ExecutionGasLimit, + FinalityConfig: protocol.Finality(dir.FinalityConfig), + } - amount, ok := new(big.Int).SetString(strings.TrimSpace(dir.TransferAmount), 10) - require.True(t, ok && amount.Sign() > 0, "%s: transfer_amount %q must be a positive integer for direction %q", path, dir.TransferAmount, direction) + if dir.RemotePoolType != "" || dir.RemotePoolVersion != "" || dir.RemotePoolQualifier != "" { + require.NotEmpty(t, dir.RemotePoolType, "%s: remote_pool_type is required when remote pool fields are set (env %q direction %q)", path, env, direction) + require.NotEmpty(t, dir.RemotePoolVersion, "%s: remote_pool_version is required when remote pool fields are set (env %q direction %q)", path, env, direction) + require.NotEmpty(t, dir.RemotePoolQualifier, "%s: remote_pool_qualifier is required when remote pool fields are set (env %q direction %q)", path, env, direction) - require.NotZero(t, dir.ExecutionGasLimit, "%s: execution_gas_limit is required for direction %q", path, direction) + remoteVersion, err := semver.NewVersion(dir.RemotePoolVersion) + require.NoError(t, err, "%s: invalid remote_pool_version %q for env %q direction %q", path, dir.RemotePoolVersion, env, direction) - poolRef = datastore.AddressRef{ - Type: datastore.ContractType(dir.PoolType), - Version: version, - Qualifier: dir.PoolQualifier, + remoteRef := datastore.AddressRef{ + Type: datastore.ContractType(dir.RemotePoolType), + Version: remoteVersion, + Qualifier: dir.RemotePoolQualifier, + } + parsed.RemotePoolRef = &remoteRef } - return poolRef, amount, dir.ExecutionGasLimit, protocol.Finality(dir.FinalityConfig) + return parsed } -func selectTokenConfig(t *testing.T, cfgs []tokenscore.TokenTransferConfig, poolRef datastore.AddressRef, srcSelector uint64) tokenscore.TokenTransferConfig { - t.Helper() +func tokenDirectionsForEnv(cfg tokenConfigTOML, env CCIPEnv) (tokenDirectionsTOML, error) { + switch env { + case EnvDevenv: + if cfg.Devenv.EVMToCanton.PoolType == "" && cfg.Devenv.CantonToEVM.PoolType == "" { + return tokenDirectionsTOML{}, fmt.Errorf("missing [devenv.*] sections") + } + return cfg.Devenv, nil + case EnvProdTestnet: + if cfg.ProdTestnet.EVMToCanton.PoolType == "" && cfg.ProdTestnet.CantonToEVM.PoolType == "" { + return tokenDirectionsTOML{}, fmt.Errorf("missing [prod-testnet.*] sections") + } + + return cfg.ProdTestnet, nil + default: + return tokenDirectionsTOML{}, fmt.Errorf("unsupported ccip env %q", env) + } +} + +func trySelectTokenConfig(cfgs []tokenscore.TokenTransferConfig, poolRef datastore.AddressRef) (tokenscore.TokenTransferConfig, bool) { matches := make([]tokenscore.TokenTransferConfig, 0, 1) for _, cfg := range cfgs { if poolRefEqual(cfg.TokenPoolRef, poolRef) { @@ -189,16 +261,10 @@ func selectTokenConfig(t *testing.T, cfgs []tokenscore.TokenTransferConfig, pool } switch len(matches) { case 1: - return matches[0] - case 0: - t.Fatalf("no token transfer config on chain %d matches pool %s (have %s)", - srcSelector, poolRefString(poolRef), poolRefsString(cfgs)) + return matches[0], true default: - t.Fatalf("pool %s matched %d configs on chain %d (expected one): %s", - poolRefString(poolRef), len(matches), srcSelector, poolRefsString(matches)) + return tokenscore.TokenTransferConfig{}, false } - - return tokenscore.TokenTransferConfig{} } func resolveDestToken( @@ -243,6 +309,102 @@ func tokenConfigProvider(chain cciptestinterfaces.CCIP17) cciptestinterfaces.Tok return evm.NewEmptyCCIP17EVM() } +func resolveCantonTransferInstrument( + t *testing.T, + ds datastore.DataStore, + chainSelector uint64, + tokenRef datastore.AddressRef, + poolRef datastore.AddressRef, + transferInstrumentIDHint string, +) splice_api_token_holding_v1.InstrumentId { + t.Helper() + + addrRef, err := ds.Addresses().Get(datastore.NewAddressRefKey(chainSelector, tokenRef.Type, tokenRef.Version, tokenRef.Qualifier)) + require.NoError(t, err, "resolve Canton token ref %s on chain %d", poolRefString(tokenRef), chainSelector) + + poolAddrRef, err := ds.Addresses().Get(datastore.NewAddressRefKey(chainSelector, poolRef.Type, poolRef.Version, poolRef.Qualifier)) + require.NoError(t, err, "resolve Canton pool ref %s on chain %d", poolRefString(poolRef), chainSelector) + + instrument, err := resolveInstrumentFromTokenRefWithFallback(addrRef, poolAddrRef, transferInstrumentIDHint) + require.NoError(t, err, "parse instrument from token ref %s on chain %d", poolRefString(tokenRef), chainSelector) + + return instrument +} + +func resolveInstrumentFromTokenRefWithFallback( + tokenRef datastore.AddressRef, + poolRef datastore.AddressRef, + transferInstrumentIDHint string, +) (splice_api_token_holding_v1.InstrumentId, error) { + if instrument, err := parseInstrumentIDFromTokenRefLabels(tokenRef); err == nil { + return instrument, nil + } + + ccipOwner := ccipOwnerFromRefLabels(poolRef) + if ccipOwner == "" { + return splice_api_token_holding_v1.InstrumentId{}, fmt.Errorf( + "tokenRef labels must include instrument-admin: and instrument-id:", + ) + } + + instrumentID := normalizeTransferInstrumentID(transferInstrumentIDHint) + if instrumentID == "" { + return splice_api_token_holding_v1.InstrumentId{}, fmt.Errorf( + "transfer_instrument_id is required when token ref labels are missing", + ) + } + + return splice_api_token_holding_v1.InstrumentId{ + Admin: types.PARTY(ccipOwner), + Id: types.TEXT(instrumentID), + }, nil +} + +func parseInstrumentIDFromTokenRefLabels(tokenRef datastore.AddressRef) (splice_api_token_holding_v1.InstrumentId, error) { + var instrumentAdmin, instrumentIDText string + for _, label := range tokenRef.Labels.List() { + switch { + case strings.HasPrefix(label, "instrument-admin:"): + instrumentAdmin = strings.TrimSpace(strings.TrimPrefix(label, "instrument-admin:")) + case strings.HasPrefix(label, "instrument-id:"): + instrumentIDText = strings.TrimSpace(strings.TrimPrefix(label, "instrument-id:")) + } + } + if instrumentAdmin == "" || instrumentIDText == "" { + return splice_api_token_holding_v1.InstrumentId{}, fmt.Errorf( + "tokenRef labels must include instrument-admin: and instrument-id:", + ) + } + + return splice_api_token_holding_v1.InstrumentId{ + Admin: types.PARTY(instrumentAdmin), + Id: types.TEXT(instrumentIDText), + }, nil +} + +func ccipOwnerFromRefLabels(ref datastore.AddressRef) string { + for _, label := range ref.Labels.List() { + if party, ok := strings.CutPrefix(label, "ccip-owner:"); ok { + return strings.TrimSpace(party) + } + if idx := strings.LastIndex(label, "@ccipOwner::"); idx >= 0 { + return strings.TrimSpace(label[idx+1:]) + } + } + + return "" +} + +func normalizeTransferInstrumentID(hint string) string { + hint = strings.TrimSpace(hint) + switch strings.ToLower(hint) { + case "link": + return "link-token" + default: + return hint + } +} + func resolveTokenRef(ds datastore.DataStore, chainSelector uint64, ref datastore.AddressRef) (protocol.UnknownAddress, error) { addrRef, err := ds.Addresses().Get(datastore.NewAddressRefKey(chainSelector, ref.Type, ref.Version, ref.Qualifier)) if err != nil { @@ -280,7 +442,6 @@ func defaultTokenConfigPath() string { return filepath.Join(filepath.Dir(file), defaultTokenConfigFile) } -// Helpers for logging // func poolRefsString(cfgs []tokenscore.TokenTransferConfig) string { if len(cfgs) == 0 { return "none" diff --git a/ccip/devenv/tests/token_lane_test.go b/ccip/devenv/tests/token_lane_test.go new file mode 100644 index 000000000..07fe0c8df --- /dev/null +++ b/ccip/devenv/tests/token_lane_test.go @@ -0,0 +1,185 @@ +package tests + +import ( + "math/big" + "os" + "path/filepath" + "testing" + + "github.com/BurntSushi/toml" + "github.com/smartcontractkit/chainlink-deployments-framework/datastore" + "github.com/stretchr/testify/require" + + "github.com/smartcontractkit/chainlink-canton/ccip/devenv" +) + +func TestTokenDirectionsForEnv(t *testing.T) { + t.Parallel() + + cfg, err := decodeTokenConfigTOML(defaultTokenConfigPath()) + require.NoError(t, err) + + devenvDirs, err := tokenDirectionsForEnv(cfg, EnvDevenv) + require.NoError(t, err) + require.Equal(t, "BurnMintTokenPool", devenvDirs.EVMToCanton.PoolType) + require.Contains(t, devenvDirs.EVMToCanton.PoolQualifier, "BurnMintTokenPool 2.0.0 [default]") + require.Equal(t, "LockReleaseTokenPool", devenvDirs.CantonToEVM.PoolType) + + prodDirs, err := tokenDirectionsForEnv(cfg, EnvProdTestnet) + require.NoError(t, err) + require.Equal(t, "TEST", prodDirs.EVMToCanton.PoolQualifier) + require.Equal(t, "LINK", prodDirs.EVMToCanton.RemotePoolQualifier) + require.Equal(t, "LINK", prodDirs.CantonToEVM.PoolQualifier) + require.Equal(t, "100", prodDirs.CantonToEVM.TransferAmount) + require.Equal(t, "link-token", prodDirs.CantonToEVM.TransferInstrumentID) +} + +func TestLoadTokenDirection_envSelection(t *testing.T) { + t.Setenv(envTokenTestConfig, defaultTokenConfigPath()) + + devenvDir := loadTokenDirection(t, EnvDevenv, directionEVMToCanton) + require.Equal(t, "BurnMintTokenPool", string(devenvDir.PoolRef.Type)) + require.Contains(t, devenvDir.PoolRef.Qualifier, "BurnMintTokenPool 2.0.0 [default]") + require.Nil(t, devenvDir.RemotePoolRef) + + prodDir := loadTokenDirection(t, EnvProdTestnet, directionEVMToCanton) + require.Equal(t, "TEST", prodDir.PoolRef.Qualifier) + require.NotNil(t, prodDir.RemotePoolRef) + require.Equal(t, "LINK", prodDir.RemotePoolRef.Qualifier) +} + +func TestLoadTokenDirection_transferAmountParsing(t *testing.T) { + t.Setenv(envTokenTestConfig, defaultTokenConfigPath()) + + evmDir := loadTokenDirection(t, EnvDevenv, directionEVMToCanton) + require.Equal(t, "100000000001", evmDir.TransferAmount.String()) + + cantonDir := loadTokenDirection(t, EnvDevenv, directionCantonToEVM) + require.Equal(t, big.NewInt(10000000001), cantonDir.TransferAmount) + + prodCantonDir := loadTokenDirection(t, EnvProdTestnet, directionCantonToEVM) + require.Equal(t, big.NewInt(100), prodCantonDir.TransferAmount) + require.Equal(t, "link-token", prodCantonDir.TransferInstrumentID) + + expectedWei := new(big.Int).Mul(prodCantonDir.TransferAmount, big.NewInt(devenv.CantonFixedPointToEVMScale)) + require.Equal(t, "10000000000", expectedWei.String()) +} + +func TestLoadTokenDirection_missingEnvSection(t *testing.T) { + path := filepath.Join(t.TempDir(), "token_transfer_config.toml") + require.NoError(t, os.WriteFile(path, []byte(` +[devenv.evm_to_canton] +pool_type = "BurnMintTokenPool" +pool_version = "2.0.0" +pool_qualifier = "TEST" +transfer_amount = "1" +execution_gas_limit = 1 +finality_config = 0 +`), 0o600)) + + t.Setenv(envTokenTestConfig, path) + + _, err := tokenDirectionsForEnv(mustDecodeTokenConfig(t, path), EnvProdTestnet) + require.Error(t, err) + require.Contains(t, err.Error(), "prod-testnet") +} + +func mustDecodeTokenConfig(t *testing.T, path string) tokenConfigTOML { + t.Helper() + + cfg, err := decodeTokenConfigTOML(path) + require.NoError(t, err) + + return cfg +} + +func decodeTokenConfigTOML(path string) (tokenConfigTOML, error) { + var cfg tokenConfigTOML + _, err := toml.DecodeFile(path, &cfg) + + return cfg, err +} + +func TestParseInstrumentIDFromTokenRefLabels(t *testing.T) { + t.Parallel() + + ccipOwner := "ccipOwner::1220e382f4e57b0815e6be737006e381e6b7de448e06bd033ece6df498017879f551" + tokenRef := datastore.AddressRef{ + Labels: datastore.NewLabelSet( + "instrument-admin:"+ccipOwner, + "instrument-id:link-token", + ), + } + + instrument, err := parseInstrumentIDFromTokenRefLabels(tokenRef) + require.NoError(t, err) + require.Equal(t, ccipOwner, string(instrument.Admin)) + require.Equal(t, "link-token", string(instrument.Id)) + + _, err = parseInstrumentIDFromTokenRefLabels(datastore.AddressRef{}) + require.Error(t, err) + require.Contains(t, err.Error(), "instrument-admin") +} + +func TestNormalizeTransferInstrumentID(t *testing.T) { + t.Parallel() + + require.Equal(t, "link-token", normalizeTransferInstrumentID("LINK")) + require.Equal(t, "link-token", normalizeTransferInstrumentID("link")) + require.Equal(t, "link-token", normalizeTransferInstrumentID(" link ")) + require.Equal(t, "Amulet", normalizeTransferInstrumentID("Amulet")) + require.Equal(t, "link-token", normalizeTransferInstrumentID("link-token")) +} + +func TestCCIPOwnerFromRefLabels(t *testing.T) { + t.Parallel() + + ccipOwner := "ccipOwner::1220e382f4e57b0815e6be737006e381e6b7de448e06bd033ece6df498017879f551" + + fromPrefix := datastore.AddressRef{ + Labels: datastore.NewLabelSet("ccip-owner:" + ccipOwner), + } + require.Equal(t, ccipOwner, ccipOwnerFromRefLabels(fromPrefix)) + + fromSuffix := datastore.AddressRef{ + Labels: datastore.NewLabelSet("burnminttokenpool-LINK@" + ccipOwner), + } + require.Equal(t, ccipOwner, ccipOwnerFromRefLabels(fromSuffix)) + + require.Empty(t, ccipOwnerFromRefLabels(datastore.AddressRef{})) +} + +func TestResolveInstrumentFromTokenRefWithFallback(t *testing.T) { + t.Parallel() + + ccipOwner := "ccipOwner::1220e382f4e57b0815e6be737006e381e6b7de448e06bd033ece6df498017879f551" + + tokenWithLabels := datastore.AddressRef{ + Labels: datastore.NewLabelSet( + "instrument-admin:"+ccipOwner, + "instrument-id:link-token", + ), + } + poolRef := datastore.AddressRef{ + Labels: datastore.NewLabelSet("burnminttokenpool-LINK@" + ccipOwner), + } + + instrument, err := resolveInstrumentFromTokenRefWithFallback(tokenWithLabels, poolRef, "LINK") + require.NoError(t, err) + require.Equal(t, ccipOwner, string(instrument.Admin)) + require.Equal(t, "link-token", string(instrument.Id)) + + tokenWithoutLabels := datastore.AddressRef{} + instrument, err = resolveInstrumentFromTokenRefWithFallback(tokenWithoutLabels, poolRef, "LINK") + require.NoError(t, err) + require.Equal(t, ccipOwner, string(instrument.Admin)) + require.Equal(t, "link-token", string(instrument.Id)) + + _, err = resolveInstrumentFromTokenRefWithFallback(tokenWithoutLabels, datastore.AddressRef{}, "LINK") + require.Error(t, err) + require.Contains(t, err.Error(), "instrument-admin") + + _, err = resolveInstrumentFromTokenRefWithFallback(tokenWithoutLabels, poolRef, "") + require.Error(t, err) + require.Contains(t, err.Error(), "transfer_instrument_id") +} diff --git a/ccip/devenv/tests/token_transfer_config.toml b/ccip/devenv/tests/token_transfer_config.toml index c3bbfe7ee..31f5691f5 100644 --- a/ccip/devenv/tests/token_transfer_config.toml +++ b/ccip/devenv/tests/token_transfer_config.toml @@ -1,24 +1,49 @@ -# Token lane inputs for devenv e2e/load tests. +# Token lane inputs for CCIP e2e/load tests. # +# Env selection uses CCIP_ENV (prod-testnet) or defaults to devenv in tests. # Token identity (pool_type / pool_version / pool_qualifier) is REQUIRED and is # matched against the source chain's GetTokenTransferConfigs to discover the lane. # Numeric send params (transfer_amount / execution_gas_limit / finality_config) # are optional; when omitted, code-level per-direction defaults are used. # -# Override the file path with the CANTON_TOKEN_TEST_CONFIG env var. +# Override the entire file path with CANTON_TOKEN_TEST_CONFIG. -[evm_to_canton] -pool_type = "BurnMintTokenPool" -pool_version = "2.0.0" +[devenv.evm_to_canton] +pool_type = "BurnMintTokenPool" +pool_version = "2.0.0" pool_qualifier = "TEST (BurnMintTokenPool 2.0.0 [default], LockReleaseTokenPool 2.0.0 [default])::BurnMintTokenPool 2.0.0 [default]" -transfer_amount = "100000000000" -execution_gas_limit = 200000 -finality_config = 0 +transfer_amount = "100000000001" +execution_gas_limit = 200001 +# Per-message FTF (1-block depth); requires Canton pool remoteChainCfg BlockDepth >= 1 (see impl.go). +finality_config = 1 -[canton_to_evm] -pool_type = "LockReleaseTokenPool" -pool_version = "2.0.0" +[devenv.canton_to_evm] +pool_type = "LockReleaseTokenPool" +pool_version = "2.0.0" pool_qualifier = "TEST (BurnMintTokenPool 2.0.0 [default], LockReleaseTokenPool 2.0.0 [default])::LockReleaseTokenPool 2.0.0 [default]" -transfer_amount = "1000" +transfer_amount = "10000000001" execution_gas_limit = 500000 -finality_config = 1 +finality_config = 1 + +[prod-testnet.evm_to_canton] +pool_type = "BurnMintTokenPool" +pool_version = "2.0.0" +pool_qualifier = "TEST" +transfer_amount = "1000000000000001" +execution_gas_limit = 200001 +finality_config = 1 +remote_pool_type = "BurnMintTokenPool" +remote_pool_version = "2.0.0" +remote_pool_qualifier = "LINK" + +[prod-testnet.canton_to_evm] +pool_type = "BurnMintTokenPool" +pool_version = "2.0.0" +pool_qualifier = "LINK" +transfer_amount = "100" +execution_gas_limit = 500000 +finality_config = 1 +remote_pool_type = "BurnMintTokenPool" +remote_pool_version = "2.0.0" +remote_pool_qualifier = "TEST" +transfer_instrument_id = "link-token" diff --git a/deployment/sequences/token_pools.go b/deployment/sequences/token_pools.go index 582916259..be082f958 100644 --- a/deployment/sequences/token_pools.go +++ b/deployment/sequences/token_pools.go @@ -592,12 +592,12 @@ var DeployTokenPoolForToken = operations.NewSequence( return ccipsequences.OnChainOutput{}, fmt.Errorf("tokenRef.address is required") } tokenRef := datastore.AddressRef{ - Address: tokenAddress, - Type: datastore.ContractType("Token"), - // TODO: what should this be set to? + Address: tokenAddress, + Type: datastore.ContractType("Token"), Version: input.TokenPoolVersion, Qualifier: qualifier, ChainSelector: input.ChainSelector, + Labels: input.TokenRef.Labels, } if mcmsEnabled && len(proposalOutputs) > 0 {