diff --git a/integration-tests/deployment/ccip/cs_test.go b/integration-tests/deployment/ccip/cs_test.go index 6a0bbb742..61415d36d 100644 --- a/integration-tests/deployment/ccip/cs_test.go +++ b/integration-tests/deployment/ccip/cs_test.go @@ -149,6 +149,7 @@ func TestDeployCCIP(t *testing.T) { {0, 0, 0, 0, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3}, {0, 0, 0, 0, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4}, } + configDigest := [32]byte{1, 2, 3, 4, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0} env, _, err = commonchangeset.ApplyChangesets(t, env, []commonchangeset.ConfiguredChangeSet{ commonchangeset.Configure(tonops.SetOCR3Config{}, tonops.SetOCR3OffRampConfig{ diff --git a/pkg/ccip/codec/executecodec.go b/pkg/ccip/codec/executecodec.go index 31c2f7d8e..c6a0e4f71 100644 --- a/pkg/ccip/codec/executecodec.go +++ b/pkg/ccip/codec/executecodec.go @@ -63,54 +63,61 @@ func (e *executePluginCodecV1) Encode(ctx context.Context, report ccipocr3.Execu msg := chainReport.Messages[0] var rampMessage ocr.Any2TVMRampMessage - tokenAmounts := make([]ocr.Any2TVMTokenTransfer, 0, len(msg.TokenAmounts)) - for _, tokenAmount := range msg.TokenAmounts { - if tokenAmount.Amount.IsEmpty() { - return nil, fmt.Errorf("empty amount for token: %s", tokenAmount.DestTokenAddress) - } + // IMPORTANT: tokenAmounts must be nil (not empty slice) when there are no tokens. + // This ensures correct serialization with tlb:"maybe ^" tag, which treats nil as + // Maybe 0 (absent) vs empty slice as Maybe 1 + empty cell (present but empty). + // The hash computed by msgHasher uses nil, so we must match that here. + var tokenAmounts []ocr.Any2TVMTokenTransfer + if len(msg.TokenAmounts) != 0 { + tokenAmounts = make([]ocr.Any2TVMTokenTransfer, 0, len(msg.TokenAmounts)) + for _, tokenAmount := range msg.TokenAmounts { + if tokenAmount.Amount.IsEmpty() { + return nil, fmt.Errorf("empty amount for token: %s", tokenAmount.DestTokenAddress) + } - if tokenAmount.Amount.Sign() < 0 { - return nil, fmt.Errorf("negative amount for token: %s", tokenAmount.DestTokenAddress) - } + if tokenAmount.Amount.Sign() < 0 { + return nil, fmt.Errorf("negative amount for token: %s", tokenAmount.DestTokenAddress) + } - if len(tokenAmount.DestTokenAddress) != 36 { - return nil, fmt.Errorf("invalid destTokenAddress address: %v", tokenAmount.DestTokenAddress) - } + if len(tokenAmount.DestTokenAddress) != 36 { + return nil, fmt.Errorf("invalid destTokenAddress address: %v", tokenAmount.DestTokenAddress) + } - destExecDataDecodedMap, err := e.extraDataCodec.DecodeTokenAmountDestExecData(tokenAmount.DestExecData, chainReport.SourceChainSelector) - if err != nil { - return nil, fmt.Errorf("failed to decode dest exec data: %w", err) - } + destExecDataDecodedMap, err := e.extraDataCodec.DecodeTokenAmountDestExecData(tokenAmount.DestExecData, chainReport.SourceChainSelector) + if err != nil { + return nil, fmt.Errorf("failed to decode dest exec data: %w", err) + } - destGasAmount, err := extractDestGasAmountFromMap(destExecDataDecodedMap) - if err != nil { - return nil, fmt.Errorf("extract dest gas amount: %w", err) - } + destGasAmount, err := extractDestGasAmountFromMap(destExecDataDecodedMap) + if err != nil { + return nil, fmt.Errorf("extract dest gas amount: %w", err) + } - poolAddrCell := common.CrossChainAddress(tokenAmount.SourcePoolAddress) + poolAddrCell := common.CrossChainAddress(tokenAmount.SourcePoolAddress) - extraData, err := tlb.ToCell(common.SnakeBytes(tokenAmount.ExtraData)) - if err != nil { - return nil, fmt.Errorf("pack extra data: %w", err) - } + extraData, err := tlb.ToCell(common.SnakeBytes(tokenAmount.ExtraData)) + if err != nil { + return nil, fmt.Errorf("pack extra data: %w", err) + } - destTokenAddrStr, err := e.addressCodec.AddressBytesToString(tokenAmount.DestTokenAddress) - if err != nil { - return nil, fmt.Errorf("convert dest token address: %w", err) - } + destTokenAddrStr, err := e.addressCodec.AddressBytesToString(tokenAmount.DestTokenAddress) + if err != nil { + return nil, fmt.Errorf("convert dest token address: %w", err) + } - DestPoolTonAddr, err := address.ParseAddr(destTokenAddrStr) - if err != nil { - return nil, fmt.Errorf("invalid dest token address %s: %w", destTokenAddrStr, err) - } + DestPoolTonAddr, err := address.ParseAddr(destTokenAddrStr) + if err != nil { + return nil, fmt.Errorf("invalid dest token address %s: %w", destTokenAddrStr, err) + } - tokenAmounts = append(tokenAmounts, ocr.Any2TVMTokenTransfer{ - SourcePoolAddress: poolAddrCell, - ExtraData: extraData, - DestPoolAddress: DestPoolTonAddr, - Amount: tokenAmount.Amount.Int, - DestGasAmount: destGasAmount, - }) + tokenAmounts = append(tokenAmounts, ocr.Any2TVMTokenTransfer{ + SourcePoolAddress: poolAddrCell, + ExtraData: extraData, + DestPoolAddress: DestPoolTonAddr, + Amount: tokenAmount.Amount.Int, + DestGasAmount: destGasAmount, + }) + } } tonReceiverAddrStr, err := e.addressCodec.AddressBytesToString(msg.Receiver) @@ -222,7 +229,10 @@ func (e *executePluginCodecV1) Decode(ctx context.Context, data []byte) (ccipocr messages := make([]ccipocr3.Message, 0, 1) msg := tonReport.Message - tokenAmounts := make([]ccipocr3.RampTokenAmount, 0, len(msg.TokenAmounts)) + // IMPORTANT: tokenAmounts must be nil (not empty slice) when there are no tokens. + // This ensures the decoded message produces the same hash as the original when re-hashed. + // nil serializes as Maybe 0 (absent), empty slice serializes as Maybe 1 + empty cell ref. + var tokenAmounts []ccipocr3.RampTokenAmount for _, tokenAmount := range msg.TokenAmounts { var extraData common.SnakeBytes err = tlb.LoadFromCell(&extraData, tokenAmount.ExtraData.BeginParse()) diff --git a/pkg/ccip/codec/msghasher_test.go b/pkg/ccip/codec/msghasher_test.go index 9a4602aed..54d2eb38d 100644 --- a/pkg/ccip/codec/msghasher_test.go +++ b/pkg/ccip/codec/msghasher_test.go @@ -108,16 +108,15 @@ func TestMessageHasherV1_TON(t *testing.T) { assert.Contains(t, err.Error(), "error convert receiver address") }) - // TODO: Re-enable when gasLimit is no longer hardcoded in the msgHasher and executecodec - // t.Run("message without extra args", func(t *testing.T) { - // msg := randomTONMessage(t, 5009297550715157269) - // msg.ExtraArgs = nil - // - // hash, err := hasher.Hash(ctx, msg) - // require.Error(t, err) - // assert.Contains(t, err.Error(), "cannot hash without extra args") - // assert.NotEqual(t, [32]byte{}, hash) - // }) + t.Run("message with empty ExtraArgs", func(t *testing.T) { + msg := randomTONMessage(t, 5009297550715157269) + msg.ExtraArgs = nil + + hash, err := hasher.Hash(ctx, msg) + require.Error(t, err) + assert.Contains(t, err.Error(), "cannot hash without extra args") + assert.NotEqual(t, [32]byte{}, hash) + }) t.Run("message without token amounts", func(t *testing.T) { msg := randomTONMessage(t, 5009297550715157269) @@ -159,6 +158,92 @@ func TestMessageHasherV1_ErrorCases(t *testing.T) { }) } +// TestMessageHasherV1_ExecuteCodecConsistency verifies that TokenAmounts is correctly +// serialized as nil (Maybe 0) rather than empty slice (Maybe 1) when there are no tokens. +// This is critical because the hash depends on how TokenAmounts is serialized. +func TestMessageHasherV1_ExecuteCodecConsistency(t *testing.T) { + ctx := context.Background() + mockExtraDataCodec := new(mocks.SourceChainExtraDataCodec) + edc := ccipocr3.ExtraDataCodecMap(map[string]ccipocr3.SourceChainExtraDataCodec{ + chainsel.FamilyEVM: mockExtraDataCodec, + }) + + mockExtraDataCodec.On("DecodeDestExecDataToMap", mock.Anything).Return(map[string]any{ + "destgasamount": uint32(1000), + }, nil) + mockExtraDataCodec.On("DecodeExtraArgsToMap", mock.Anything).Return(map[string]any{ + "gasLimit": big.NewInt(100_000_000), + }, nil) + + executeCodec := NewExecutePluginCodecV1(edc) + + t.Run("tokenAmounts nil preserved through encode/decode", func(t *testing.T) { + // Create a message with NO token amounts + tonAddr, err := address.ParseAddr("EQDtFpEwcFAEcRe5mLVh2N6C0x-_hJEM7W61_JLnSF74p4q2") + require.NoError(t, err) + + rawTonAddr := ToRawAddr(tonAddr) + evmSenderBytes, err := hex.DecodeString("1a5fdbc891c5d4e6ad68064ae45d43146d4f9f3a") + require.NoError(t, err) + + evmOnrampBytes, err := hex.DecodeString("111111c891c5d4e6ad68064ae45d43146d4f9f3a") + require.NoError(t, err) + + var messageID [32]byte + binary.BigEndian.PutUint64(messageID[24:], 1) + + // Message with NO token amounts - this MUST remain nil through encode/decode + msg := ccipocr3.Message{ + Header: ccipocr3.RampMessageHeader{ + MessageID: messageID, + SourceChainSelector: ccipocr3.ChainSelector(909606746561742123), + DestChainSelector: ccipocr3.ChainSelector(13879075125137744094), + SequenceNumber: ccipocr3.SeqNum(1), + Nonce: 0, + OnRamp: evmOnrampBytes, + }, + Sender: ccipocr3.UnknownAddress(evmSenderBytes), + Data: []byte{}, + Receiver: rawTonAddr[:], + ExtraArgs: []byte{0x2}, + TokenAmounts: nil, // MUST stay nil, not become empty slice + } + + // Encode the message + report := ccipocr3.ExecutePluginReport{ + ChainReports: []ccipocr3.ExecutePluginReportSingleChain{ + { + SourceChainSelector: msg.Header.SourceChainSelector, + Messages: []ccipocr3.Message{msg}, + OffchainTokenData: [][][]byte{{}}, + Proofs: []ccipocr3.Bytes32{}, + ProofFlagBits: ccipocr3.BigInt{Int: big.NewInt(0)}, + }, + }, + } + + encoded, err := executeCodec.Encode(ctx, report) + require.NoError(t, err) + + // Decode the message + decoded, err := executeCodec.Decode(ctx, encoded) + require.NoError(t, err) + + decodedMsg := decoded.ChainReports[0].Messages[0] + + // CRITICAL: TokenAmounts must be nil after decode, not an empty slice! + // If it's an empty slice instead of nil, the hash will be different because: + // - nil serializes as Maybe 0 (1 bit = 0) + // - empty slice serializes as Maybe 1 + empty cell reference + t.Logf("Original msg.TokenAmounts == nil: %v", msg.TokenAmounts == nil) + t.Logf("Decoded msg.TokenAmounts == nil: %v", decodedMsg.TokenAmounts == nil) + + require.Nil(t, decodedMsg.TokenAmounts, + "TokenAmounts should be nil after decode, not an empty slice. "+ + "This is critical for hash consistency: nil→Maybe 0, empty slice→Maybe 1+ref") + }) +} + func TestMessageHasherV1_CrossLanguageCompatibility(t *testing.T) { // Right now the hash from ts and gobinding Any2TVMRamp message generates different msg hash. Need to fix it before running this test ctx := context.Background() @@ -194,6 +279,7 @@ func TestMessageHasherV1_CrossLanguageCompatibility(t *testing.T) { var messageID [32]byte binary.BigEndian.PutUint64(messageID[24:], 1) // This sets the last 8 bytes to 1 + ta := make([]ccipocr3.RampTokenAmount, 0) // Create exact same message as TypeScript test msg := ccipocr3.Message{ Header: ccipocr3.RampMessageHeader{ @@ -208,7 +294,7 @@ func TestMessageHasherV1_CrossLanguageCompatibility(t *testing.T) { Data: []byte{}, // empty cell data Receiver: rawTonAddr[:], ExtraArgs: []byte{0x2}, // will be populated by mock - TokenAmounts: nil, // no token amounts + TokenAmounts: ta, // no token amounts } // Set messageID to 1 diff --git a/pkg/ccip/provider/provider.go b/pkg/ccip/provider/provider.go index 444a23d7f..0df6651f4 100644 --- a/pkg/ccip/provider/provider.go +++ b/pkg/ccip/provider/provider.go @@ -91,6 +91,7 @@ func NewCCIPProvider( ChainSpecificAddressCodec: addressCodec, CommitPluginCodec: codec.NewCommitPluginCodecV1(), ExecutePluginCodec: codec.NewExecutePluginCodecV1(cargs.ExtraDataCodecBundle), + MessageHasher: codec.NewMessageHasherV1(lggr, cargs.ExtraDataCodecBundle), TokenDataEncoder: codec.NewTokenDataEncoder(), SourceChainExtraDataCodec: codec.NewExtraDataDecoder(), }