Skip to content
Open
Show file tree
Hide file tree
Changes from 5 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 pkg/solana/ccip/codec/addresscodec.go
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,6 @@ func (a addressCodec) OracleIDAsAddressBytes(oracleID uint8) ([]byte, error) {
}

func (a addressCodec) TransmitterBytesToString(addr []byte) (string, error) {
// Transmitter accounts are addresses
// Transmitter Accounts are addresses
return a.AddressBytesToString(addr)
}
122 changes: 82 additions & 40 deletions pkg/solana/ccip/codec/executecodec.go
Original file line number Diff line number Diff line change
Expand Up @@ -25,10 +25,11 @@ type ExecutePluginCodecV1 struct {
extraDataCodec ccipocr3.ExtraDataCodecBundle
}

type extraData struct {
extraArgs ccip_offramp.Any2SVMRampExtraArgs
accounts []solana.PublicKey
tokenReceiver solana.PublicKey
// ExtraData is a struct for holding the decoded extra args data used for encoding the execute report.
type ExtraData struct {
ExtraArgs ccip_offramp.Any2SVMRampExtraArgs
Accounts []solana.PublicKey
TokenReceiver solana.PublicKey
}

func NewExecutePluginCodecV1(extraDataCodec ccipocr3.ExtraDataCodecBundle) *ExecutePluginCodecV1 {
Expand Down Expand Up @@ -78,7 +79,7 @@ func (e *ExecutePluginCodecV1) Encode(ctx context.Context, report ccipocr3.Execu
return nil, fmt.Errorf("failed to decode dest exec data: %w", err)
}

destGasAmount, err := extractDestGasAmountFromMap(destExecDataDecodedMap)
destGasAmount, err := ExtractDestGasAmountFromMap(destExecDataDecodedMap)
if err != nil {
return nil, err
}
Expand All @@ -97,7 +98,7 @@ func (e *ExecutePluginCodecV1) Encode(ctx context.Context, report ccipocr3.Execu
return nil, fmt.Errorf("failed to decode extra args: %w", err)
}

ed, err := parseExtraDataMap(extraDataDecodedMap)
ed, err := ParseExtraDataMap(extraDataDecodedMap)
if err != nil {
return nil, fmt.Errorf("invalid extra args map: %w", err)
}
Expand All @@ -116,9 +117,9 @@ func (e *ExecutePluginCodecV1) Encode(ctx context.Context, report ccipocr3.Execu
},
Sender: msg.Sender,
Data: msg.Data,
TokenReceiver: ed.tokenReceiver,
TokenReceiver: ed.TokenReceiver,
TokenAmounts: tokenAmounts,
ExtraArgs: ed.extraArgs,
ExtraArgs: ed.ExtraArgs,
}

// should only have an offchain token data if there are tokens as part of the message
Expand Down Expand Up @@ -223,63 +224,104 @@ func (e *ExecutePluginCodecV1) Decode(ctx context.Context, encodedReport []byte)
return report, nil
}

