diff --git a/go.mod b/go.mod index 77a3b33b6..5d1af3ade 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 87d740955..ce3d76a2a 100644 --- a/integration-tests/relayinterface/chain_components_test.go +++ b/integration-tests/relayinterface/chain_components_test.go @@ -116,79 +116,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) } @@ -264,6 +298,7 @@ func RunChainWriterTests[T WrappedTestingT[T]](t T, it *SolanaChainComponentsInt // GetLatestValue method const ( + ContractReaderNotFoundReadsReturnZeroedResponses = "Get latest value not found reads return zeroed responses" ContractReaderGetLatestValueUsingMultiReader = "Get latest value using multi reader" ContractReaderBatchGetLatestValueUsingMultiReader = "Batch Get latest value using multi reader" ContractReaderGetLatestValueWithAddressHardcodedIntoResponse = "Get latest value with AddressHardcoded into response" @@ -272,14 +307,50 @@ const ( ChainWriterLookupTableTest = "Set contract value using a lookup table for addresses" ) -type TimestampedUnixBig struct { - Value *big.Int `json:"value"` - Timestamp uint32 `json:"timestamp"` -} - func RunContractReaderInLoopTests[T WrappedTestingT[T]](t T, it ChainComponentsInterfaceTester[T]) { //RunContractReaderInterfaceTests(t, it, false, true) testCases := []Testcase[T]{ + { + Name: ContractReaderNotFoundReadsReturnZeroedResponses, + Test: func(t T) { + cr := it.GetContractReader(t) + bindings := it.GetBindings(t) + ctx := tests.Context(t) + + bound := BindingsByName(bindings, AnyContractName)[0] + require.NoError(t, cr.Bind(ctx, bindings)) + + dAccRes := contractprimary.DataAccount{} + require.NoError(t, cr.GetLatestValue(ctx, bound.ReadIdentifier(ReadUninitializedPDA), primitives.Unconfirmed, nil, &dAccRes)) + require.Equal(t, contractprimary.DataAccount{}, dAccRes) + + mR3Res := contractprimary.MultiRead3{} + batchGetLatestValueRequest := make(types.BatchGetLatestValuesRequest) + batchGetLatestValueRequest[bound] = []types.BatchRead{ + { + ReadName: ReadUninitializedPDA, + Params: nil, + ReturnVal: &dAccRes, + }, + { + ReadName: MultiReadWithParamsReuse, + Params: map[string]any{"ID": 999}, + ReturnVal: &mR3Res, + }, + } + + batchResult, err := cr.BatchGetLatestValues(ctx, batchGetLatestValueRequest) + require.NoError(t, err) + + result, err := batchResult[bound][0].GetResult() + require.NoError(t, err) + require.Equal(t, &contractprimary.DataAccount{}, result) + + result, err = batchResult[bound][1].GetResult() + require.NoError(t, err) + require.Equal(t, &contractprimary.MultiRead3{}, result) + }, + }, { Name: ContractReaderGetLatestValueWithAddressHardcodedIntoResponse, Test: func(t T) { @@ -794,6 +865,7 @@ func (h *helper) runInitialize( } const ( + ReadUninitializedPDA = "ReadUninitializedPDA" MultiRead = "MultiRead" ReadWithAddressHardCodedIntoResponse = "ReadWithAddressHardCodedIntoResponse" MultiReadWithParamsReuse = "MultiReadWithParamsReuse" @@ -874,6 +946,13 @@ func (it *SolanaChainComponentsInterfaceTester[T]) buildContractReaderConfig(t T AnyContractName: { IDL: idl, Reads: map[string]config.ReadDefinition{ + ReadUninitializedPDA: { + ChainSpecificName: "DataAccount", + ReadType: config.Account, + PDADefinition: codec.PDATypeDef{ + Prefix: []byte("AAAAAAAAAA"), + }, + }, ReadWithAddressHardCodedIntoResponse: readWithAddressHardCodedIntoResponseDef, GetTokenPrices: { ChainSpecificName: "USDPerToken", diff --git a/integration-tests/relayinterface/lookups_test.go b/integration-tests/relayinterface/lookups_test.go index 910b2cf69..bc0e3eb6e 100644 --- a/integration-tests/relayinterface/lookups_test.go +++ b/integration-tests/relayinterface/lookups_test.go @@ -150,7 +150,7 @@ func TestAccountLookups(t *testing.T) { IsWritable: chainwriter.MetaBool{Value: true}, }} _, err := lookupConfig.AccountLookup.Resolve(testArgs) - require.Error(t, err) + require.ErrorIs(t, err, chainwriter.ErrLookupNotFoundAtLocation) }) t.Run("AccountLookup works with MetaBool bitmap lookups", func(t *testing.T) { @@ -360,8 +360,7 @@ func TestPDALookups(t *testing.T) { } _, err := pdaLookup.Resolve(ctx, args, nil, client.MultiClient{}) - require.Error(t, err) - require.Contains(t, err.Error(), "key not found") + require.ErrorIs(t, err, chainwriter.ErrGettingSeedAtLocation) }) t.Run("PDALookup resolves valid PDA with address lookup seeds", func(t *testing.T) { @@ -569,6 +568,21 @@ func TestLookupTables(t *testing.T) { require.Contains(t, err.Error(), "error fetching account info for table") // Example error message }) + t.Run("Derived lookup table fails with invalid table name", func(t *testing.T) { + derivedTableMap := map[string]map[string][]*solana.AccountMeta{ + "DerivedTable": {}, + } + accountsFromLookupTable := chainwriter.Lookup{ + AccountsFromLookupTable: &chainwriter.AccountsFromLookupTable{ + LookupTableName: "InvalidTable", + IncludeIndexes: []int{}, + }, + } + + _, err = accountsFromLookupTable.Resolve(ctx, nil, derivedTableMap, multiClient) + require.ErrorIs(t, err, chainwriter.ErrLookupTableNotFound) + }) + t.Run("Static lookup table fails with invalid address", func(t *testing.T) { invalidTable := chainwriter.GetRandomPubKey(t) @@ -608,8 +622,15 @@ func TestLookupTables(t *testing.T) { derivedTableMap, _, err := cw.ResolveLookupTables(ctx, testArgs, lookupConfig) require.NoError(t, err) - addresses, ok := derivedTableMap["DerivedTable"][table.String()] - require.True(t, ok) + accountsFromLookupTable := chainwriter.Lookup{ + AccountsFromLookupTable: &chainwriter.AccountsFromLookupTable{ + LookupTableName: "DerivedTable", + IncludeIndexes: []int{}, + }, + } + + addresses, err := accountsFromLookupTable.Resolve(ctx, nil, derivedTableMap, multiClient) + require.NoError(t, err) for i, address := range addresses { require.Equal(t, pubKeys[i], address.PublicKey) } @@ -654,8 +675,15 @@ func TestLookupTables(t *testing.T) { derivedTableMap, _, err := cw.ResolveLookupTables(ctx, args, lookupConfig) require.NoError(t, err) - addresses, ok := derivedTableMap["DerivedTable"][lookupTable.String()] - require.True(t, ok) + accountsFromLookupTable := chainwriter.Lookup{ + AccountsFromLookupTable: &chainwriter.AccountsFromLookupTable{ + LookupTableName: "DerivedTable", + IncludeIndexes: []int{}, + }, + } + + addresses, err := accountsFromLookupTable.Resolve(ctx, args, derivedTableMap, multiClient) + require.NoError(t, err) for i, address := range addresses { require.Equal(t, lookupKeys[i], address.PublicKey) } diff --git a/pkg/solana/chainreader/batch.go b/pkg/solana/chainreader/batch.go index cb1057cdb..f715216f9 100644 --- a/pkg/solana/chainreader/batch.go +++ b/pkg/solana/chainreader/batch.go @@ -8,13 +8,15 @@ import ( "github.com/gagliardetto/solana-go" + "github.com/smartcontractkit/chainlink-common/pkg/logger" "github.com/smartcontractkit/chainlink-common/pkg/types" "github.com/smartcontractkit/chainlink-common/pkg/values" ) type call struct { - Namespace, ReadName string - Params, ReturnVal any + Namespace, ReadName string + Params, ReturnVal any + ErrOnMissingAccountData bool } type batchResultWithErr struct { @@ -33,20 +35,21 @@ type MultipleAccountGetter interface { } // doMultiRead aggregate results from multiple PDAs from the same contract into one result. -func doMultiRead(ctx context.Context, client MultipleAccountGetter, bdRegistry *bindingsRegistry, rv readValues, params, returnValue any) error { +func doMultiRead(ctx context.Context, lggr logger.Logger, client MultipleAccountGetter, bdRegistry *bindingsRegistry, rv readValues, params, returnValue any) error { batch := make([]call, len(rv.reads)) for idx, r := range rv.reads { batch[idx] = call{ - Namespace: rv.contract, - ReadName: r.readName, - ReturnVal: returnValue, + Namespace: rv.contract, + ReadName: r.readName, + ReturnVal: returnValue, + ErrOnMissingAccountData: r.errOnMissingAccountData, } if r.useParams { batch[idx].Params = params } } - results, err := doMethodBatchCall(ctx, client, bdRegistry, batch) + results, err := doMethodBatchCall(ctx, lggr, client, bdRegistry, batch) if err != nil { return err } @@ -69,15 +72,15 @@ func doMultiRead(ctx context.Context, client MultipleAccountGetter, bdRegistry * return nil } -func doMethodBatchCall(ctx context.Context, client MultipleAccountGetter, bdRegistry *bindingsRegistry, batch []call) ([]batchResultWithErr, error) { +func doMethodBatchCall(ctx context.Context, lggr logger.Logger, client MultipleAccountGetter, bdRegistry *bindingsRegistry, batch []call) ([]batchResultWithErr, error) { results := make([]batchResultWithErr, len(batch)) // create the list of public keys to fetch + // Solana RPC expects at least an empty list, not nil keys := []solana.PublicKey{} // map batch call index to key index (some calls are event reads and will be handled by a different binding) dataMap := make(map[int]int) - for idx, batchCall := range batch { rBinding, err := bdRegistry.GetReader(batchCall.Namespace, batchCall.ReadName) if err != nil { @@ -129,16 +132,18 @@ func doMethodBatchCall(ctx context.Context, client MultipleAccountGetter, bdRegi returnVal: batchCall.ReturnVal, } - if data[dataIdx] == nil || len(data[dataIdx]) == 0 { - results[idx].err = ErrMissingAccountData - + if len(data[idx]) == 0 { + if batchCall.ErrOnMissingAccountData { + results[idx].err = ErrMissingAccountData + continue + } + lggr.Infow("failed to find account, returning zero value instead", "namespace", batchCall.Namespace, "readName", batchCall.ReadName, "address", keys[dataIdx].String()) continue } rBinding, err := bdRegistry.GetReader(results[idx].namespace, results[idx].readName) if err != nil { results[idx].err = err - continue } diff --git a/pkg/solana/chainreader/chain_reader.go b/pkg/solana/chainreader/chain_reader.go index bd59d4b45..482c6da20 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" @@ -183,7 +184,7 @@ func (s *ContractReaderService) GetLatestValue(ctx context.Context, readIdentifi } if len(values.reads) > 1 { - return doMultiRead(ctx, s.client, s.bdRegistry, values, params, returnVal) + return doMultiRead(ctx, s.lggr, s.client, s.bdRegistry, values, params, returnVal) } // TODO this is a temporary edge case - NONEVM-1320 @@ -196,14 +197,15 @@ func (s *ContractReaderService) GetLatestValue(ctx context.Context, readIdentifi batch := []call{ { - Namespace: values.contract, - ReadName: values.reads[0].readName, - Params: params, - ReturnVal: returnVal, + Namespace: values.contract, + ReadName: values.reads[0].readName, + Params: params, + ReturnVal: returnVal, + ErrOnMissingAccountData: values.reads[0].errOnMissingAccountData, }, } - results, err := doMethodBatchCall(ctx, s.client, s.bdRegistry, batch) + results, err := doMethodBatchCall(ctx, s.lggr, s.client, s.bdRegistry, batch) if err != nil { return err } @@ -244,7 +246,7 @@ func (s *ContractReaderService) BatchGetLatestValues(ctx context.Context, reques // exclude multi read reads from the big batch request and populate them separately and merge results later. if len(vals.reads) > 1 { - err := doMultiRead(ctx, s.client, s.bdRegistry, vals, readReq.Params, readReq.ReturnVal) + err := doMultiRead(ctx, s.lggr, s.client, s.bdRegistry, vals, readReq.Params, readReq.ReturnVal) multiIdxLookup[bound][idx] = len(multiReadResults) multiReadResults = append(multiReadResults, batchResultWithErr{address: vals.address, namespace: vals.contract, readName: readReq.ReadName, returnVal: readReq.ReturnVal, err: err}) @@ -260,15 +262,16 @@ func (s *ContractReaderService) BatchGetLatestValues(ctx context.Context, reques } batch = append(batch, call{ - Namespace: bound.Name, - ReadName: readReq.ReadName, - Params: readReq.Params, - ReturnVal: readReq.ReturnVal, + Namespace: bound.Name, + ReadName: readReq.ReadName, + Params: readReq.Params, + ReturnVal: readReq.ReturnVal, + ErrOnMissingAccountData: vals.reads[0].errOnMissingAccountData, }) } } - results, err := doMethodBatchCall(ctx, s.client, s.bdRegistry, batch) + results, err := doMethodBatchCall(ctx, s.lggr, s.client, s.bdRegistry, batch) if err != nil { return nil, err } @@ -450,7 +453,7 @@ func (s *ContractReaderService) initNamespace(namespaces map[string]config.Chain } func (s *ContractReaderService) addAccountRead(namespace string, genericName string, idl codec.IDL, outputIDLDef codec.IdlTypeDef, readDefinition config.ReadDefinition) error { - reads := []read{{readName: genericName, useParams: true}} + reads := []read{{readName: genericName, useParams: true, errOnMissingAccountData: readDefinition.ErrOnMissingAccountData}} if readDefinition.MultiReader != nil { multiRead, err := s.addMultiAccountReadToCodec(namespace, readDefinition, idl) if err != nil { @@ -521,8 +524,9 @@ func (s *ContractReaderService) addMultiAccountReadToCodec(namespace string, rea s.bdRegistry.AddReader(namespace, genericName, newAccountReadBinding(namespace, genericName, isPDA, mr.PDADefinition.Prefix, idl, inputIDLDef, accountIDLDef, readDefinition)) reads = append(reads, read{ - readName: genericName, - useParams: readDefinition.MultiReader.ReuseParams, + readName: genericName, + useParams: readDefinition.MultiReader.ReuseParams, + errOnMissingAccountData: mr.ErrOnMissingAccountData, }) } @@ -708,6 +712,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 @@ -717,7 +726,35 @@ 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 we got back an empty account then the account must not exist yet, use zero value + if len(d) > 0 { + 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 { @@ -753,8 +790,11 @@ func (s *ContractReaderService) handleGetTokenPricesGetLatestValue( for _, d := range data { var wrapper fee_quoter.BillingTokenConfigWrapper - if err = wrapper.UnmarshalWithDecoder(bin.NewBorshDecoder(d)); err != nil { - return err + // if we got back an empty account then the account must not exist yet, use zero value + if len(d) > 0 { + if err = wrapper.UnmarshalWithDecoder(bin.NewBorshDecoder(d)); err != nil { + return err + } } newElemPtr := reflect.New(underlyingStruct) @@ -783,25 +823,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, ) } @@ -816,7 +877,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( diff --git a/pkg/solana/chainreader/chain_reader_test.go b/pkg/solana/chainreader/chain_reader_test.go index eb905b6bf..e087047f9 100644 --- a/pkg/solana/chainreader/chain_reader_test.go +++ b/pkg/solana/chainreader/chain_reader_test.go @@ -16,11 +16,12 @@ import ( "github.com/cometbft/cometbft/libs/service" "github.com/gagliardetto/solana-go" "github.com/google/uuid" - "github.com/smartcontractkit/chainlink-common/pkg/sqlutil/sqltest" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/mock" "github.com/stretchr/testify/require" + "github.com/smartcontractkit/chainlink-common/pkg/sqlutil/sqltest" + "github.com/smartcontractkit/libocr/commontypes" codeccommon "github.com/smartcontractkit/chainlink-common/pkg/codec" @@ -620,8 +621,9 @@ func newTestConfAndCodec(t *testing.T) (types.RemoteCodec, config.ContractReader IDL: mustUnmarshalIDL(t, rawIDL), Reads: map[string]config.ReadDefinition{ NamedMethod: { - ChainSpecificName: testutils.TestStructWithNestedStruct, - ReadType: config.Account, + ChainSpecificName: testutils.TestStructWithNestedStruct, + ReadType: config.Account, + ErrOnMissingAccountData: true, OutputModifications: codeccommon.ModifiersConfig{ &codeccommon.RenameModifierConfig{Fields: map[string]string{"Value": "V"}}, }, diff --git a/pkg/solana/chainreader/client_wrapper.go b/pkg/solana/chainreader/client_wrapper.go index 4755d93d6..8196e1cde 100644 --- a/pkg/solana/chainreader/client_wrapper.go +++ b/pkg/solana/chainreader/client_wrapper.go @@ -28,18 +28,10 @@ func (w *RPCClientWrapper) GetMultipleAccountData(ctx context.Context, keys ...s bts := make([][]byte, len(result.Value)) for idx, res := range result.Value { - if res == nil { - return nil, rpc.ErrNotFound + if res == nil || res.Data == nil || res.Data.GetBinary() == nil { + // any accounts that can't be resolved will be nil + continue } - - if res.Data == nil { - return nil, rpc.ErrNotFound - } - - if res.Data.GetBinary() == nil { - return nil, rpc.ErrNotFound - } - bts[idx] = res.Data.GetBinary() } diff --git a/pkg/solana/chainreader/lookup.go b/pkg/solana/chainreader/lookup.go index ef7b1a551..1ca9c6dc4 100644 --- a/pkg/solana/chainreader/lookup.go +++ b/pkg/solana/chainreader/lookup.go @@ -9,7 +9,7 @@ import ( type read struct { readName string // useParams is used when this read is part of a multi read to determine if it should use parent read params. - useParams bool + useParams, errOnMissingAccountData bool } type readValues struct { diff --git a/pkg/solana/chainwriter/chain_writer.go b/pkg/solana/chainwriter/chain_writer.go index 169824cf0..9ba2c1e5f 100644 --- a/pkg/solana/chainwriter/chain_writer.go +++ b/pkg/solana/chainwriter/chain_writer.go @@ -155,7 +155,7 @@ func GetAddresses(ctx context.Context, args any, accounts []Lookup, derivedTable var addresses []*solana.AccountMeta for _, accountConfig := range accounts { meta, err := accountConfig.Resolve(ctx, args, derivedTableMap, client) - if accountConfig.Optional && err != nil { + if accountConfig.Optional && err != nil && isIgnorableError(err) { // skip optional accounts if they are not found continue } @@ -167,6 +167,13 @@ func GetAddresses(ctx context.Context, args any, accounts []Lookup, derivedTable return addresses, nil } +// These errors are ignorable if the lookup is optional. +func isIgnorableError(err error) bool { + return errors.Is(err, ErrLookupNotFoundAtLocation) || + errors.Is(err, ErrLookupTableNotFound) || + errors.Is(err, ErrGettingSeedAtLocation) +} + // FilterLookupTableAddresses takes a list of accounts and two lookup table maps // (one for derived tables, one for static tables) and filters out any addresses that are // not used by the accounts. It returns a map of only those lookup table @@ -281,7 +288,7 @@ func CreateATAs(ctx context.Context, args any, lookups []ATALookup, derivedTable Commitment: rpc.CommitmentFinalized, }) if err == nil { - logger.Info("ATA already exists, skipping creation.", lookup.Location) + logger.Infow("ATA already exists, skipping creation.", "location", lookup.Location) continue } if !strings.Contains(err.Error(), "not found") { @@ -348,7 +355,7 @@ func (s *SolanaChainWriterService) SubmitTransaction(ctx context.Context, contra return errorWithDebugID(fmt.Errorf("error getting lookup tables: %w", err), debugID) } - s.lggr.Debug("Resolving account addresses", "contract", contractName, "method", method) + s.lggr.Debugw("Resolving account addresses", "contract", contractName, "method", method) // Resolve account metas accounts, err := GetAddresses(ctx, args, methodConfig.Accounts, derivedTableMap, s.client) if err != nil { @@ -360,13 +367,13 @@ func (s *SolanaChainWriterService) SubmitTransaction(ctx context.Context, contra return errorWithDebugID(fmt.Errorf("error parsing fee payer address: %w", err), debugID) } - s.lggr.Debug("Creating ATAs", "contract", contractName, "method", method) + s.lggr.Debugw("Creating ATAs", "contract", contractName, "method", method) createATAinstructions, err := CreateATAs(ctx, args, methodConfig.ATAs, derivedTableMap, s.client, programConfig.IDL, feePayer, s.lggr) if err != nil { return errorWithDebugID(fmt.Errorf("error resolving account addresses: %w", err), debugID) } - s.lggr.Debug("Filtering lookup table addresses", "contract", contractName, "method", method) + s.lggr.Debugw("Filtering lookup table addresses", "contract", contractName, "method", method) // Filter the lookup table addresses based on which accounts are actually used filteredLookupTableMap := s.FilterLookupTableAddresses(accounts, derivedTableMap, staticTableMap) @@ -376,7 +383,7 @@ func (s *SolanaChainWriterService) SubmitTransaction(ctx context.Context, contra if tfErr != nil { return errorWithDebugID(fmt.Errorf("error finding transform function: %w", tfErr), debugID) } - s.lggr.Debug("Applying args transformation", "contract", contractName, "method", method) + s.lggr.Debugw("Applying args transformation", "contract", contractName, "method", method) args, err = transformFunc(ctx, s, args, accounts, toAddress) if err != nil { return errorWithDebugID(fmt.Errorf("error transforming args: %w", err), debugID) @@ -389,7 +396,7 @@ func (s *SolanaChainWriterService) SubmitTransaction(ctx context.Context, contra return errorWithDebugID(fmt.Errorf("error parsing program ID: %w", err), debugID) } - s.lggr.Debug("Encoding transaction payload", "contract", contractName, "method", method) + s.lggr.Debugw("Encoding transaction payload", "contract", contractName, "method", method) encodedPayload, err := s.encoder.Encode(ctx, args, codec.WrapItemType(true, contractName, method)) if err != nil { @@ -422,7 +429,7 @@ func (s *SolanaChainWriterService) SubmitTransaction(ctx context.Context, contra return errorWithDebugID(fmt.Errorf("error constructing transaction: %w", err), debugID) } - s.lggr.Debug("Sending main transaction", "contract", contractName, "method", method) + s.lggr.Debugw("Sending main transaction", "contract", contractName, "method", method) // Enqueue transaction if err = s.txm.Enqueue(ctx, methodConfig.FromAddress, tx, &transactionID, blockhash.Value.LastValidBlockHeight); err != nil { return errorWithDebugID(fmt.Errorf("error enqueuing transaction: %w", err), debugID) @@ -433,7 +440,7 @@ func (s *SolanaChainWriterService) SubmitTransaction(ctx context.Context, contra // GetTransactionStatus returns the current status of a transaction in the underlying chain's TXM. func (s *SolanaChainWriterService) GetTransactionStatus(ctx context.Context, transactionID string) (types.TransactionStatus, error) { - s.lggr.Debug("Fetching transaction status", "transactionID", transactionID) + s.lggr.Debugw("Fetching transaction status", "transactionID", transactionID) return s.txm.GetTransactionStatus(ctx, transactionID) } @@ -447,7 +454,7 @@ func (s *SolanaChainWriterService) GetFeeComponents(ctx context.Context) (*types fee := s.ge.BaseComputeUnitPrice() return &types.ChainFeeComponents{ ExecutionFee: new(big.Int).SetUint64(fee), - DataAvailabilityFee: nil, + DataAvailabilityFee: big.NewInt(0), // required field so return 0 instead of nil }, nil } @@ -460,7 +467,7 @@ func (s *SolanaChainWriterService) ResolveLookupTables(ctx context.Context, args // Load the lookup table - note: This could be multiple tables if the lookup is a PDALookups that resolves to more // than one address lookupTableMap, err := s.loadTable(ctx, args, derivedLookup) - if derivedLookup.Optional && err != nil { + if derivedLookup.Optional && err != nil && isIgnorableError(err) { continue } if err != nil { diff --git a/pkg/solana/chainwriter/chain_writer_test.go b/pkg/solana/chainwriter/chain_writer_test.go index 17732416e..78ef1754d 100644 --- a/pkg/solana/chainwriter/chain_writer_test.go +++ b/pkg/solana/chainwriter/chain_writer_test.go @@ -1055,11 +1055,11 @@ func TestChainWriter_GetFeeComponents(t *testing.T) { cw, err := chainwriter.NewSolanaChainWriterService(testutils.NewNullLogger(), mc, txm, ge, chainwriter.ChainWriterConfig{}) require.NoError(t, err) - t.Run("returns valid compute unit price", func(t *testing.T) { + t.Run("returns valid compute unit price and non-nil data availability fee", func(t *testing.T) { feeComponents, err := cw.GetFeeComponents(ctx) require.NoError(t, err) require.Equal(t, big.NewInt(100), feeComponents.ExecutionFee) - require.Nil(t, feeComponents.DataAvailabilityFee) // always nil for Solana + require.Equal(t, big.NewInt(0), feeComponents.DataAvailabilityFee) // always nil for Solana }) t.Run("fails if gas estimator not set", func(t *testing.T) { diff --git a/pkg/solana/chainwriter/lookups.go b/pkg/solana/chainwriter/lookups.go index 2ae81b16f..e3782b56d 100644 --- a/pkg/solana/chainwriter/lookups.go +++ b/pkg/solana/chainwriter/lookups.go @@ -13,6 +13,12 @@ import ( "github.com/smartcontractkit/chainlink-solana/pkg/solana/codec" ) +var ( + ErrLookupNotFoundAtLocation = fmt.Errorf("error getting account from lookup") + ErrLookupTableNotFound = fmt.Errorf("lookup table not found") + ErrGettingSeedAtLocation = fmt.Errorf("error getting address seed for location") +) + type Lookup struct { Optional bool AccountConstant *AccountConstant `json:"accountConstant,omitempty"` @@ -146,7 +152,7 @@ func (ac AccountConstant) Resolve() ([]*solana.AccountMeta, error) { func (al AccountLookup) Resolve(args any) ([]*solana.AccountMeta, error) { derivedValues, err := GetValuesAtLocation(args, al.Location) if err != nil { - return nil, fmt.Errorf("error getting account from lookup: %w", err) + return nil, fmt.Errorf("%w: %v", ErrLookupNotFoundAtLocation, err) } var metas []*solana.AccountMeta @@ -206,7 +212,7 @@ func (alt AccountsFromLookupTable) Resolve(derivedTableMap map[string]map[string // Fetch the inner map for the specified lookup table name innerMap, ok := derivedTableMap[alt.LookupTableName] if !ok { - return nil, fmt.Errorf("lookup table not found: %s", alt.LookupTableName) + return nil, fmt.Errorf("%w: %s", ErrLookupTableNotFound, alt.LookupTableName) } var result []*solana.AccountMeta @@ -332,7 +338,7 @@ func getSeedBytesCombinations( // Get value from a location (This doesn't have to be an address, it can be any value) bytes, err := GetValuesAtLocation(args, lookupSeed.Location) if err != nil { - return nil, fmt.Errorf("error getting address seed for location %q: %w", lookupSeed.Location, err) + return nil, fmt.Errorf("%w %q: %w", ErrGettingSeedAtLocation, lookupSeed.Location, err) } // append each byte array to the expansions for _, b := range bytes { diff --git a/pkg/solana/config/chain_reader.go b/pkg/solana/config/chain_reader.go index dfeae9e6d..7dd6921a9 100644 --- a/pkg/solana/config/chain_reader.go +++ b/pkg/solana/config/chain_reader.go @@ -83,13 +83,14 @@ type MultiReader struct { } type ReadDefinition struct { - ChainSpecificName string `json:"chainSpecificName"` - ReadType ReadType `json:"readType,omitempty"` - InputModifications commoncodec.ModifiersConfig `json:"inputModifications,omitempty"` - OutputModifications commoncodec.ModifiersConfig `json:"outputModifications,omitempty"` - PDADefinition codec.PDATypeDef `json:"pdaDefinition,omitempty"` // Only used for PDA account reads - MultiReader *MultiReader `json:"multiReader,omitempty"` - EventDefinitions *EventDefinitions `json:"eventDefinitions,omitempty"` + ChainSpecificName string `json:"chainSpecificName"` + ReadType ReadType `json:"readType,omitempty"` + ErrOnMissingAccountData bool `json:"errOnMissingAccountData,omitempty"` + InputModifications commoncodec.ModifiersConfig `json:"inputModifications,omitempty"` + OutputModifications commoncodec.ModifiersConfig `json:"outputModifications,omitempty"` + PDADefinition codec.PDATypeDef `json:"pdaDefinition,omitempty"` // Only used for PDA account reads + MultiReader *MultiReader `json:"multiReader,omitempty"` + EventDefinitions *EventDefinitions `json:"eventDefinitions,omitempty"` // ResponseAddressHardCoder hardcodes the address of the contract into the defined field in the response. ResponseAddressHardCoder *commoncodec.HardCodeModifierConfig `json:"responseAddressHardCoder,omitempty"` }