diff --git a/pkg/solana/ccip/codec/addresscodec.go b/pkg/solana/ccip/codec/addresscodec.go index 29f253df9..4c222d386 100644 --- a/pkg/solana/ccip/codec/addresscodec.go +++ b/pkg/solana/ccip/codec/addresscodec.go @@ -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) } diff --git a/pkg/solana/ccip/codec/executecodec.go b/pkg/solana/ccip/codec/executecodec.go index b012985c2..04cd64c74 100644 --- a/pkg/solana/ccip/codec/executecodec.go +++ b/pkg/solana/ccip/codec/executecodec.go @@ -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 { @@ -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 } @@ -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) } @@ -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 @@ -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) { // 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) diff --git a/pkg/solana/ccip/codec/executecodec_test.go b/pkg/solana/ccip/codec/executecodec_test.go index 99768378b..240a8934f 100644 --- a/pkg/solana/ccip/codec/executecodec_test.go +++ b/pkg/solana/ccip/codec/executecodec_test.go @@ -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]") + }) +} diff --git a/pkg/solana/ccip/codec/extradatacodec.go b/pkg/solana/ccip/codec/extradatacodec.go index aaee60783..88a17afda 100644 --- a/pkg/solana/ccip/codec/extradatacodec.go +++ b/pkg/solana/ccip/codec/extradatacodec.go @@ -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 diff --git a/pkg/solana/ccip/codec/msghasher.go b/pkg/solana/ccip/codec/msghasher.go index 4c6f98638..71903e083 100644 --- a/pkg/solana/ccip/codec/msghasher.go +++ b/pkg/solana/ccip/codec/msghasher.go @@ -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) + destGasAmount, err := ExtractDestGasAmountFromMap(destExecDataDecodedMap) if err != nil { return [32]byte{}, err } @@ -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...) }