From 81a855ec1fc656e37b890ce46d598b0bd7aa40a7 Mon Sep 17 00:00:00 2001 From: Jordan Krage Date: Fri, 17 Jan 2025 13:55:12 -0600 Subject: [PATCH 1/3] pkg/solana: expand HealthReport (#1019) --- pkg/solana/chain.go | 8 +++++++- pkg/solana/client/multinode/multi_node.go | 4 ++++ pkg/solana/client/multinode/transaction_sender.go | 4 ++++ 3 files changed, 15 insertions(+), 1 deletion(-) diff --git a/pkg/solana/chain.go b/pkg/solana/chain.go index d137e75de..4492cb15d 100644 --- a/pkg/solana/chain.go +++ b/pkg/solana/chain.go @@ -229,11 +229,12 @@ func (v *verifiedCachedClient) GetAccountInfoWithOpts(ctx context.Context, addr } func newChain(id string, cfg *config.TOMLConfig, ks core.Keystore, lggr logger.Logger, ds sqlutil.DataSource) (*chain, error) { + lggr = logger.Named(lggr, "Chain") lggr = logger.With(lggr, "chainID", id, "chain", "solana") var ch = chain{ id: id, cfg: cfg, - lggr: logger.Named(lggr, "Chain"), + lggr: lggr, clientCache: map[string]*verifiedCachedClient{}, } @@ -531,6 +532,11 @@ func (c *chain) Ready() error { func (c *chain) HealthReport() map[string]error { report := map[string]error{c.Name(): c.Healthy()} services.CopyHealth(report, c.txm.HealthReport()) + services.CopyHealth(report, c.balanceMonitor.HealthReport()) + if c.cfg.MultiNode.Enabled() { + report[c.multiNode.Name()] = c.multiNode.Healthy() + report[c.txSender.Name()] = c.txSender.Healthy() + } return report } diff --git a/pkg/solana/client/multinode/multi_node.go b/pkg/solana/client/multinode/multi_node.go index fa05a75cd..02bec62c4 100644 --- a/pkg/solana/client/multinode/multi_node.go +++ b/pkg/solana/client/multinode/multi_node.go @@ -86,6 +86,10 @@ func NewMultiNode[ return c } +func (c *MultiNode[CHAIN_ID, RPC]) Name() string { + return c.lggr.Name() +} + func (c *MultiNode[CHAIN_ID, RPC]) ChainID() CHAIN_ID { return c.chainID } diff --git a/pkg/solana/client/multinode/transaction_sender.go b/pkg/solana/client/multinode/transaction_sender.go index 06b2e18be..60d7e74df 100644 --- a/pkg/solana/client/multinode/transaction_sender.go +++ b/pkg/solana/client/multinode/transaction_sender.go @@ -72,6 +72,10 @@ type TransactionSender[TX any, RESULT SendTxResult, CHAIN_ID ID, RPC SendTxRPCCl chStop services.StopChan } +func (txSender *TransactionSender[TX, RESULT, CHAIN_ID, RPC]) Name() string { + return txSender.lggr.Name() +} + // SendTransaction - broadcasts transaction to all the send-only and primary nodes in MultiNode. // A returned nil or error does not guarantee that the transaction will or won't be included. Additional checks must be // performed to determine the final state. From 933f88fd23aa4a967574e251c8c0a11ea5206075 Mon Sep 17 00:00:00 2001 From: amit-momin <108959691+amit-momin@users.noreply.github.com> Date: Fri, 17 Jan 2025 14:16:56 -0600 Subject: [PATCH 2/3] Enable ChainReader to read PDA account state (#1003) * Implemented PDA account reading in ChainReader * Fixed linting * Updated PDA account reads to use the codec to encode seeds in params * Removed string seed encoder * Reverted moving ChainWriter helper methods to utils * Updated the PDA codec entry to use the existing builder methods * Removed duplicated public key encoder * Added builder method for IDL string types * Updated PDA account read test to use input modifier * Moved PDA seed IDL type builder out of the config * Added new PDA account read unit tests * Fixed linting * Added back encoder codec def for normal account reads * Merged the pda read binding with the existing account read binding --- .../chainreader/account_read_binding.go | 60 ++++- pkg/solana/chainreader/batch.go | 7 +- pkg/solana/chainreader/bindings.go | 2 +- pkg/solana/chainreader/bindings_test.go | 4 +- pkg/solana/chainreader/chain_reader.go | 27 +- pkg/solana/chainreader/chain_reader_test.go | 234 +++++++++++++++++- pkg/solana/codec/anchoridl.go | 18 ++ pkg/solana/codec/codec_entry.go | 27 ++ pkg/solana/codec/solana.go | 2 + pkg/solana/config/chain_reader.go | 1 + pkg/solana/config/chain_reader_test.go | 6 + 11 files changed, 362 insertions(+), 26 deletions(-) diff --git a/pkg/solana/chainreader/account_read_binding.go b/pkg/solana/chainreader/account_read_binding.go index eacd45fad..b8854b38c 100644 --- a/pkg/solana/chainreader/account_read_binding.go +++ b/pkg/solana/chainreader/account_read_binding.go @@ -2,6 +2,7 @@ package chainreader import ( "context" + "fmt" "github.com/gagliardetto/solana-go" @@ -15,12 +16,16 @@ type accountReadBinding struct { namespace, genericName string codec types.RemoteCodec key solana.PublicKey + isPda bool // flag to signify whether or not the account read is for a PDA + prefix string // only used for PDA public key calculation } -func newAccountReadBinding(namespace, genericName string) *accountReadBinding { +func newAccountReadBinding(namespace, genericName, prefix string, isPda bool) *accountReadBinding { return &accountReadBinding{ namespace: namespace, genericName: genericName, + prefix: prefix, + isPda: isPda, } } @@ -34,8 +39,21 @@ func (b *accountReadBinding) SetAddress(key solana.PublicKey) { b.key = key } -func (b *accountReadBinding) GetAddress() solana.PublicKey { - return b.key +func (b *accountReadBinding) GetAddress(ctx context.Context, params any) (solana.PublicKey, error) { + // Return the bound key if normal account read + if !b.isPda { + return b.key, nil + } + // Calculate the public key if PDA account read + seedBytes, err := b.buildSeedsSlice(ctx, params) + if err != nil { + return solana.PublicKey{}, fmt.Errorf("failed build seeds list for PDA calculation: %w", err) + } + key, _, err := solana.FindProgramAddress(seedBytes, b.key) + if err != nil { + return solana.PublicKey{}, fmt.Errorf("failed find program address for PDA: %w", err) + } + return key, nil } func (b *accountReadBinding) CreateType(forEncoding bool) (any, error) { @@ -45,3 +63,39 @@ func (b *accountReadBinding) CreateType(forEncoding bool) (any, error) { func (b *accountReadBinding) Decode(ctx context.Context, bts []byte, outVal any) error { return b.codec.Decode(ctx, bts, outVal, codec.WrapItemType(false, b.namespace, b.genericName, codec.ChainConfigTypeAccountDef)) } + +// buildSeedsSlice encodes and builds the seedslist to calculate the PDA public key +func (b *accountReadBinding) buildSeedsSlice(ctx context.Context, params any) ([][]byte, error) { + flattenedSeeds := make([]byte, 0, solana.MaxSeeds*solana.MaxSeedLength) + // Append the static prefix string first + flattenedSeeds = append(flattenedSeeds, []byte(b.prefix)...) + // Encode the seeds provided in the params + encodedParamSeeds, err := b.codec.Encode(ctx, params, codec.WrapItemType(true, b.namespace, b.genericName, "")) + if err != nil { + return nil, fmt.Errorf("failed to encode params into bytes for PDA seeds: %w", err) + } + // Append the encoded seeds + flattenedSeeds = append(flattenedSeeds, encodedParamSeeds...) + + if len(flattenedSeeds) > solana.MaxSeeds*solana.MaxSeedLength { + return nil, fmt.Errorf("seeds exceed the maximum allowed length") + } + + // Splitting the seeds since they are expected to be provided separately to FindProgramAddress + // Arbitrarily separating the seeds at max seed length would still yield the same PDA since + // FindProgramAddress appends the seed bytes together under the hood + numSeeds := len(flattenedSeeds) / solana.MaxSeedLength + if len(flattenedSeeds)%solana.MaxSeedLength != 0 { + numSeeds++ + } + seedByteArray := make([][]byte, 0, numSeeds) + for i := 0; i < numSeeds; i++ { + startIdx := i * solana.MaxSeedLength + endIdx := startIdx + solana.MaxSeedLength + if endIdx > len(flattenedSeeds) { + endIdx = len(flattenedSeeds) + } + seedByteArray = append(seedByteArray, flattenedSeeds[startIdx:endIdx]) + } + return seedByteArray, nil +} diff --git a/pkg/solana/chainreader/batch.go b/pkg/solana/chainreader/batch.go index d5990601d..91995bb80 100644 --- a/pkg/solana/chainreader/batch.go +++ b/pkg/solana/chainreader/batch.go @@ -3,6 +3,7 @@ package chainreader import ( "context" "errors" + "fmt" "github.com/gagliardetto/solana-go" @@ -38,7 +39,11 @@ func doMethodBatchCall(ctx context.Context, client MultipleAccountGetter, bindin return nil, err } - keys[idx] = binding.GetAddress() + key, err := binding.GetAddress(ctx, call.Params) + if err != nil { + return nil, fmt.Errorf("failed to get address for %s account read: %w", call.ReadName, err) + } + keys[idx] = key } // Fetch the account data diff --git a/pkg/solana/chainreader/bindings.go b/pkg/solana/chainreader/bindings.go index 751a58fdd..1b927df85 100644 --- a/pkg/solana/chainreader/bindings.go +++ b/pkg/solana/chainreader/bindings.go @@ -11,7 +11,7 @@ import ( type readBinding interface { SetAddress(solana.PublicKey) - GetAddress() solana.PublicKey + GetAddress(context.Context, any) (solana.PublicKey, error) SetCodec(types.RemoteCodec) CreateType(bool) (any, error) Decode(context.Context, []byte, any) error diff --git a/pkg/solana/chainreader/bindings_test.go b/pkg/solana/chainreader/bindings_test.go index e8dbea89a..3dec21194 100644 --- a/pkg/solana/chainreader/bindings_test.go +++ b/pkg/solana/chainreader/bindings_test.go @@ -50,8 +50,8 @@ func (_m *mockBinding) SetCodec(_ types.RemoteCodec) {} func (_m *mockBinding) SetAddress(_ solana.PublicKey) {} -func (_m *mockBinding) GetAddress() solana.PublicKey { - return solana.PublicKey{} +func (_m *mockBinding) GetAddress(_ context.Context, _ any) (solana.PublicKey, error) { + return solana.PublicKey{}, nil } func (_m *mockBinding) CreateType(b bool) (any, error) { diff --git a/pkg/solana/chainreader/chain_reader.go b/pkg/solana/chainreader/chain_reader.go index 1edcb9b8e..41d70161b 100644 --- a/pkg/solana/chainreader/chain_reader.go +++ b/pkg/solana/chainreader/chain_reader.go @@ -284,25 +284,26 @@ func (s *SolanaChainReaderService) init(namespaces map[string]config.ChainContra } func (s *SolanaChainReaderService) addAccountRead(namespace string, genericName string, idl codec.IDL, idlType codec.IdlTypeDef, readDefinition config.ReadDefinition) error { - inputAccountIDLDef := codec.NilIdlTypeDefTy - // TODO: - // if hasPDA{ - // inputAccountIDLDef = pdaType - // } - if err := s.addCodecDef(true, namespace, genericName, codec.ChainConfigTypeAccountDef, idl, inputAccountIDLDef, readDefinition.InputModifications); err != nil { - return err - } - if err := s.addCodecDef(false, namespace, genericName, codec.ChainConfigTypeAccountDef, idl, idlType, readDefinition.OutputModifications); err != nil { return err } s.lookup.addReadNameForContract(namespace, genericName) - s.bindings.AddReadBinding(namespace, genericName, newAccountReadBinding( - namespace, - genericName, - )) + var reader readBinding + var inputAccountIDLDef interface{} + // Create PDA read binding if PDA prefix or seeds configs are populated + if len(readDefinition.PDADefiniton.Prefix) > 0 || len(readDefinition.PDADefiniton.Seeds) > 0 { + inputAccountIDLDef = readDefinition.PDADefiniton + reader = newAccountReadBinding(namespace, genericName, readDefinition.PDADefiniton.Prefix, true) + } else { + inputAccountIDLDef = codec.NilIdlTypeDefTy + reader = newAccountReadBinding(namespace, genericName, "", false) + } + if err := s.addCodecDef(true, namespace, genericName, codec.ChainConfigTypeAccountDef, idl, inputAccountIDLDef, readDefinition.InputModifications); err != nil { + return err + } + s.bindings.AddReadBinding(namespace, genericName, reader) return nil } diff --git a/pkg/solana/chainreader/chain_reader_test.go b/pkg/solana/chainreader/chain_reader_test.go index de37567b6..019ef3e09 100644 --- a/pkg/solana/chainreader/chain_reader_test.go +++ b/pkg/solana/chainreader/chain_reader_test.go @@ -2,6 +2,7 @@ package chainreader_test import ( "context" + go_binary "encoding/binary" "encoding/json" "fmt" "math/big" @@ -13,7 +14,6 @@ import ( "time" "github.com/gagliardetto/solana-go" - ag_solana "github.com/gagliardetto/solana-go" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -38,6 +38,7 @@ import ( const ( Namespace = "NameSpace" NamedMethod = "NamedMethod1" + PDAAccount = "PDAAccount1" ) func TestSolanaChainReaderService_ReaderInterface(t *testing.T) { @@ -222,7 +223,7 @@ func TestSolanaChainReaderService_GetLatestValue(t *testing.T) { require.NoError(t, svc.Close()) }) - pk := ag_solana.NewWallet().PublicKey() + pk := solana.NewWallet().PublicKey() require.NotNil(t, svc.Bind(ctx, []types.BoundContract{ { @@ -266,6 +267,204 @@ func TestSolanaChainReaderService_GetLatestValue(t *testing.T) { }, })) }) + + t.Run("PDA account read success", func(t *testing.T) { + t.Parallel() + + programID := solana.NewWallet().PublicKey() + pubKey := solana.NewWallet().PublicKey() + uint64Seed := uint64(5) + prefixString := "Prefix" + + readDef := config.ReadDefinition{ + ChainSpecificName: testutils.TestStructWithNestedStruct, + ReadType: config.Account, + OutputModifications: codeccommon.ModifiersConfig{ + &codeccommon.RenameModifierConfig{Fields: map[string]string{"Value": "V"}}, + }, + } + + testCases := []struct { + name string + pdaDefinition codec.PDATypeDef + inputModifier codeccommon.ModifiersConfig + expected solana.PublicKey + params map[string]any + }{ + { + name: "happy path", + pdaDefinition: codec.PDATypeDef{ + Prefix: prefixString, + Seeds: []codec.PDASeed{ + { + Name: "PubKey", + Type: codec.IdlTypePublicKey, + }, + { + Name: "Uint64Seed", + Type: codec.IdlTypeU64, + }, + }, + }, + expected: mustFindProgramAddress(t, programID, [][]byte{[]byte(prefixString), pubKey.Bytes(), go_binary.LittleEndian.AppendUint64([]byte{}, uint64Seed)}), + params: map[string]any{ + "PubKey": pubKey, + "Uint64Seed": uint64Seed, + }, + }, + { + name: "with modifier and random field", + pdaDefinition: codec.PDATypeDef{ + Prefix: prefixString, + Seeds: []codec.PDASeed{ + { + Name: "PubKey", + Type: codec.IdlTypePublicKey, + }, + { + Name: "Uint64Seed", + Type: codec.IdlTypeU64, + }, + }, + }, + inputModifier: codeccommon.ModifiersConfig{ + &codeccommon.RenameModifierConfig{Fields: map[string]string{"PubKey": "PublicKey"}}, + }, + expected: mustFindProgramAddress(t, programID, [][]byte{[]byte(prefixString), pubKey.Bytes(), go_binary.LittleEndian.AppendUint64([]byte{}, uint64Seed)}), + params: map[string]any{ + "PublicKey": pubKey, + "randomField": "randomValue", // unused field should be ignored by the codec + "Uint64Seed": uint64Seed, + }, + }, + { + name: "only prefix", + pdaDefinition: codec.PDATypeDef{ + Prefix: prefixString, + }, + expected: mustFindProgramAddress(t, programID, [][]byte{[]byte(prefixString)}), + params: nil, + }, + { + name: "no prefix", + pdaDefinition: codec.PDATypeDef{ + Prefix: "", + Seeds: []codec.PDASeed{ + { + Name: "PubKey", + Type: codec.IdlTypePublicKey, + }, + { + Name: "Uint64Seed", + Type: codec.IdlTypeU64, + }, + }, + }, + expected: mustFindProgramAddress(t, programID, [][]byte{pubKey.Bytes(), go_binary.LittleEndian.AppendUint64([]byte{}, uint64Seed)}), + params: map[string]any{ + "PubKey": pubKey, + "Uint64Seed": uint64Seed, + }, + }, + { + name: "public key seed provided as bytes", + pdaDefinition: codec.PDATypeDef{ + Prefix: prefixString, + Seeds: []codec.PDASeed{ + { + Name: "PubKey", + Type: codec.IdlTypePublicKey, + }, + }, + }, + expected: mustFindProgramAddress(t, programID, [][]byte{[]byte(prefixString), pubKey.Bytes()}), + params: map[string]any{ + "PubKey": pubKey.Bytes(), + }, + }, + } + + for _, testCase := range testCases { + t.Run(testCase.name, func(t *testing.T) { + testReadDef := readDef + testReadDef.PDADefiniton = testCase.pdaDefinition + testReadDef.InputModifications = testCase.inputModifier + testCodec, conf := newTestConfAndCodecWithInjectibleReadDef(t, PDAAccount, testReadDef) + encoded, err := testCodec.Encode(ctx, expected, testutils.TestStructWithNestedStruct) + require.NoError(t, err) + + client := new(mockedRPCClient) + svc, err := chainreader.NewChainReaderService(logger.Test(t), client, conf) + require.NoError(t, err) + require.NotNil(t, svc) + require.NoError(t, svc.Start(ctx)) + + t.Cleanup(func() { + require.NoError(t, svc.Close()) + }) + + binding := types.BoundContract{ + Name: Namespace, + Address: programID.String(), // Set the program ID used to calculate the PDA + } + + client.SetForAddress(testCase.expected, encoded, nil, 0) + + require.NoError(t, svc.Bind(ctx, []types.BoundContract{binding})) + + var result modifiedStructWithNestedStruct + require.NoError(t, svc.GetLatestValue(ctx, binding.ReadIdentifier(PDAAccount), primitives.Unconfirmed, testCase.params, &result)) + + assert.Equal(t, expected.InnerStruct, result.InnerStruct) + assert.Equal(t, expected.Value, result.V) + assert.Equal(t, expected.TimeVal, result.TimeVal) + assert.Equal(t, expected.DurationVal, result.DurationVal) + }) + } + }) + + t.Run("PDA account read errors if missing param", func(t *testing.T) { + prefixString := "Prefix" + readDef := config.ReadDefinition{ + ChainSpecificName: testutils.TestStructWithNestedStruct, + ReadType: config.Account, + PDADefiniton: codec.PDATypeDef{ + Prefix: prefixString, + Seeds: []codec.PDASeed{ + { + Name: "PubKey", + Type: codec.IdlTypePublicKey, + }, + }, + }, + OutputModifications: codeccommon.ModifiersConfig{ + &codeccommon.RenameModifierConfig{Fields: map[string]string{"Value": "V"}}, + }, + } + _, conf := newTestConfAndCodecWithInjectibleReadDef(t, PDAAccount, readDef) + + client := new(mockedRPCClient) + svc, err := chainreader.NewChainReaderService(logger.Test(t), client, conf) + require.NoError(t, err) + require.NotNil(t, svc) + require.NoError(t, svc.Start(ctx)) + + t.Cleanup(func() { + require.NoError(t, svc.Close()) + }) + + binding := types.BoundContract{ + Name: Namespace, + Address: solana.NewWallet().PublicKey().String(), // Set the program ID used to calculate the PDA + } + + require.NoError(t, svc.Bind(ctx, []types.BoundContract{binding})) + + var result modifiedStructWithNestedStruct + require.Error(t, svc.GetLatestValue(ctx, binding.ReadIdentifier(PDAAccount), primitives.Unconfirmed, map[string]any{ + "randomField": "randomValue", // unused field should be ignored by the codec + }, &result)) + }) } func newTestIDLAndCodec(t *testing.T) (string, codec.IDL, types.RemoteCodec) { @@ -311,6 +510,23 @@ func newTestConfAndCodec(t *testing.T) (types.RemoteCodec, config.ContractReader return testCodec, conf } +func newTestConfAndCodecWithInjectibleReadDef(t *testing.T, readDefName string, readDef config.ReadDefinition) (types.RemoteCodec, config.ContractReader) { + t.Helper() + rawIDL, _, testCodec := newTestIDLAndCodec(t) + conf := config.ContractReader{ + Namespaces: map[string]config.ChainContractReader{ + Namespace: { + IDL: mustUnmarshalIDL(t, rawIDL), + Reads: map[string]config.ReadDefinition{ + readDefName: readDef, + }, + }, + }, + } + + return testCodec, conf +} + type modifiedStructWithNestedStruct struct { V uint8 InnerStruct testutils.ObjectRef1 @@ -320,7 +536,7 @@ type modifiedStructWithNestedStruct struct { BasicVector []string TimeVal int64 DurationVal time.Duration - PublicKey ag_solana.PublicKey + PublicKey solana.PublicKey EnumVal uint8 } @@ -365,7 +581,7 @@ func (_m *mockedRPCClient) SetNext(bts []byte, err error, delay time.Duration) { }) } -func (_m *mockedRPCClient) SetForAddress(pk ag_solana.PublicKey, bts []byte, err error, delay time.Duration) { +func (_m *mockedRPCClient) SetForAddress(pk solana.PublicKey, bts []byte, err error, delay time.Duration) { _m.mu.Lock() defer _m.mu.Unlock() @@ -409,7 +625,7 @@ func (r *chainReaderInterfaceTester) Name() string { func (r *chainReaderInterfaceTester) Setup(t *testing.T) { r.address = make([]string, 7) for idx := range r.address { - r.address[idx] = ag_solana.NewWallet().PublicKey().String() + r.address[idx] = solana.NewWallet().PublicKey().String() } r.conf = config.ContractReader{ @@ -643,7 +859,7 @@ func (r *wrappedTestChainReader) GetLatestValue(ctx context.Context, readIdentif } } - r.client.SetForAddress(ag_solana.PublicKey(r.tester.GetAccountBytes(acct)), bts, nil, 0) + r.client.SetForAddress(solana.PublicKey(r.tester.GetAccountBytes(acct)), bts, nil, 0) return r.service.GetLatestValue(ctx, readIdentifier, confidenceLevel, params, returnVal) } @@ -925,3 +1141,9 @@ func mustUnmarshalIDL(t *testing.T, rawIDL string) codec.IDL { return idl } + +func mustFindProgramAddress(t *testing.T, programID solana.PublicKey, seeds [][]byte) solana.PublicKey { + key, _, err := solana.FindProgramAddress(seeds, programID) + require.NoError(t, err) + return key +} diff --git a/pkg/solana/codec/anchoridl.go b/pkg/solana/codec/anchoridl.go index 3fc296e97..0ea1322ad 100644 --- a/pkg/solana/codec/anchoridl.go +++ b/pkg/solana/codec/anchoridl.go @@ -140,6 +140,18 @@ type IdlField struct { Type IdlType `json:"type"` } +// PDA is a struct that does not correlate to an official IDL type +// It is needed to encode seeds to calculate the address for PDA account reads +type PDATypeDef struct { + Prefix string `json:"prefix,omitempty"` + Seeds []PDASeed `json:"seeds,omitempty"` +} + +type PDASeed struct { + Name string `json:"name"` + Type IdlTypeAsString `json:"type"` +} + type IdlTypeAsString string const ( @@ -255,6 +267,12 @@ type IdlType struct { asIdlTypeArray *IdlTypeArray } +func NewIdlStringType(asString IdlTypeAsString) IdlType { + return IdlType{ + asString: asString, + } +} + func (env *IdlType) IsString() bool { return env.asString != "" } diff --git a/pkg/solana/codec/codec_entry.go b/pkg/solana/codec/codec_entry.go index f22b05984..d3b459d57 100644 --- a/pkg/solana/codec/codec_entry.go +++ b/pkg/solana/codec/codec_entry.go @@ -53,6 +53,22 @@ func NewAccountEntry(offchainName string, idlTypes AccountIDLTypes, includeDiscr ), nil } +func NewPDAEntry(offchainName string, pdaTypeDef PDATypeDef, mod codec.Modifier, builder commonencodings.Builder) (Entry, error) { + // PDA seeds do not have any dependecies in the IDL so the type def slice can be left empty for refs + _, accCodec, err := asStruct(pdaSeedsToIdlField(pdaTypeDef.Seeds), createRefs(IdlTypeDefSlice{}, builder), offchainName, false, false) + if err != nil { + return nil, err + } + + return newEntry( + offchainName, + offchainName, // PDA seeds do not correlate to anything on-chain so reusing offchain name + accCodec, + false, + mod, + ), nil +} + type InstructionArgsIDLTypes struct { Instruction IdlInstruction Types IdlTypeDefSlice @@ -205,3 +221,14 @@ func eventFieldsToFields(evFields []IdlEventField) []IdlField { } return idlFields } + +func pdaSeedsToIdlField(seeds []PDASeed) []IdlField { + idlFields := make([]IdlField, 0, len(seeds)) + for _, seed := range seeds { + idlFields = append(idlFields, IdlField{ + Name: seed.Name, + Type: NewIdlStringType(seed.Type), + }) + } + return idlFields +} diff --git a/pkg/solana/codec/solana.go b/pkg/solana/codec/solana.go index 3a19a1683..08ff964a9 100644 --- a/pkg/solana/codec/solana.go +++ b/pkg/solana/codec/solana.go @@ -98,6 +98,8 @@ func CreateCodecEntry(idlDefinition interface{}, offChainName string, idl IDL, m entry, err = NewInstructionArgsEntry(offChainName, InstructionArgsIDLTypes{Instruction: v, Types: idl.Types}, mod, binary.LittleEndian()) case IdlEvent: entry, err = NewEventArgsEntry(offChainName, EventIDLTypes{Event: v, Types: idl.Types}, true, mod, binary.LittleEndian()) + case PDATypeDef: + entry, err = NewPDAEntry(offChainName, v, mod, binary.LittleEndian()) default: return nil, fmt.Errorf("unknown codec IDL definition: %T", idlDefinition) } diff --git a/pkg/solana/config/chain_reader.go b/pkg/solana/config/chain_reader.go index 57ccb9040..ab09e013a 100644 --- a/pkg/solana/config/chain_reader.go +++ b/pkg/solana/config/chain_reader.go @@ -26,6 +26,7 @@ type ReadDefinition struct { ReadType ReadType `json:"readType,omitempty"` InputModifications commoncodec.ModifiersConfig `json:"inputModifications,omitempty"` OutputModifications commoncodec.ModifiersConfig `json:"outputModifications,omitempty"` + PDADefiniton codec.PDATypeDef `json:"pdaDefinition,omitempty"` // Only used for PDA account reads } type ReadType int diff --git a/pkg/solana/config/chain_reader_test.go b/pkg/solana/config/chain_reader_test.go index 19bcedbe3..cb52c56f9 100644 --- a/pkg/solana/config/chain_reader_test.go +++ b/pkg/solana/config/chain_reader_test.go @@ -43,6 +43,12 @@ func TestChainReaderConfig(t *testing.T) { assert.Equal(t, validChainReaderConfig, result) }) + t.Run("valid unmarshal with PDA account", func(t *testing.T) { + var result config.ContractReader + require.NoError(t, json.Unmarshal([]byte(validJSONWithIDLAsString), &result)) + assert.Equal(t, validChainReaderConfig, result) + }) + t.Run("invalid unmarshal", func(t *testing.T) { t.Parallel() From afa96364f827999ea3115d2f3ce3f355dcdc373f Mon Sep 17 00:00:00 2001 From: Silas Lenihan <32529249+silaslenihan@users.noreply.github.com> Date: Fri, 17 Jan 2025 15:36:33 -0500 Subject: [PATCH 3/3] Added bitmap location parsing for IsWritable and IsSignable on AccountLookups (#1017) * Added location parsing for IsWritable and IsSignable on AccountLookups * Updated MetaBool to use a bitmap location rather than a bool location --- .../relayinterface/lookups_test.go | 145 +++++++++++++++++- pkg/solana/chainwriter/ccip_example_config.go | 12 +- pkg/solana/chainwriter/chain_writer_test.go | 8 +- pkg/solana/chainwriter/helpers.go | 2 +- pkg/solana/chainwriter/lookups.go | 73 +++++++-- 5 files changed, 213 insertions(+), 27 deletions(-) diff --git a/integration-tests/relayinterface/lookups_test.go b/integration-tests/relayinterface/lookups_test.go index 0154b683a..4b0f672dc 100644 --- a/integration-tests/relayinterface/lookups_test.go +++ b/integration-tests/relayinterface/lookups_test.go @@ -21,6 +21,15 @@ import ( "github.com/smartcontractkit/chainlink-solana/pkg/solana/utils" ) +type InnerAccountArgs struct { + Accounts []*solana.AccountMeta + Bitmap uint64 +} + +type TestAccountArgs struct { + Inner InnerAccountArgs +} + func TestAccountContant(t *testing.T) { t.Run("AccountConstant resolves valid address", func(t *testing.T) { expectedAddr := chainwriter.GetRandomPubKey(t) @@ -62,8 +71,8 @@ func TestAccountLookups(t *testing.T) { lookupConfig := chainwriter.AccountLookup{ Name: "TestAccount", Location: "Inner.Address", - IsSigner: true, - IsWritable: true, + IsSigner: chainwriter.MetaBool{Value: true}, + IsWritable: chainwriter.MetaBool{Value: true}, } result, err := lookupConfig.Resolve(ctx, testArgs, nil, nil) require.NoError(t, err) @@ -96,8 +105,8 @@ func TestAccountLookups(t *testing.T) { lookupConfig := chainwriter.AccountLookup{ Name: "TestAccount", Location: "Inner.Address", - IsSigner: true, - IsWritable: true, + IsSigner: chainwriter.MetaBool{Value: true}, + IsWritable: chainwriter.MetaBool{Value: true}, } result, err := lookupConfig.Resolve(ctx, testArgs, nil, nil) require.NoError(t, err) @@ -117,12 +126,134 @@ func TestAccountLookups(t *testing.T) { lookupConfig := chainwriter.AccountLookup{ Name: "InvalidAccount", Location: "Invalid.Directory", - IsSigner: true, - IsWritable: true, + IsSigner: chainwriter.MetaBool{Value: true}, + IsWritable: chainwriter.MetaBool{Value: true}, } _, err := lookupConfig.Resolve(ctx, testArgs, nil, nil) require.Error(t, err) }) + + t.Run("AccountLookup works with MetaBool bitmap lookups", func(t *testing.T) { + accounts := [3]*solana.AccountMeta{} + + for i := 0; i < 3; i++ { + accounts[i] = &solana.AccountMeta{ + PublicKey: chainwriter.GetRandomPubKey(t), + IsSigner: (i)%2 == 0, + IsWritable: (i)%2 == 0, + } + } + + lookupConfig := chainwriter.AccountLookup{ + Name: "InvalidAccount", + Location: "Inner.Accounts.PublicKey", + IsSigner: chainwriter.MetaBool{BitmapLocation: "Inner.Bitmap"}, + IsWritable: chainwriter.MetaBool{BitmapLocation: "Inner.Bitmap"}, + } + + args := TestAccountArgs{ + Inner: InnerAccountArgs{ + Accounts: accounts[:], + // should be 101... so {true, false, true} + Bitmap: 5, + }, + } + + result, err := lookupConfig.Resolve(ctx, args, nil, nil) + require.NoError(t, err) + + for i, meta := range result { + require.Equal(t, accounts[i], meta) + } + }) + + t.Run("AccountLookup fails with MetaBool due to an invalid number of bitmaps", func(t *testing.T) { + type TestAccountArgsExtended struct { + Inner InnerAccountArgs + Bitmaps []uint64 + } + + accounts := [3]*solana.AccountMeta{} + + for i := 0; i < 3; i++ { + accounts[i] = &solana.AccountMeta{ + PublicKey: chainwriter.GetRandomPubKey(t), + IsWritable: true, + IsSigner: true, + } + } + + lookupConfig := chainwriter.AccountLookup{ + Name: "InvalidAccount", + Location: "Inner.Accounts.PublicKey", + IsSigner: chainwriter.MetaBool{BitmapLocation: "Bitmaps"}, + IsWritable: chainwriter.MetaBool{BitmapLocation: "Bitmaps"}, + } + + args := TestAccountArgsExtended{ + Inner: InnerAccountArgs{ + Accounts: accounts[:], + }, + Bitmaps: []uint64{5, 3}, + } + + _, err := lookupConfig.Resolve(ctx, args, nil, nil) + require.Contains(t, err.Error(), "bitmap value is not a single value") + }) + + t.Run("AccountLookup fails with MetaBool with an Invalid BitmapLocation", func(t *testing.T) { + accounts := [3]*solana.AccountMeta{} + + for i := 0; i < 3; i++ { + accounts[i] = &solana.AccountMeta{ + PublicKey: chainwriter.GetRandomPubKey(t), + IsWritable: true, + } + } + + lookupConfig := chainwriter.AccountLookup{ + Name: "InvalidAccount", + Location: "Inner.Accounts.PublicKey", + IsSigner: chainwriter.MetaBool{BitmapLocation: "Invalid.Bitmap"}, + IsWritable: chainwriter.MetaBool{BitmapLocation: "Invalid.Bitmap"}, + } + + args := TestAccountArgs{ + Inner: InnerAccountArgs{ + Accounts: accounts[:], + }, + } + + _, err := lookupConfig.Resolve(ctx, args, nil, nil) + require.Contains(t, err.Error(), "error reading bitmap from location") + }) + + t.Run("AccountLookup fails when MetaBool Bitmap is an invalid type", func(t *testing.T) { + accounts := [3]*solana.AccountMeta{} + + for i := 0; i < 3; i++ { + accounts[i] = &solana.AccountMeta{ + PublicKey: chainwriter.GetRandomPubKey(t), + IsWritable: true, + } + } + + lookupConfig := chainwriter.AccountLookup{ + Name: "InvalidAccount", + Location: "Inner.Accounts.PublicKey", + IsSigner: chainwriter.MetaBool{BitmapLocation: "Inner"}, + IsWritable: chainwriter.MetaBool{BitmapLocation: "Inner"}, + } + + args := TestAccountArgs{ + Inner: InnerAccountArgs{ + Accounts: accounts[:], + }, + } + + _, err := lookupConfig.Resolve(ctx, args, nil, nil) + require.Contains(t, err.Error(), "invalid value format at path") + }) } func TestPDALookups(t *testing.T) { @@ -435,7 +566,7 @@ func TestLookupTables(t *testing.T) { Accounts: chainwriter.AccountLookup{ Name: "TestLookupTable", Location: "Inner.Address", - IsSigner: true, + IsSigner: chainwriter.MetaBool{Value: true}, }, }, }, diff --git a/pkg/solana/chainwriter/ccip_example_config.go b/pkg/solana/chainwriter/ccip_example_config.go index fc46794a8..6c612ca16 100644 --- a/pkg/solana/chainwriter/ccip_example_config.go +++ b/pkg/solana/chainwriter/ccip_example_config.go @@ -104,8 +104,8 @@ func TestConfig() { AccountLookup{ Name: "TokenAccount", Location: "Message.TokenAmounts.DestTokenAddress", - IsSigner: false, - IsWritable: false, + IsSigner: MetaBool{Value: false}, + IsWritable: MetaBool{Value: false}, }, // PDA Account Lookup - Based on an account lookup and an address lookup PDALookups{ @@ -116,16 +116,16 @@ func TestConfig() { PublicKey: AccountLookup{ Name: "TokenAccount", Location: "Message.TokenAmounts.DestTokenAddress", - IsSigner: false, - IsWritable: false, + IsSigner: MetaBool{Value: false}, + IsWritable: MetaBool{Value: false}, }, // The seed is the receiver address. Seeds: []Seed{ {Dynamic: AccountLookup{ Name: "Receiver", Location: "Message.Receiver", - IsSigner: false, - IsWritable: false, + IsSigner: MetaBool{Value: false}, + IsWritable: MetaBool{Value: false}, }}, }, }, diff --git a/pkg/solana/chainwriter/chain_writer_test.go b/pkg/solana/chainwriter/chain_writer_test.go index 947674a2f..f113f33da 100644 --- a/pkg/solana/chainwriter/chain_writer_test.go +++ b/pkg/solana/chainwriter/chain_writer_test.go @@ -128,8 +128,8 @@ func TestChainWriter_GetAddresses(t *testing.T) { chainwriter.AccountLookup{ Name: "LookupTable", Location: "LookupTable", - IsSigner: accountLookupMeta.IsSigner, - IsWritable: accountLookupMeta.IsWritable, + IsSigner: chainwriter.MetaBool{Value: accountLookupMeta.IsSigner}, + IsWritable: chainwriter.MetaBool{Value: accountLookupMeta.IsWritable}, }, chainwriter.PDALookups{ Name: "DataAccountPDA", @@ -463,8 +463,8 @@ func TestChainWriter_SubmitTransaction(t *testing.T) { chainwriter.AccountLookup{ Name: "LookupTable", Location: "LookupTable", - IsSigner: false, - IsWritable: false, + IsSigner: chainwriter.MetaBool{Value: false}, + IsWritable: chainwriter.MetaBool{Value: false}, }, chainwriter.PDALookups{ Name: "DataAccountPDA", diff --git a/pkg/solana/chainwriter/helpers.go b/pkg/solana/chainwriter/helpers.go index 7d146a25a..c67bb7ca5 100644 --- a/pkg/solana/chainwriter/helpers.go +++ b/pkg/solana/chainwriter/helpers.go @@ -52,7 +52,7 @@ func GetValuesAtLocation(args any, location string) ([][]byte, error) { binary.LittleEndian.PutUint64(buf, num) vals = append(vals, buf) } else { - return nil, fmt.Errorf("invalid value format at path: %s", location) + return nil, fmt.Errorf("invalid value format at path: %s, type: %s", location, reflect.TypeOf(value).String()) } } diff --git a/pkg/solana/chainwriter/lookups.go b/pkg/solana/chainwriter/lookups.go index 349ad74bc..b724b77d9 100644 --- a/pkg/solana/chainwriter/lookups.go +++ b/pkg/solana/chainwriter/lookups.go @@ -2,6 +2,7 @@ package chainwriter import ( "context" + "encoding/binary" "fmt" "reflect" @@ -27,10 +28,16 @@ type AccountConstant struct { // AccountLookup dynamically derives an account address from args using a specified location path. type AccountLookup struct { - Name string - Location string - IsSigner bool - IsWritable bool + Name string + Location string + // IsSigner and IsWritable can either be a constant bool or a location to a bitmap which decides the bools + IsSigner MetaBool + IsWritable MetaBool +} + +type MetaBool struct { + Value bool + BitmapLocation string } type Seed struct { @@ -89,23 +96,71 @@ func (ac AccountConstant) Resolve(_ context.Context, _ any, _ map[string]map[str }, nil } -func (al AccountLookup) Resolve(_ context.Context, args any, _ map[string]map[string][]*solana.AccountMeta, _ client.Reader) ([]*solana.AccountMeta, error) { +func (al AccountLookup) Resolve( + _ context.Context, + args any, + _ map[string]map[string][]*solana.AccountMeta, + _ client.Reader, +) ([]*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("error getting account from '%s': %w", al.Location, err) } var metas []*solana.AccountMeta - for _, address := range derivedValues { + signerIndexes, err := resolveBitMap(al.IsSigner, args, len(derivedValues)) + if err != nil { + return nil, err + } + + writerIndexes, err := resolveBitMap(al.IsWritable, args, len(derivedValues)) + if err != nil { + return nil, err + } + + for i, address := range derivedValues { + // Resolve isSigner for this particular pubkey + isSigner := signerIndexes[i] + + // Resolve isWritable + isWritable := writerIndexes[i] + metas = append(metas, &solana.AccountMeta{ PublicKey: solana.PublicKeyFromBytes(address), - IsSigner: al.IsSigner, - IsWritable: al.IsWritable, + IsSigner: isSigner, + IsWritable: isWritable, }) } + return metas, nil } +func resolveBitMap(mb MetaBool, args any, length int) ([]bool, error) { + result := make([]bool, length) + if mb.BitmapLocation == "" { + for i := 0; i < length; i++ { + result[i] = mb.Value + } + return result, nil + } + + bitmapVals, err := GetValuesAtLocation(args, mb.BitmapLocation) + if err != nil { + return []bool{}, fmt.Errorf("error reading bitmap from location '%s': %w", mb.BitmapLocation, err) + } + + if len(bitmapVals) != 1 { + return []bool{}, fmt.Errorf("bitmap value is not a single value: %v, length: %d", bitmapVals, len(bitmapVals)) + } + + bitmapInt := binary.LittleEndian.Uint64(bitmapVals[0]) + for i := 0; i < length; i++ { + result[i] = bitmapInt&(1< 0 + } + + return result, nil +} + func (alt AccountsFromLookupTable) Resolve(_ context.Context, _ any, derivedTableMap map[string]map[string][]*solana.AccountMeta, _ client.Reader) ([]*solana.AccountMeta, error) { // Fetch the inner map for the specified lookup table name innerMap, ok := derivedTableMap[alt.LookupTableName]