Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Enable ChainReader to read PDA account state #1003

Merged
merged 17 commits into from
Jan 17, 2025
Merged
Show file tree
Hide file tree
Changes from 5 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions pkg/solana/chainreader/account_read_binding.go
Original file line number Diff line number Diff line change
Expand Up @@ -34,8 +34,8 @@ func (b *accountReadBinding) SetAddress(key solana.PublicKey) {
b.key = key
}

func (b *accountReadBinding) GetAddress() solana.PublicKey {
return b.key
func (b *accountReadBinding) GetAddress(_ context.Context, _ any) (solana.PublicKey, error) {
return b.key, nil
}

func (b *accountReadBinding) CreateType(forEncoding bool) (any, error) {
Expand Down
7 changes: 6 additions & 1 deletion pkg/solana/chainreader/batch.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package chainreader
import (
"context"
"errors"
"fmt"

"github.com/gagliardetto/solana-go"

Expand Down Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion pkg/solana/chainreader/bindings.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions pkg/solana/chainreader/bindings_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
44 changes: 31 additions & 13 deletions pkg/solana/chainreader/chain_reader.go
Original file line number Diff line number Diff line change
Expand Up @@ -249,6 +249,23 @@ func (s *SolanaChainReaderService) addCodecDef(forEncoding bool, namespace, gene
return nil
}

func (s *SolanaChainReaderService) addSeedsCodecDef(namespace, genericName string, seedDefs []codec.SeedDefinition, modCfg commoncodec.ModifiersConfig) error {
mod, err := modCfg.ToModifier(codec.DecoderHooks...)
if err != nil {
return err
}
// Append seed suffix to differentiate the entry for encoding seeds from the account read entry
seedEntry, err := codec.NewSeedEntry(genericName, mod, seedDefs)
if err != nil {
return fmt.Errorf("failed to create a codec entry for seed definitions %v: %w", seedDefs, err)
}

// Seed codec entry is only used for encoding. Read type is not used by WrapItemType for encoding string.
s.parsed.EncoderDefs[codec.WrapItemType(true, namespace, genericName, "")] = seedEntry

return nil
}

Copy link
Contributor

@ilija42 ilija42 Jan 15, 2025

Choose a reason for hiding this comment

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

I think this can go away in favour of just using addCodecDef(). You just need to define seeds as a new IDL type and handle it like events, args and accounts are handled here CreateCodecEntry() which is a func used by addCodecDef().

func (s *SolanaChainReaderService) init(namespaces map[string]config.ChainContractReader) error {
for namespace, nameSpaceDef := range namespaces {
for genericName, read := range nameSpaceDef.Reads {
Expand Down Expand Up @@ -284,25 +301,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
// Create PDA read binding if PDA prefix or seeds configs are populated
if len(readDefinition.PDAPrefix) > 0 || len(readDefinition.Seeds) > 0 {
if err := s.addSeedsCodecDef(namespace, genericName, readDefinition.Seeds, readDefinition.InputModifications); err != nil {
return fmt.Errorf("failed to add codec entry for seed configs: %w", err)
}
reader = newPdaReadBinding(namespace, genericName, readDefinition.PDAPrefix)
} else {
if err := s.addCodecDef(true, namespace, genericName, codec.ChainConfigTypeAccountDef, idl, codec.NilIdlTypeDefTy, readDefinition.InputModifications); err != nil {
Copy link
Contributor

@ilija42 ilija42 Jan 15, 2025

Choose a reason for hiding this comment

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

What does this do? There are no inputs for non PDA account reads

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I think this is unused for normal account reads. For encoding PDAs, we only really need the new addSeedsCodecDef method I added. But when I tried to remove this, I started getting some errors. I can debug and see what's going on but since this was already in the existing code I opted to keep it as is

Copy link
Contributor

Choose a reason for hiding this comment

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

my bad actually, I wrote this code lol

Copy link
Contributor

@ilija42 ilija42 Jan 15, 2025

Choose a reason for hiding this comment

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

I thought I left it as a placeholder for PDA to be input, not sure why it throws errors

return err
}
reader = newAccountReadBinding(namespace, genericName)
}
s.bindings.AddReadBinding(namespace, genericName, reader)

return nil
}
Expand Down
97 changes: 89 additions & 8 deletions pkg/solana/chainreader/chain_reader_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package chainreader_test

import (
"context"
go_binary "encoding/binary"
"encoding/json"
"fmt"
"math/big"
Expand All @@ -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"

Expand All @@ -36,8 +36,16 @@ import (
)

const (
Namespace = "NameSpace"
NamedMethod = "NamedMethod1"
Namespace = "NameSpace"
NamedMethod = "NamedMethod1"
PDAAccount = "PDAAccount1"
PDAPrefixString = "Prefix"
PDAPubKeySeedName = "PubKey"
PDAUint64SeedName = "Uint64Seed"
)

var (
PDAPublicKeySeed = solana.NewWallet().PublicKey()
)

func TestSolanaChainReaderService_ReaderInterface(t *testing.T) {
Expand Down Expand Up @@ -129,6 +137,58 @@ func TestSolanaChainReaderService_GetLatestValue(t *testing.T) {
assert.Equal(t, expected.DurationVal, result.DurationVal)
})

t.Run("PDA account read successful", func(t *testing.T) {
t.Parallel()

testCodec, conf := newTestConfAndCodec(t)
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())
})

programID := solana.NewWallet().PublicKey()

var result modifiedStructWithNestedStruct

binding := types.BoundContract{
Name: Namespace,
Address: programID.String(), // Set the program ID used to calculate the PDA
}

uint64Seed := uint64(5)

pdaAccount, _, err := solana.FindProgramAddress([][]byte{
[]byte(PDAPrefixString),
PDAPublicKeySeed.Bytes(),
go_binary.LittleEndian.AppendUint64([]byte{}, uint64Seed),
}, programID)
require.NoError(t, err)

client.SetForAddress(pdaAccount, encoded, nil, 0)

require.NoError(t, svc.Bind(ctx, []types.BoundContract{binding}))
require.NoError(t, svc.GetLatestValue(ctx, binding.ReadIdentifier(PDAAccount), primitives.Unconfirmed, map[string]any{
PDAPubKeySeedName: PDAPublicKeySeed,
"randomField": "randomValue", // unused field should be ignored by the codec
PDAUint64SeedName: uint64Seed,
}, &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("Error Returned From Account Reader", func(t *testing.T) {
t.Parallel()

Expand Down Expand Up @@ -222,7 +282,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{
{
Expand Down Expand Up @@ -303,6 +363,27 @@ func newTestConfAndCodec(t *testing.T) (types.RemoteCodec, config.ContractReader
&codeccommon.RenameModifierConfig{Fields: map[string]string{"Value": "V"}},
},
},
PDAAccount: {
ChainSpecificName: testutils.TestStructWithNestedStruct,
ReadType: config.Account,
PDAPrefix: PDAPrefixString,
Seeds: []codec.SeedDefinition{
{
Name: PDAPubKeySeedName,
Type: codec.SeedPubKey,
},
{
Name: PDAUint64SeedName,
Type: codec.SeedUint64,
},
},
// InputModifications: codeccommon.ModifiersConfig{
// &codeccommon.RenameModifierConfig{Fields: map[string]string{"PublicKey": PDAPubKeySeedName}},
// },
OutputModifications: codeccommon.ModifiersConfig{
&codeccommon.RenameModifierConfig{Fields: map[string]string{"Value": "V"}},
},
},
},
},
},
Expand All @@ -320,7 +401,7 @@ type modifiedStructWithNestedStruct struct {
BasicVector []string
TimeVal int64
DurationVal time.Duration
PublicKey ag_solana.PublicKey
PublicKey solana.PublicKey
EnumVal uint8
}

Expand Down Expand Up @@ -365,7 +446,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()

Expand Down Expand Up @@ -409,7 +490,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{
Expand Down Expand Up @@ -643,7 +724,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)
}
Expand Down
94 changes: 94 additions & 0 deletions pkg/solana/chainreader/pda_read_binding.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
package chainreader

import (
"context"
"fmt"

"github.com/gagliardetto/solana-go"

"github.com/smartcontractkit/chainlink-common/pkg/types"

"github.com/smartcontractkit/chainlink-solana/pkg/solana/codec"
)

// pdaReadBinding provides calculating PDA addresses with the provided seeds and reading decoded PDA Account data using a defined codec
type pdaReadBinding struct {
Copy link
Contributor

Choose a reason for hiding this comment

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

Do you think we could merge this into account_read_binding.go? They essentially do the same thing, since we’re reading an account in both cases. The only difference is that we need to calculate the PDA address if parameters are provided.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Hmm good point. I think I could. I'll give this a shot

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I merged the two in the latest commit

namespace string
genericName string
codec types.RemoteCodec
programID solana.PublicKey
prefix string
}

func newPdaReadBinding(namespace, genericName string, prefix string) *pdaReadBinding {
return &pdaReadBinding{
namespace: namespace,
genericName: genericName,
prefix: prefix,
}
}

var _ readBinding = &pdaReadBinding{}

func (b *pdaReadBinding) SetCodec(codec types.RemoteCodec) {
b.codec = codec
}

func (b *pdaReadBinding) SetAddress(programID solana.PublicKey) {
b.programID = programID
}

func (b *pdaReadBinding) GetAddress(ctx context.Context, params any) (solana.PublicKey, error) {
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.programID)
if err != nil {
return solana.PublicKey{}, fmt.Errorf("failed find program address for PDA: %w", err)
}
return key, nil
}

func (b *pdaReadBinding) CreateType(forEncoding bool) (any, error) {
return b.codec.CreateType(codec.WrapItemType(forEncoding, b.namespace, b.genericName, codec.ChainConfigTypeAccountDef), forEncoding)
}

func (b *pdaReadBinding) 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))
}

func (b *pdaReadBinding) 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
}
13 changes: 13 additions & 0 deletions pkg/solana/codec/anchoridl.go
Original file line number Diff line number Diff line change
Expand Up @@ -404,3 +404,16 @@ type IdlErrorCode struct {
Name string `json:"name"`
Msg string `json:"msg,omitempty"`
}

// Custom type used for seeds
type SeedDefinition struct {
Name string `json:"name,omitempty"`
Type SeedType `json:"type,omitempty"`
}

type SeedType int

const (
SeedPubKey SeedType = iota
SeedUint64
)
Loading
Loading