Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down
124 changes: 124 additions & 0 deletions ccip/devenv/fee_quoter_limits.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
package devenv

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for creating another file and not putting this under impl.go
Could we order the functions definitions? As:
1 - Exported methods first
2 - Unexported methods
3 - Helper functions (eg: destChainConfigFromFeeQuoterCreateArgs)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed, thanks!


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")
}
22 changes: 17 additions & 5 deletions ccip/devenv/impl.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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()
Expand All @@ -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)
Expand Down
55 changes: 55 additions & 0 deletions ccip/devenv/test_hooks.go
Original file line number Diff line number Diff line change
@@ -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
}
110 changes: 110 additions & 0 deletions ccip/devenv/tests/e2e/canton2evm_e2e_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
})
}
Loading
Loading