diff --git a/Makefile b/Makefile index db4a766cf..1719a2d96 100644 --- a/Makefile +++ b/Makefile @@ -98,7 +98,7 @@ start-devenv: build-ccv-images build-committeeverifier build-eds .PHONY: run-e2e-tests run-e2e-tests: - cd ccip/devenv/tests/e2e && go test -timeout 5m -v -count 1 -run TestEVM2Canton_Basic && go test -timeout 5m -v -count 1 -run TestCanton2EVM_Basic + cd ccip/devenv/tests/e2e && go test -timeout 5m -v -count 1 -run TestEVM2Canton_Basic && go test -timeout 5m -v -count 1 -run 'TestCanton2EVM_(Basic|SendValidation)' .PHONY: run-canton2evm-load run-canton2evm-load: ## Canton→EVM WASP load (requires running devenv + env-canton-evm-out.toml). diff --git a/ccip/devenv/fee_quoter_limits.go b/ccip/devenv/fee_quoter_limits.go new file mode 100644 index 000000000..4f96b1128 --- /dev/null +++ b/ccip/devenv/fee_quoter_limits.go @@ -0,0 +1,124 @@ +package devenv + +import ( + "context" + "fmt" + "math" + "strconv" + "strings" + + apiv2 "github.com/digital-asset/dazl-client/v8/go/api/com/daml/ledger/api/v2" + "github.com/smartcontractkit/chainlink-deployments-framework/datastore" + "github.com/smartcontractkit/go-daml/pkg/service/ledger" + + "github.com/smartcontractkit/chainlink-canton/bindings/generated/latest/ccip/core" + "github.com/smartcontractkit/chainlink-canton/contracts" + feequoterop "github.com/smartcontractkit/chainlink-canton/deployment/operations/ccip/fee_quoter" + "github.com/smartcontractkit/chainlink-canton/deployment/utils/operations/contract" +) + +// GetMaxDataBytes implements cciptestinterfaces.CCIP17. +func (c *Chain) GetMaxDataBytes(ctx context.Context, remoteChainSelector uint64) (uint32, error) { + cfg, err := c.feeQuoterDestConfig(ctx, remoteChainSelector) + if err != nil { + return 0, err + } + if cfg.MaxDataBytes < 0 { + return 0, fmt.Errorf("negative maxDataBytes: %d", cfg.MaxDataBytes) + } + if cfg.MaxDataBytes > math.MaxUint32 { + return 0, fmt.Errorf("maxDataBytes overflows uint32: %d", cfg.MaxDataBytes) + } + + return uint32(cfg.MaxDataBytes), nil +} + +// GetMaxPerMsgGasLimit returns the FeeQuoter maxPerMsgGasLimit for a destination chain. +func (c *Chain) GetMaxPerMsgGasLimit(ctx context.Context, remoteChainSelector uint64) (uint32, error) { + cfg, err := c.feeQuoterDestConfig(ctx, remoteChainSelector) + if err != nil { + return 0, err + } + if cfg.MaxPerMsgGasLimit < 0 { + return 0, fmt.Errorf("negative maxPerMsgGasLimit: %d", cfg.MaxPerMsgGasLimit) + } + if cfg.MaxPerMsgGasLimit > math.MaxUint32 { + return 0, fmt.Errorf("maxPerMsgGasLimit overflows uint32: %d", cfg.MaxPerMsgGasLimit) + } + + return uint32(cfg.MaxPerMsgGasLimit), nil +} + +func (c *Chain) feeQuoterDestConfig(ctx context.Context, remoteChainSelector uint64) (core.FeeQuoterDestChainConfig, error) { + if c.e == nil { + return core.FeeQuoterDestChainConfig{}, fmt.Errorf("canton chain environment is nil") + } + if len(c.chain.Participants) == 0 { + return core.FeeQuoterDestChainConfig{}, fmt.Errorf("no canton participants configured") + } + + feeQuoterRef, err := c.e.DataStore.Addresses().Get(datastore.NewAddressRefKey( + c.ChainSelector(), + datastore.ContractType(feequoterop.ContractType), + feequoterop.Version, + "", + )) + if err != nil { + return core.FeeQuoterDestChainConfig{}, fmt.Errorf("resolve FeeQuoter address: %w", err) + } + + participant := c.chain.Participants[0] + party := participant.PartyID + feeQuoterAddress := contracts.HexToInstanceAddress(feeQuoterRef.Address) + + activeContract, err := contract.FindActiveContractByInstanceAddress( + ctx, + participant.LedgerServices.State, + []string{party}, + core.FeeQuoter{}.GetTemplateID(), + feeQuoterAddress, + ) + if err != nil { + return core.FeeQuoterDestChainConfig{}, fmt.Errorf("find active FeeQuoter contract: %w", err) + } + + createArgs := activeContract.GetCreatedEvent().GetCreateArguments() + if createArgs == nil { + return core.FeeQuoterDestChainConfig{}, fmt.Errorf("FeeQuoter create arguments missing") + } + + return destChainConfigFromFeeQuoterCreateArgs(createArgs, remoteChainSelector) +} + +func destChainConfigFromFeeQuoterCreateArgs(createArgs *apiv2.Record, remoteChainSelector uint64) (core.FeeQuoterDestChainConfig, error) { + selectorKey := strconv.FormatUint(remoteChainSelector, 10) + for _, field := range createArgs.GetFields() { + if field.GetLabel() != "destChainConfigs" { + continue + } + genMap := field.GetValue().GetGenMap() + if genMap == nil { + return core.FeeQuoterDestChainConfig{}, fmt.Errorf("destChainConfigs is not a GenMap") + } + for _, entry := range genMap.GetEntries() { + key := strings.TrimSuffix(entry.GetKey().GetNumeric(), ".") + if key != selectorKey { + continue + } + record := entry.GetValue().GetRecord() + if record == nil { + return core.FeeQuoterDestChainConfig{}, fmt.Errorf("dest chain config value is not a record") + } + var cfg core.FeeQuoterDestChainConfig + if err := ledger.RecordToStruct(record, &cfg); err != nil { + return core.FeeQuoterDestChainConfig{}, fmt.Errorf("parse dest chain config record: %w", err) + } + + return cfg, nil + } + + return core.FeeQuoterDestChainConfig{}, fmt.Errorf("no FeeQuoter dest config for chain selector %d", remoteChainSelector) + } + + return core.FeeQuoterDestChainConfig{}, fmt.Errorf("destChainConfigs field not found on FeeQuoter") +} diff --git a/ccip/devenv/impl.go b/ccip/devenv/impl.go index ce977c537..612b1abcf 100644 --- a/ccip/devenv/impl.go +++ b/ccip/devenv/impl.go @@ -848,11 +848,6 @@ func (c *Chain) GetExpectedNextSequenceNumber(ctx context.Context, to uint64) (u return c.nextSeq + 1, nil } -// GetMaxDataBytes implements cciptestinterfaces.CCIP17. -func (c *Chain) GetMaxDataBytes(ctx context.Context, remoteChainSelector uint64) (uint32, error) { - return 0, nil // TODO: implement -} - // GetSenderAddress implements cciptestinterfaces.CCIP17. func (c *Chain) GetSenderAddress() (protocol.UnknownAddress, error) { return protocol.UnknownAddress{}, nil // TODO: implement @@ -1165,6 +1160,10 @@ func (c *Chain) SendMessage(ctx context.Context, dest uint64, fields cciptestint ) } + if c.transferTokenInstrument != nil && fields.TokenAmount.Amount != nil && fields.TokenAmount.Amount.Sign() == 0 { + 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 participant, clientIdx, err := c.ClientParticipant() @@ -1191,6 +1190,19 @@ func (c *Chain) SendMessage(ctx context.Context, dest uint64, fields cciptestint } } + maxDataBytes, err := c.GetMaxDataBytes(ctx, dest) + if err != nil { + return cciptestinterfaces.MessageSentEvent{}, fmt.Errorf( + "canton SendMessage: resolve max data bytes for destination %d: %w", dest, err, + ) + } + if len(fields.Data) > int(maxDataBytes) { + return cciptestinterfaces.MessageSentEvent{}, fmt.Errorf( + "canton SendMessage: payload exceeds destination maxDataBytes (%d > %d)", + len(fields.Data), maxDataBytes, + ) + } + sendLog := c.logger.Info().Str("NextFeeCID", c.nextFeeCID) if hasTokenTransfer { sendLog = sendLog.Str("NextTransferCID", c.nextTransferCID) diff --git a/ccip/devenv/test_hooks.go b/ccip/devenv/test_hooks.go new file mode 100644 index 000000000..b95089dd9 --- /dev/null +++ b/ccip/devenv/test_hooks.go @@ -0,0 +1,55 @@ +package devenv + +import ( + "context" + "fmt" + + "github.com/smartcontractkit/chainlink-canton/bindings/generated/latest/splice/splice_api_token_holding_v1" + "github.com/smartcontractkit/chainlink-canton/testhelpers" +) + +// ClearFeeHoldingForTest clears the next fee holding CID so SendMessage fails without a fee input. +func (c *Chain) ClearFeeHoldingForTest() { + c.nextFeeCID = "" +} + +// SetNextFeeCIDForTest overrides the fee holding CID used on the next send. +func (c *Chain) SetNextFeeCIDForTest(cid string) { + c.nextFeeCID = cid +} + +// MintTokensReturningCID mints Amulet and returns the new holding contract ID. +func (c *Chain) MintTokensReturningCID(ctx context.Context, amount string) (string, error) { + participant := c.chain.Participants[0] + party := participant.PartyID + + validatorAPIClients, err := c.getValidatorAPIClients() + if err != nil { + return "", fmt.Errorf("get validator API clients: %w", err) + } + + cid, err := testhelpers.MintAMT( + ctx, + participant, + validatorAPIClients.metadataClient, + validatorAPIClients.transferClient, + validatorAPIClients.scanClient, + party, + amount, + ) + if err != nil { + return "", fmt.Errorf("mint tokens: %w", err) + } + + return cid, nil +} + +// SetFeeTokenInstrumentForTest overrides the fee token instrument used on the next send. +func (c *Chain) SetFeeTokenInstrumentForTest(inst splice_api_token_holding_v1.InstrumentId) { + c.feeTokenInstrument = inst +} + +// SetTransferTokenInstrumentForTest overrides the transfer token instrument used on the next send. +func (c *Chain) SetTransferTokenInstrumentForTest(inst splice_api_token_holding_v1.InstrumentId) { + c.transferTokenInstrument = &inst +} diff --git a/ccip/devenv/tests/e2e/canton2evm_e2e_test.go b/ccip/devenv/tests/e2e/canton2evm_e2e_test.go index 10839a0fa..28e2e1a3b 100644 --- a/ccip/devenv/tests/e2e/canton2evm_e2e_test.go +++ b/ccip/devenv/tests/e2e/canton2evm_e2e_test.go @@ -229,4 +229,114 @@ func TestCanton2EVM_Basic(t *testing.T) { t.Logf("EVM receiver token balance: before=%s after=%s totalExpectedTransfer=%s", receiverBalanceBefore.String(), receiverBalanceAfter.String(), totalExpectedTransfer.String()) require.Equal(t, expectedReceiverBalanceAfter, receiverBalanceAfter) }) + + // Token transfer with default extraArgs (gasLimit=0, default executor/CCVs from lane) should succeed. + t.Run("token transfer with default extraArgs", func(t *testing.T) { + subtestCtx := ccv.Plog.WithContext(t.Context()) + + lane := devenvtests.ResolveTokenLane(t, in, lib, chainMap, cantonChain.ChainSelector(), []uint64{evmChain.ChainSelector()}) + tokenTransferAmount := lane.TransferAmount.Uint64() + + require.NoError(t, cantonImpl.MintTokens(ctx, uint64(devenvtests.CantonToEVMFeeAmount))) + require.NoError(t, cantonImpl.MintTokens(ctx, tokenTransferAmount)) + require.NoError(t, cantonImpl.SetupSend(ctx, uint64(devenvtests.CantonToEVMFeeAmount), tokenTransferAmount)) + + receiver, err := evmChain.GetEOAReceiverAddress() + require.NoError(t, err) + + sendMessageResult, err := cantonChain.SendMessage( + subtestCtx, + evmChain.ChainSelector(), + cciptestinterfaces.MessageFields{ + Receiver: receiver, + Data: []byte("canton2evm token transfer default extraArgs"), + TokenAmount: cciptestinterfaces.TokenAmount{ + Amount: lane.TransferAmount, + }, + }, + cciptestinterfaces.MessageOptions{ + ExecutionGasLimit: 0, + FinalityConfig: lane.FinalityConfig, + }, + 3, + ) + require.NoError(t, err) + require.NotNil(t, sendMessageResult.Message) + 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)) + require.NoError(t, err) + + ev, err := evmChain.ConfirmExecOnDest(subtestCtx, cantonChain.ChainSelector(), cciptestinterfaces.MessageEventKey{SeqNum: seqNo, MessageID: sentEvent.MessageID}, tests.WaitTimeout(t)) + require.NoError(t, err) + require.Equal(t, cciptestinterfaces.ExecutionStateSuccess, ev.State) + }) + + // Token transfer with gasLimit=0 should succeed — the EVM receiver gets the tokens + // even without gas for a callback since there is no receiver contract to call. + t.Run("token transfer with zero gas limit", func(t *testing.T) { + subtestCtx := ccv.Plog.WithContext(t.Context()) + + lane := devenvtests.ResolveTokenLane(t, in, lib, chainMap, cantonChain.ChainSelector(), []uint64{evmChain.ChainSelector()}) + tokenTransferAmount := lane.TransferAmount.Uint64() + + require.NoError(t, cantonImpl.MintTokens(ctx, uint64(devenvtests.CantonToEVMFeeAmount))) + require.NoError(t, cantonImpl.MintTokens(ctx, tokenTransferAmount)) + require.NoError(t, cantonImpl.SetupSend(ctx, uint64(devenvtests.CantonToEVMFeeAmount), tokenTransferAmount)) + + ds, err := lib.DataStore() + require.NoError(t, err) + receiver, err := evmChain.GetEOAReceiverAddress() + require.NoError(t, err) + ccvAddr := devenvtests.GetContractAddress( + t, ds, cantonChain.ChainSelector(), + datastore.ContractType(canton_committee_verifier.ContractType), + canton_committee_verifier.Version.String(), + devenvcommon.DefaultCommitteeVerifierQualifier, + "canton committee verifier", + ) + executorAddr := devenvtests.GetContractAddress( + t, ds, cantonChain.ChainSelector(), + datastore.ContractType(executor.ContractType), + executor.Version.String(), + devenvcommon.DefaultExecutorQualifier, + "source executor", + ) + + sendMessageResult, err := cantonChain.SendMessage( + subtestCtx, + evmChain.ChainSelector(), + cciptestinterfaces.MessageFields{ + Receiver: receiver, + Data: []byte("canton2evm token transfer zero gas"), + TokenAmount: cciptestinterfaces.TokenAmount{ + Amount: lane.TransferAmount, + }, + }, + cciptestinterfaces.MessageOptions{ + ExecutionGasLimit: 0, + FinalityConfig: lane.FinalityConfig, + Executor: executorAddr, + CCVs: []protocol.CCV{ + { + CCVAddress: ccvAddr, + Args: []byte{}, + ArgsLen: 0, + }, + }, + }, + 3, + ) + require.NoError(t, err) + require.NotNil(t, sendMessageResult.Message) + + seqNo := uint64(sendMessageResult.Message.SequenceNumber) + sentEvent, err := cantonChain.ConfirmSendOnSource(subtestCtx, evmChain.ChainSelector(), cciptestinterfaces.MessageEventKey{SeqNum: seqNo}, tests.WaitTimeout(t)) + require.NoError(t, err) + + ev, err := evmChain.ConfirmExecOnDest(subtestCtx, cantonChain.ChainSelector(), cciptestinterfaces.MessageEventKey{SeqNum: seqNo, MessageID: sentEvent.MessageID}, tests.WaitTimeout(t)) + require.NoError(t, err) + require.Equal(t, cciptestinterfaces.ExecutionStateSuccess, ev.State) + }) } diff --git a/ccip/devenv/tests/e2e/canton2evm_send_validation_test.go b/ccip/devenv/tests/e2e/canton2evm_send_validation_test.go new file mode 100644 index 000000000..60d57ba07 --- /dev/null +++ b/ccip/devenv/tests/e2e/canton2evm_send_validation_test.go @@ -0,0 +1,286 @@ +package canton + +import ( + "context" + "math/big" + "testing" + + ccv "github.com/smartcontractkit/chainlink-ccv/build/devenv" + "github.com/smartcontractkit/chainlink-ccv/build/devenv/cciptestinterfaces" + devenvcommon "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" + "github.com/smartcontractkit/chainlink-deployments-framework/datastore" + "github.com/smartcontractkit/chainlink-testing-framework/framework/components/blockchain" + "github.com/smartcontractkit/go-daml/pkg/types" + "github.com/stretchr/testify/require" + + "github.com/smartcontractkit/chainlink-canton/bindings/generated/latest/splice/splice_api_token_holding_v1" + cantondevenv "github.com/smartcontractkit/chainlink-canton/ccip/devenv" + 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" +) + +type canton2evmSendFixture struct { + ctx context.Context //nolint:containedctx // test fixture + cantonChain cciptestinterfaces.CCIP17 + cantonImpl *cantondevenv.Chain + evmSelector uint64 + receiver protocol.UnknownAddress + ccvAddr protocol.UnknownAddress + executor protocol.UnknownAddress +} + +func setupCanton2EVMSendFixture(t *testing.T) canton2evmSendFixture { + t.Helper() + + return setupCanton2EVMSendFixtureWithHoldings(t, true) +} + +func setupCanton2EVMSendFixtureWithHoldings(t *testing.T, withFeeHoldings bool) canton2evmSendFixture { + t.Helper() + + 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) + 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) + + if withFeeHoldings { + require.NoError(t, cantonImpl.MintTokens(ctx, uint64(devenvtests.CantonToEVMFeeAmount))) + require.NoError(t, cantonImpl.SetupSend(ctx, uint64(devenvtests.CantonToEVMFeeAmount), 0)) + } + + ds, err := lib.DataStore() + require.NoError(t, err) + receiver, err := evmChain.GetEOAReceiverAddress() + require.NoError(t, err) + ccvAddr := devenvtests.GetContractAddress( + t, ds, cantonChain.ChainSelector(), + datastore.ContractType(canton_committee_verifier.ContractType), + canton_committee_verifier.Version.String(), + devenvcommon.DefaultCommitteeVerifierQualifier, + "canton committee verifier", + ) + executorAddr := devenvtests.GetContractAddress( + t, ds, cantonChain.ChainSelector(), + datastore.ContractType(executor.ContractType), + executor.Version.String(), + devenvcommon.DefaultExecutorQualifier, + "source executor", + ) + + return canton2evmSendFixture{ + ctx: ctx, + cantonChain: cantonChain, + cantonImpl: cantonImpl, + evmSelector: evmChain.ChainSelector(), + receiver: receiver, + ccvAddr: ccvAddr, + executor: executorAddr, + } +} + +func (f canton2evmSendFixture) defaultMessageOptions(gasLimit uint32) cciptestinterfaces.MessageOptions { + return cciptestinterfaces.MessageOptions{ + ExecutionGasLimit: gasLimit, + FinalityConfig: 1, + Executor: f.executor, + CCVs: []protocol.CCV{ + { + CCVAddress: f.ccvAddr, + Args: []byte{}, + ArgsLen: 0, + }, + }, + } +} + +func (f canton2evmSendFixture) send(fields cciptestinterfaces.MessageFields, opts cciptestinterfaces.MessageOptions) error { + _, err := f.cantonChain.SendMessage(f.ctx, f.evmSelector, fields, opts, 3) + + return err +} + +//nolint:paralleltest // devenv is single-flight for Canton holdings. +func TestCanton2EVM_SendValidation(t *testing.T) { + if testing.Short() { + t.Skip("skipping Canton2EVM_SendValidation in short mode") + } + + t.Run("Payload exceeds maxDataBytes", func(t *testing.T) { + f := setupCanton2EVMSendFixture(t) + maxDataBytes, err := f.cantonImpl.GetMaxDataBytes(f.ctx, f.evmSelector) + require.NoError(t, err) + require.Positive(t, maxDataBytes) + + err = f.send( + cciptestinterfaces.MessageFields{ + Receiver: f.receiver, + Data: make([]byte, maxDataBytes+1), + }, + f.defaultMessageOptions(200_000), + ) + require.Error(t, err) + require.Contains(t, err.Error(), "payload exceeds destination maxDataBytes") + }) + + t.Run("Gas limit exceeds maxPerMsgGasLimit", func(t *testing.T) { + f := setupCanton2EVMSendFixture(t) + maxGas, err := f.cantonImpl.GetMaxPerMsgGasLimit(f.ctx, f.evmSelector) + require.NoError(t, err) + require.Positive(t, maxGas) + + err = f.send( + cciptestinterfaces.MessageFields{ + Receiver: f.receiver, + Data: []byte("gas limit too high"), + }, + f.defaultMessageOptions(maxGas+1), + ) + require.Error(t, err) + require.Contains(t, err.Error(), "gas limit too high") + }) + + // TODO: SendMessage always builds GenericExtraArgsV3; invalid tag requires a lower-level send path + t.Run("Invalid extraArgs tag", func(t *testing.T) { + t.Skip("") + }) + + t.Run("Bad chain selector", func(t *testing.T) { + f := setupCanton2EVMSendFixture(t) + _, err := f.cantonChain.SendMessage( + f.ctx, + 9_999_999_999_999_999_999, + cciptestinterfaces.MessageFields{ + Receiver: f.receiver, + Data: []byte("bad dest selector"), + }, + f.defaultMessageOptions(200_000), + 3, + ) + require.Error(t, err) + require.Contains(t, err.Error(), "unsupported destination chain selector") + }) + + t.Run("Invalid fee token", func(t *testing.T) { + f := setupCanton2EVMSendFixture(t) + f.cantonImpl.SetFeeTokenInstrumentForTest(splice_api_token_holding_v1.InstrumentId{ + Admin: types.PARTY("invalid-fee-admin"), + Id: types.TEXT("NotARealFeeToken"), + }) + + err := f.send( + cciptestinterfaces.MessageFields{ + Receiver: f.receiver, + Data: []byte("invalid fee token"), + }, + f.defaultMessageOptions(200_000), + ) + require.Error(t, err) + require.Contains(t, err.Error(), "no token config registered for fee token") + }) + + t.Run("No fee holding supplied", func(t *testing.T) { + f := setupCanton2EVMSendFixture(t) + f.cantonImpl.ClearFeeHoldingForTest() + + err := f.send( + cciptestinterfaces.MessageFields{ + Receiver: f.receiver, + Data: []byte("no fee holding"), + }, + f.defaultMessageOptions(200_000), + ) + require.Error(t, err) + // Error: canton SendMessage: next fee holding CID is unset; call SetupSend after minting + require.Contains(t, err.Error(), "next fee holding CID is unset") + }) + + t.Run("Insufficient fee balance", func(t *testing.T) { + f := setupCanton2EVMSendFixture(t) + // Party already has seeded Amulet from devenv; force a too-small fee holding instead. + smallCID, err := f.cantonImpl.MintTokensReturningCID(f.ctx, "0.001") + require.NoError(t, err) + f.cantonImpl.SetNextFeeCIDForTest(smallCID) + + err = f.send( + cciptestinterfaces.MessageFields{ + Receiver: f.receiver, + Data: []byte("insufficient fee"), + }, + f.defaultMessageOptions(200_000), + ) + require.Error(t, err) + require.Contains(t, err.Error(), "Insufficient funds") + }) + + 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)) + f.cantonImpl.SetTransferTokenInstrumentForTest(splice_api_token_holding_v1.InstrumentId{ + Admin: types.PARTY("unsupported-token-admin"), + Id: types.TEXT("UnsupportedToken"), + }) + + err := f.send( + cciptestinterfaces.MessageFields{ + Receiver: f.receiver, + Data: []byte("unsupported token"), + TokenAmount: cciptestinterfaces.TokenAmount{ + Amount: big.NewInt(1000), + }, + }, + f.defaultMessageOptions(500_000), + ) + require.Error(t, err) + require.Contains(t, err.Error(), "wrong pool for Message.TokenTransfer") + }) + + 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)) + + err := f.send( + cciptestinterfaces.MessageFields{ + Receiver: f.receiver, + Data: []byte("zero token amount"), + TokenAmount: cciptestinterfaces.TokenAmount{ + Amount: big.NewInt(0), + }, + }, + f.defaultMessageOptions(500_000), + ) + require.Error(t, err) + require.Contains(t, err.Error(), "token transfer amount must be positive") + }) + + t.Run("Invalid receiver", func(t *testing.T) { + f := setupCanton2EVMSendFixture(t) + // EVM dest addresses are 20 bytes; wrong length is rejected at PrepareSend. + // Note: 20 zero bytes (0x0) is a valid encoded EVM address and is accepted on Canton send. + err := f.send( + cciptestinterfaces.MessageFields{ + Receiver: make([]byte, 19), + Data: []byte("invalid receiver length"), + }, + f.defaultMessageOptions(200_000), + ) + require.Error(t, err) + require.Contains(t, err.Error(), "InvalidDestChainAddress: address length (19) does not match expected length (20) for the destination chain") + }) +}