Skip to content
Merged
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
1 change: 1 addition & 0 deletions integration-tests/deployment/ccip/cs_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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{
Expand Down
90 changes: 50 additions & 40 deletions pkg/ccip/codec/executecodec.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Copy link
Collaborator

Choose a reason for hiding this comment

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

Could we add a comment here to clarify why explicit nil initialization is important?
This would help prevent accidentally reintroducing the bug in the future.

// Explicit nil for tokenAmounts is necessary for deterministic hash calculation to match with contracts.
// A nil slice encodes as absent optional reference, while make([]T, 0, ...) encodes as present-but-empty,
// causing hash mismatches and Merkle proof verification failures.

Copy link
Contributor Author

@huangzhen1997 huangzhen1997 Jan 29, 2026

Choose a reason for hiding this comment

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

Think that report analysis was not true, we don't have token transfer and the msgHasher is returning the same on-chain otherwise the msg report will not be accepted in our staging env. This is more like consistency improvement, not actually fixing bug.

Copy link
Contributor Author

@huangzhen1997 huangzhen1997 Jan 29, 2026

Choose a reason for hiding this comment

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

I believe the ccip report cell gets parsed to stack values in TVM and the hashing logic matches with the msgHasher off-chain. So having empty array is okay in the cell.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Actually I think this does cause hash mismatch, I tested it locally, but I'm not sure why we never ran into this issue in staging msg passing test

Copy link
Collaborator

Choose a reason for hiding this comment

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

Agree that it's weird that if this i a real issue we have not seen it happen live.

Copy link
Contributor Author

@huangzhen1997 huangzhen1997 Feb 2, 2026

Choose a reason for hiding this comment

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

Thanks to @vicentevieytes, he found that the contract transmitter is decoding and encoding the execute report again after the executecodec, and "normalizing" empty slices to nil, which accidentally makes the hashes match.

We should fix the msgHasher and executecodec misalignment here, and good to understand how it worked previously. cc @archseer @krebernisak

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)
Expand Down Expand Up @@ -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())
Expand Down
108 changes: 97 additions & 11 deletions pkg/ccip/codec/msghasher_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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{
Expand All @@ -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
Expand Down
1 change: 1 addition & 0 deletions pkg/ccip/provider/provider.go
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
}
Expand Down
Loading