func parseExtraDataMap(input map[string]any) (extraData, error) {
// ParseExtraDataMap parses the decoded extra args map into the extraData struct used for encoding the execute report.
func ParseExtraDataMap(input map[string]any) (ExtraData, error) {
Copy link
Copy Markdown
Contributor Author

@huangzhen1997 huangzhen1997 Apr 22, 2026

Choose a reason for hiding this comment

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

This change comes from a previous fix from core.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Ah I see it's moved here

// Parse input map into SolanaExtraArgs
var out extraData
var out ExtraData
var extraArgs ccip_offramp.Any2SVMRampExtraArgs
var accounts []solana.PublicKey
var tokenReceiver solana.PublicKey

// Iterate through the expected fields in the struct
// the field name should match with the one in SVMExtraArgsV1
// Iterate through the expected fields in the struct.
// The field names should match SVMExtraArgsV1 from the EVM Client library:
// https://github.com/smartcontractkit/chainlink/blob/33c0bda696b0ed97f587a46eacd5c65bed9fb2c1/contracts/src/v0.8/ccip/libraries/Client.sol#L57
//
// Note: when the source chain ExtraDataCodec runs in a LOOP plugin (e.g. TON relay),
// the map[string]any values go through gRPC protobuf serialization which changes types:
// uint32 -> int64, [32]byte -> []byte, [][32]byte -> []interface{}
// Each case below handles both the native type and the LOOP-converted type.
for fieldName, fieldValue := range input {
lowercase := strings.ToLower(fieldName)
switch lowercase {
case "computeunits":
// Expect uint32
v, ok := fieldValue.(uint32)
if !ok {
return out, errors.New("invalid type for ComputeUnits, expected uint32")
switch v := fieldValue.(type) {
case uint32:
extraArgs.ComputeUnits = v
case int64: // LOOP gRPC converts uint32 -> int64
if v < 0 || v > math.MaxUint32 {
return out, fmt.Errorf("ComputeUnits out of uint32 range: %d", v)
}
extraArgs.ComputeUnits = uint32(v)
default:
return out, fmt.Errorf("invalid type for ComputeUnits, expected uint32, got %T", fieldValue)
}
extraArgs.ComputeUnits = v
case "accountiswritablebitmap":
// Expect uint64
v, ok := fieldValue.(uint64)
if !ok {
return out, errors.New("invalid type for IsWritableBitmap, expected uint64")
switch v := fieldValue.(type) {
case uint64:
extraArgs.IsWritableBitmap = v
case int64: // LOOP gRPC may convert uint64 -> int64
extraArgs.IsWritableBitmap = uint64(v) //nolint:gosec // bitmap, interpret bits as-is
default:
return out, fmt.Errorf("invalid type for IsWritableBitmap, expected uint64, got %T", fieldValue)
}
extraArgs.IsWritableBitmap = v
case "accounts":
// Expect [][32]byte
v, ok := fieldValue.([][32]byte)
if !ok {
return out, errors.New("invalid type for Accounts, expected [][32]byte")
}
a := make([]solana.PublicKey, len(v))
for i, val := range v {
a[i] = solana.PublicKeyFromBytes(val[:])
switch v := fieldValue.(type) {
case [][32]byte:
a := make([]solana.PublicKey, len(v))
for i, val := range v {
a[i] = solana.PublicKeyFromBytes(val[:])
}
accounts = a
case []interface{}: // LOOP gRPC converts [][32]byte -> []interface{}
a := make([]solana.PublicKey, len(v))
for i, elem := range v {
bs, ok := elem.([]byte)
if !ok {
return out, fmt.Errorf("invalid type for Accounts[%d], expected []byte, got %T", i, elem)
}
if len(bs) != 32 {
return out, fmt.Errorf("invalid length for Accounts[%d]: expected 32, got %d", i, len(bs))
}
a[i] = solana.PublicKeyFromBytes(bs)
}
accounts = a
case [][]byte: // alternative LOOP representation
a := make([]solana.PublicKey, len(v))
for i, bs := range v {
if len(bs) != 32 {
return out, fmt.Errorf("invalid length for Accounts[%d]: expected 32, got %d", i, len(bs))
}
a[i] = solana.PublicKeyFromBytes(bs)
}
accounts = a
default:
return out, fmt.Errorf("invalid type for Accounts, expected [][32]byte, got %T", fieldValue)
}
accounts = a
case "tokenreceiver":
// Expect [32]byte
v, ok := fieldValue.([32]byte)
if !ok {
return out, errors.New("invalid type for TokenReceiver, expected [32]byte")
switch v := fieldValue.(type) {
case [32]byte:
tokenReceiver = solana.PublicKeyFromBytes(v[:])
case []byte: // LOOP gRPC converts [32]byte -> []byte
if len(v) != 32 {
return out, fmt.Errorf("invalid length for TokenReceiver: expected 32, got %d", len(v))
}
tokenReceiver = solana.PublicKeyFromBytes(v)
default:
return out, fmt.Errorf("invalid type for TokenReceiver, expected [32]byte, got %T", fieldValue)
}
tokenReceiver = solana.PublicKeyFromBytes(v[:])
default:
// no error here, unneeded keys can be skipped without return errors
}
}

out.extraArgs = extraArgs
out.accounts = accounts
out.tokenReceiver = tokenReceiver
out.ExtraArgs = extraArgs
out.Accounts = accounts
out.TokenReceiver = tokenReceiver
return out, nil
}

func extractDestGasAmountFromMap(input map[string]any) (uint32, error) {
// ExtractDestGasAmountFromMap searches for the dest gas amount in the decoded DestExecData map and returns it as a uint32.
func ExtractDestGasAmountFromMap(input map[string]any) (uint32, error) {
// Search for the gas fields
for fieldName, fieldValue := range input {
lowercase := strings.ToLower(fieldName)
Expand Down
111 changes: 111 additions & 0 deletions pkg/solana/ccip/codec/executecodec_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -310,3 +310,114 @@ func Test_DecodingExecuteReport(t *testing.T) {
require.Equal(t, originMsg.Sender, ccipocr3.UnknownAddress(executeReport.Message.Sender))
})
}

func TestParseExtraDataMap_NativeTypes(t *testing.T) {
// Test with native Go types (in-process codec, no LOOP)
account1 := [32]byte{0x01}
account2 := [32]byte{0x02}
receiver := [32]byte{0x03}

input := map[string]any{
"ComputeUnits": uint32(2000000),
"AccountIsWritableBitmap": uint64(6),
"AllowOutOfOrderExecution": true,
"TokenReceiver": receiver,
"Accounts": [][32]byte{account1, account2},
}

ed, err := ParseExtraDataMap(input)
require.NoError(t, err)
assert.Equal(t, uint32(2000000), ed.ExtraArgs.ComputeUnits)
assert.Equal(t, uint64(6), ed.ExtraArgs.IsWritableBitmap)
assert.Equal(t, solanago.PublicKeyFromBytes(receiver[:]), ed.TokenReceiver)
require.Len(t, ed.Accounts, 2)
assert.Equal(t, solanago.PublicKeyFromBytes(account1[:]), ed.Accounts[0])
assert.Equal(t, solanago.PublicKeyFromBytes(account2[:]), ed.Accounts[1])
}

func TestParseExtraDataMap_LOOPConvertedTypes(t *testing.T) {
// Test with types as they arrive after LOOP gRPC roundtrip:
// uint32 -> int64, [32]byte -> []byte, [][32]byte -> []interface{}
account1 := make([]byte, 32)
account1[0] = 0x01
account2 := make([]byte, 32)
account2[0] = 0x02
receiver := make([]byte, 32)
receiver[0] = 0x03

input := map[string]any{
"ComputeUnits": int64(2000000), // LOOP: uint32 -> int64
"AccountIsWritableBitmap": int64(6), // LOOP: uint64 -> int64
"AllowOutOfOrderExecution": true,
"TokenReceiver": receiver, // LOOP: [32]byte -> []byte
"Accounts": []interface{}{ // LOOP: [][32]byte -> []interface{}
account1,
account2,
},
}

ed, err := ParseExtraDataMap(input)
require.NoError(t, err)
assert.Equal(t, uint32(2000000), ed.ExtraArgs.ComputeUnits)
assert.Equal(t, uint64(6), ed.ExtraArgs.IsWritableBitmap)
assert.Equal(t, solanago.PublicKeyFromBytes(receiver), ed.TokenReceiver)
require.Len(t, ed.Accounts, 2)
assert.Equal(t, solanago.PublicKeyFromBytes(account1), ed.Accounts[0])
assert.Equal(t, solanago.PublicKeyFromBytes(account2), ed.Accounts[1])
}

func TestParseExtraDataMap_LOOPAccountsAsByteSlice(t *testing.T) {
// Test [][]byte variant (alternative LOOP representation)
account1 := make([]byte, 32)
account1[0] = 0x01

input := map[string]any{
"ComputeUnits": uint32(1000),
"AccountIsWritableBitmap": uint64(0),
"TokenReceiver": [32]byte{},
"Accounts": [][]byte{account1},
}

ed, err := ParseExtraDataMap(input)
require.NoError(t, err)
require.Len(t, ed.Accounts, 1)
assert.Equal(t, solanago.PublicKeyFromBytes(account1), ed.Accounts[0])
}

func TestParseExtraDataMap_InvalidTypes(t *testing.T) {
t.Run("ComputeUnits out of range", func(t *testing.T) {
input := map[string]any{
"ComputeUnits": int64(5000000000), // exceeds uint32 max
}
_, err := ParseExtraDataMap(input)
require.ErrorContains(t, err, "ComputeUnits out of uint32 range")
})

t.Run("TokenReceiver wrong length", func(t *testing.T) {
input := map[string]any{
"TokenReceiver": []byte{0x01, 0x02}, // not 32 bytes
}
_, err := ParseExtraDataMap(input)
require.ErrorContains(t, err, "invalid length for TokenReceiver")
})

t.Run("Accounts element wrong length", func(t *testing.T) {
input := map[string]any{
"Accounts": []interface{}{
[]byte{0x01, 0x02}, // not 32 bytes
},
}
_, err := ParseExtraDataMap(input)
require.ErrorContains(t, err, "invalid length for Accounts[0]")
})

t.Run("Accounts element wrong type", func(t *testing.T) {
input := map[string]any{
"Accounts": []interface{}{
"not bytes",
},
}
_, err := ParseExtraDataMap(input)
require.ErrorContains(t, err, "invalid type for Accounts[0]")
})
}
2 changes: 1 addition & 1 deletion pkg/solana/ccip/codec/extradatacodec.go
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ func NewExtraDataDecoder() ExtraDataDecoder {
// DecodeExtraArgsToMap is a helper function for converting Borsh encoded extra args bytes into map[string]any
func (d ExtraDataDecoder) DecodeExtraArgsToMap(extraArgs ccipocr3.Bytes) (map[string]any, error) {
if len(extraArgs) < 4 {
return nil, fmt.Errorf("extra args too short: %d, should be at least 4 (i.e the extraArgs tag)", len(extraArgs))
return nil, fmt.Errorf("extra args too short: %d, should be at least 4 (i.e the ExtraArgs tag)", len(extraArgs))
}

var decodedEvmTag, decodedSvmTag []byte
Expand Down
12 changes: 6 additions & 6 deletions pkg/solana/ccip/codec/msghasher.go
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@ func (h *MessageHasherV1) Hash(_ context.Context, msg ccipocr3.Message) (ccipocr
return [32]byte{}, fmt.Errorf("failed to decode dest exec data: %w", err)
}

destGasAmount, err := extractDestGasAmountFromMap(destExecDataDecodedMap)
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Are there no more changes to backport into this file? The msghasher in core looks completely different

destGasAmount, err := ExtractDestGasAmountFromMap(destExecDataDecodedMap)
if err != nil {
return [32]byte{}, err
}
Expand All @@ -76,15 +76,15 @@ func (h *MessageHasherV1) Hash(_ context.Context, msg ccipocr3.Message) (ccipocr
return [32]byte{}, fmt.Errorf("failed to decode extra args: %w", err)
}

ed, err := parseExtraDataMap(extraDataDecodedMap)
ed, err := ParseExtraDataMap(extraDataDecodedMap)
if err != nil {
return [32]byte{}, fmt.Errorf("failed to decode ExtraArgs: %w", err)
}

anyToSolanaMessage.TokenReceiver = ed.tokenReceiver
anyToSolanaMessage.ExtraArgs = ed.extraArgs
accounts := ed.accounts
// if logical receiver is empty, don't prepend it to the accounts list
anyToSolanaMessage.TokenReceiver = ed.TokenReceiver
anyToSolanaMessage.ExtraArgs = ed.ExtraArgs
accounts := ed.Accounts
// if logical receiver is empty, don't prepend it to the Accounts list
if !msg.Receiver.IsZeroOrEmpty() {
accounts = append([]solana.PublicKey{solana.PublicKeyFromBytes(msg.Receiver)}, accounts...)
}
Expand Down
Loading