diff --git a/go.mod b/go.mod index 539941f03..56dfcb2b5 100644 --- a/go.mod +++ b/go.mod @@ -15,6 +15,7 @@ require ( github.com/hashicorp/go-plugin v1.6.2 github.com/jackc/pgx/v4 v4.18.3 github.com/lib/pq v1.10.9 + github.com/mitchellh/mapstructure v1.5.0 github.com/pelletier/go-toml/v2 v2.2.3 github.com/prometheus/client_golang v1.20.5 github.com/smartcontractkit/chainlink-ccip v0.0.0-20250203132120-f0d42463e405 @@ -99,7 +100,6 @@ require ( github.com/mattn/go-sqlite3 v2.0.3+incompatible // indirect github.com/miekg/dns v1.1.61 // indirect github.com/mitchellh/go-testing-interface v1.14.1 // indirect - github.com/mitchellh/mapstructure v1.5.0 // indirect github.com/moby/sys/sequential v0.6.0 // indirect github.com/moby/sys/user v0.3.0 // indirect github.com/moby/sys/userns v0.1.0 // indirect diff --git a/integration-tests/relayinterface/chain_components_test.go b/integration-tests/relayinterface/chain_components_test.go index 5cc6b07a1..12c1a96b6 100644 --- a/integration-tests/relayinterface/chain_components_test.go +++ b/integration-tests/relayinterface/chain_components_test.go @@ -113,79 +113,113 @@ func DisableTests(it *SolanaChainComponentsInterfaceTester[*testing.T]) { } func RunChainComponentsSolanaTests[T WrappedTestingT[T]](t T, it *SolanaChainComponentsInterfaceTester[T]) { - testCases := Testcase[T]{ - Name: "Test address groups where first namespace shares address with second namespace", - Test: func(t T) { - ctx := tests.Context(t) - cfg := it.buildContractReaderConfig(t) - cfg.AddressShareGroups = [][]string{{AnyContractNameWithSharedAddress1, AnyContractNameWithSharedAddress2, AnyContractNameWithSharedAddress3}} - cr := it.GetContractReaderWithCustomCfg(t, cfg) - - t.Run("Namespace is part of an address share group that doesn't have a registered address and provides no address during Bind", func(t T) { - bound1 := []types.BoundContract{{ - Name: AnyContractNameWithSharedAddress1, - }} - require.Error(t, cr.Bind(ctx, bound1)) - }) - - addressToBeShared := it.Helper.CreateAccount(t, *it, AnyContractName, AnyValueToReadWithoutAnArgument, CreateTestStruct(0, it)).String() - t.Run("Namespace is part of an address share group that doesn't have a registered address and provides an address during Bind", func(t T) { - bound1 := []types.BoundContract{{Name: AnyContractNameWithSharedAddress1, Address: addressToBeShared}} - - require.NoError(t, cr.Bind(ctx, bound1)) + testCases := []Testcase[T]{ + { + Name: "Test address groups where first namespace shares address with second namespace", + Test: func(t T) { + ctx := tests.Context(t) + cfg := it.buildContractReaderConfig(t) + cfg.AddressShareGroups = [][]string{{AnyContractNameWithSharedAddress1, AnyContractNameWithSharedAddress2, AnyContractNameWithSharedAddress3}} + cr := it.GetContractReaderWithCustomCfg(t, cfg) + + t.Run("Namespace is part of an address share group that doesn't have a registered address and provides no address during Bind", func(t T) { + bound1 := []types.BoundContract{{ + Name: AnyContractNameWithSharedAddress1, + }} + require.Error(t, cr.Bind(ctx, bound1)) + }) + + addressToBeShared := it.Helper.CreateAccount(t, *it, AnyContractName, AnyValueToReadWithoutAnArgument, CreateTestStruct(0, it)).String() + t.Run("Namespace is part of an address share group that doesn't have a registered address and provides an address during Bind", func(t T) { + bound1 := []types.BoundContract{{Name: AnyContractNameWithSharedAddress1, Address: addressToBeShared}} + + require.NoError(t, cr.Bind(ctx, bound1)) + + var prim uint64 + require.NoError(t, cr.GetLatestValue(ctx, bound1[0].ReadIdentifier(MethodReturningUint64), primitives.Unconfirmed, nil, &prim)) + assert.Equal(t, AnyValueToReadWithoutAnArgument, prim) + }) + + t.Run("Namespace is part of an address share group that has a registered address and provides that same address during Bind", func(t T) { + bound2 := []types.BoundContract{{ + Name: AnyContractNameWithSharedAddress2, + Address: addressToBeShared}} + require.NoError(t, cr.Bind(ctx, bound2)) + + var prim uint64 + require.NoError(t, cr.GetLatestValue(ctx, bound2[0].ReadIdentifier(MethodReturningUint64), primitives.Unconfirmed, nil, &prim)) + assert.Equal(t, AnyValueToReadWithoutAnArgument, prim) + assert.Equal(t, addressToBeShared, bound2[0].Address) + }) + + t.Run("Namespace is part of an address share group that has a registered address and provides a wrong address during Bind", func(t T) { + key, err := solana.NewRandomPrivateKey() + require.NoError(t, err) + + bound2 := []types.BoundContract{{ + Name: AnyContractNameWithSharedAddress2, + Address: key.PublicKey().String()}} + require.Error(t, cr.Bind(ctx, bound2)) + }) + + t.Run("Namespace is part of an address share group that has a registered address and provides no address during Bind", func(t T) { + bound3 := []types.BoundContract{{Name: AnyContractNameWithSharedAddress3}} + require.NoError(t, cr.Bind(ctx, bound3)) + + var prim uint64 + require.NoError(t, cr.GetLatestValue(ctx, bound3[0].ReadIdentifier(MethodReturningUint64), primitives.Unconfirmed, nil, &prim)) + assert.Equal(t, AnyValueToReadWithoutAnArgument, prim) + assert.Equal(t, addressToBeShared, bound3[0].Address) + + // when run in a loop Bind address won't be set, so check if CR Method works without set address. + prim = 0 + require.NoError(t, cr.GetLatestValue(ctx, types.BoundContract{ + Address: "", + Name: AnyContractNameWithSharedAddress3, + }.ReadIdentifier(MethodReturningUint64), primitives.Unconfirmed, nil, &prim)) + assert.Equal(t, AnyValueToReadWithoutAnArgument, prim) + }) + + t.Run("Namespace is not part of an address share group that has a registered address and provides no address during Bind", func(t T) { + require.Error(t, cr.Bind(ctx, []types.BoundContract{{Name: AnyContractName}})) + }) + }, + }, - var prim uint64 - require.NoError(t, cr.GetLatestValue(ctx, bound1[0].ReadIdentifier(MethodReturningUint64), primitives.Unconfirmed, nil, &prim)) - assert.Equal(t, AnyValueToReadWithoutAnArgument, prim) - }) + {Name: ContractReaderGetLatestValueGetTokenPrices, + Test: func(t T) { + cr := it.GetContractReader(t) + bindings := it.GetBindings(t) + ctx := tests.Context(t) - t.Run("Namespace is part of an address share group that has a registered address and provides that same address during Bind", func(t T) { - bound2 := []types.BoundContract{{ - Name: AnyContractNameWithSharedAddress2, - Address: addressToBeShared}} - require.NoError(t, cr.Bind(ctx, bound2)) + bound := BindingsByName(bindings, AnyContractName)[0] - var prim uint64 - require.NoError(t, cr.GetLatestValue(ctx, bound2[0].ReadIdentifier(MethodReturningUint64), primitives.Unconfirmed, nil, &prim)) - assert.Equal(t, AnyValueToReadWithoutAnArgument, prim) - assert.Equal(t, addressToBeShared, bound2[0].Address) - }) + require.NoError(t, cr.Bind(ctx, bindings)) - t.Run("Namespace is part of an address share group that has a registered address and provides a wrong address during Bind", func(t T) { - key, err := solana.NewRandomPrivateKey() - require.NoError(t, err) + type TimestampedUnixBig struct { + Value *big.Int `json:"value"` + Timestamp uint32 `json:"timestamp"` + } - bound2 := []types.BoundContract{{ - Name: AnyContractNameWithSharedAddress2, - Address: key.PublicKey().String()}} - require.Error(t, cr.Bind(ctx, bound2)) - }) + res := make([]TimestampedUnixBig, 2) - t.Run("Namespace is part of an address share group that has a registered address and provides no address during Bind", func(t T) { - bound3 := []types.BoundContract{{Name: AnyContractNameWithSharedAddress3}} - require.NoError(t, cr.Bind(ctx, bound3)) + byteTokens := make([][]byte, 0, 2) + pubKey1, err := solana.PublicKeyFromBase58(GetTokenPricesPubKey1) + require.NoError(t, err) + pubKey2, err := solana.PublicKeyFromBase58(GetTokenPricesPubKey2) + require.NoError(t, err) - var prim uint64 - require.NoError(t, cr.GetLatestValue(ctx, bound3[0].ReadIdentifier(MethodReturningUint64), primitives.Unconfirmed, nil, &prim)) - assert.Equal(t, AnyValueToReadWithoutAnArgument, prim) - assert.Equal(t, addressToBeShared, bound3[0].Address) - - // when run in a loop Bind address won't be set, so check if CR Method works without set address. - prim = 0 - require.NoError(t, cr.GetLatestValue(ctx, types.BoundContract{ - Address: "", - Name: AnyContractNameWithSharedAddress3, - }.ReadIdentifier(MethodReturningUint64), primitives.Unconfirmed, nil, &prim)) - assert.Equal(t, AnyValueToReadWithoutAnArgument, prim) - }) - - t.Run("Namespace is not part of an address share group that has a registered address and provides no address during Bind", func(t T) { - require.Error(t, cr.Bind(ctx, []types.BoundContract{{Name: AnyContractName}})) - }) - }, + byteTokens = append(byteTokens, pubKey1.Bytes()) + byteTokens = append(byteTokens, pubKey2.Bytes()) + require.NoError(t, cr.GetLatestValue(ctx, bound.ReadIdentifier(GetTokenPrices), primitives.Unconfirmed, map[string]any{"tokens": byteTokens}, &res)) + require.Equal(t, "7048352069843304521481572571769838000081483315549204879493368331", res[0].Value.String()) + require.Equal(t, uint32(1700000001), res[0].Timestamp) + require.Equal(t, "17980346130170174053328187512531209543631592085982266692926093439168", res[1].Value.String()) + require.Equal(t, uint32(1800000002), res[1].Timestamp) + }}, } - RunTests(t, it, []Testcase[T]{testCases}) + RunTests(t, it, testCases) RunContractReaderTests(t, it) RunChainWriterTests(t, it) } @@ -334,8 +368,9 @@ func RunContractReaderInLoopTests[T WrappedTestingT[T]](t T, it ChainComponentsI AddressToShare []byte } + c := int16(0) mRR := MultiReadResult{} - require.NoError(t, cr.GetLatestValue(ctx, bound.ReadIdentifier(ReadWithAddressHardCodedIntoResponse), primitives.Unconfirmed, nil, &mRR)) + require.NoError(t, cr.GetLatestValue(ctx, bound.ReadIdentifier(ReadWithAddressHardCodedIntoResponse), primitives.Unconfirmed, nil, &c)) expectedMRR := MultiReadResult{A: 1, B: 2, SharedAddress: boundAddress.Bytes(), AddressToShare: boundAddress.Bytes()} require.Equal(t, expectedMRR, mRR) @@ -841,12 +876,8 @@ func (it *SolanaChainComponentsInterfaceTester[T]) buildContractReaderConfig(t T PDADefinition: codec.PDATypeDef{ Prefix: []byte("multi_read1"), }, - ResponseAddressHardCoder: &commoncodec.HardCodeModifierConfig{ - // placeholder values, whatever is put as value gets replaced with a solana pub key anyway - OffChainValues: map[string]any{ - "SharedAddress": "", - "AddressToShare": "", - }, + OutputModifications: commoncodec.ModifiersConfig{ + &commoncodec.PropertyExtractorConfig{FieldName: "B"}, }, } diff --git a/pkg/solana/chainreader/chain_reader.go b/pkg/solana/chainreader/chain_reader.go index 419b2ac40..7bfff527a 100644 --- a/pkg/solana/chainreader/chain_reader.go +++ b/pkg/solana/chainreader/chain_reader.go @@ -13,6 +13,7 @@ import ( bin "github.com/gagliardetto/binary" "github.com/gagliardetto/solana-go" "github.com/gagliardetto/solana-go/rpc" + "github.com/mitchellh/mapstructure" "github.com/smartcontractkit/chainlink-ccip/chains/solana/gobindings/fee_quoter" commoncodec "github.com/smartcontractkit/chainlink-common/pkg/codec" @@ -696,6 +697,11 @@ func (s *ContractReaderService) handleGetTokenPricesGetLatestValue( return err } + if len(pdaAddresses) == 0 { + s.lggr.Infof("No token addresses found in params: %v that were passed into %q, call to contract: %q with address: %q", params, GetTokenPrices, values.contract, values.address) + return nil + } + data, err := s.client.GetMultipleAccountData(ctx, pdaAddresses...) if err != nil { return err @@ -705,7 +711,32 @@ func (s *ContractReaderService) handleGetTokenPricesGetLatestValue( if returnSliceVal.Kind() != reflect.Ptr { return fmt.Errorf("expected <**[]*struct { Value *big.Int; Timestamp *int64 } Value>, got %q", returnSliceVal.String()) } + returnSliceVal = returnSliceVal.Elem() + // if called directly instead of as a loop + if returnSliceVal.Kind() == reflect.Slice { + underlyingType := returnSliceVal.Type().Elem() + if underlyingType.Kind() == reflect.Struct { + if _, hasValue := underlyingType.FieldByName("Value"); hasValue { + if _, hasTimestamp := underlyingType.FieldByName("Timestamp"); hasTimestamp { + sliceVal := reflect.MakeSlice(returnSliceVal.Type(), 0, 0) + for _, d := range data { + var wrapper fee_quoter.BillingTokenConfigWrapper + if err = wrapper.UnmarshalWithDecoder(bin.NewBorshDecoder(d)); err != nil { + return err + } + newElem := reflect.New(underlyingType).Elem() + newElem.FieldByName("Value").Set(reflect.ValueOf(big.NewInt(0).SetBytes(wrapper.Config.UsdPerToken.Value[:]))) + // nolint:gosec + // G115: integer overflow conversion int64 -> uint32 + newElem.FieldByName("Timestamp").Set(reflect.ValueOf(uint32(wrapper.Config.UsdPerToken.Timestamp))) + sliceVal = reflect.Append(sliceVal, newElem) + } + return mapstructure.Decode(sliceVal.Interface(), returnVal) + } + } + } + } returnSliceValType := returnSliceVal.Type() if returnSliceValType.Kind() != reflect.Ptr { @@ -771,25 +802,46 @@ func (s *ContractReaderService) getPDAsForGetTokenPrices(params any, values read if val.Kind() == reflect.Ptr { val = val.Elem() } - if val.Kind() != reflect.Struct { + + var field reflect.Value + switch val.Kind() { + case reflect.Struct: + field = val.FieldByName("Tokens") + if !field.IsValid() { + field = val.FieldByName("tokens") + } + case reflect.Map: + field = val.MapIndex(reflect.ValueOf("Tokens")) + if !field.IsValid() { + field = val.MapIndex(reflect.ValueOf("tokens")) + } + default: return nil, fmt.Errorf( - "for contract %q read %q: expected `params` to be a struct, got %s", - values.contract, values.reads[0].readName, val.Kind(), + "for contract %q read %q: expected `params` to be a struct or map, got %q: %q", + values.contract, values.reads[0].readName, val.Kind(), val.String(), ) } - field := val.FieldByName("Tokens") if !field.IsValid() { return nil, fmt.Errorf( - "for contract %q read %q: no field named 'Tokens' found in params", - values.contract, values.reads[0].readName, + "for contract %q read %q: no field named 'Tokens' found in kind: %q: %q", + values.contract, values.reads[0].readName, val.Kind(), val.String(), ) } - tokens, ok := field.Interface().(*[][32]uint8) - if !ok { + var tokens [][]uint8 + switch x := field.Interface().(type) { + // this is the type when CR is called as LOOP and creates types from IDL + case *[][32]uint8: + for _, arr := range *x { + tokens = append(tokens, arr[:]) // Slice [32]uint8 → []uint8 + } + // this is the expected type when CR is called directly + case [][]uint8: + tokens = x + default: return nil, fmt.Errorf( - "for contract %q read %q: 'Tokens' field is not of type *[][32]uint8", + "for contract %q read %q: 'Tokens' field is neither *[][32]uint8 nor [][]uint8", values.contract, values.reads[0].readName, ) } @@ -804,7 +856,7 @@ func (s *ContractReaderService) getPDAsForGetTokenPrices(params any, values read // Build the PDA addresses for all tokens. var pdaAddresses []solana.PublicKey - for _, token := range *tokens { + for _, token := range tokens { tokenAddr := solana.PublicKeyFromBytes(token[:]) if !tokenAddr.IsOnCurve() || tokenAddr.IsZero() { return nil, fmt.Errorf(