diff --git a/cmd/generate-bindings/solana/anchor-go/generator/cre.go b/cmd/generate-bindings/solana/anchor-go/generator/cre.go index a50c1e94..c7089fd1 100644 --- a/cmd/generate-bindings/solana/anchor-go/generator/cre.go +++ b/cmd/generate-bindings/solana/anchor-go/generator/cre.go @@ -20,6 +20,11 @@ const ( PkgPbSdk = "github.com/smartcontractkit/chainlink-protos/cre/go/sdk" PkgSolanaCre = "github.com/smartcontractkit/cre-sdk-go/capabilities/blockchain/solana" PkgBindings = "github.com/smartcontractkit/cre-sdk-go/capabilities/blockchain/solana/bindings" + // maxGeneratedSubkeysPerEvent mirrors backend support in the Solana log poller. + maxGeneratedSubkeysPerEvent = 4 + // maxGeneratedComparersPerSubkey limits per-subkey comparer fanout to match + // trigger registration guardrails. + maxGeneratedComparersPerSubkey = 4 ) // func (c *Codec) Decode(data []byte) (*, error) { @@ -564,6 +569,16 @@ func creEncodeSubkeysForEvent(eventName string, filterFields []eventFilterField) block.Line() } + for _, field := range filterFields { + block.If(Len(Id(field.goName + "Comparers")).Op(">").Lit(maxGeneratedComparersPerSubkey)).Block( + Return(Nil(), Qual("fmt", "Errorf").Call( + Lit("too many comparers for subkey "+field.goName+": maximum supported is %d, got %d"), + Lit(maxGeneratedComparersPerSubkey), + Len(Id(field.goName+"Comparers")), + )), + ) + } + block.Id("subkeys").Op(":=").Make(Index().Op("*").Qual(PkgSolanaCre, "SubkeyConfig"), Lit(0)) for _, field := range filterFields { block.If(Len(Id(field.goName + "Comparers")).Op(">").Lit(0)).Block( diff --git a/cmd/generate-bindings/solana/anchor-go/generator/triggers.go b/cmd/generate-bindings/solana/anchor-go/generator/triggers.go index 88811149..dd1e1f6d 100644 --- a/cmd/generate-bindings/solana/anchor-go/generator/triggers.go +++ b/cmd/generate-bindings/solana/anchor-go/generator/triggers.go @@ -33,6 +33,9 @@ func (g *Generator) genfile_triggers() (*OutputFile, error) { } filterFields := getEventFilterFields(fields) + if len(filterFields) > maxGeneratedSubkeysPerEvent { + return nil, fmt.Errorf("event %s has %d auto-filterable indexed fields; maximum supported is %d", event.Name, len(filterFields), maxGeneratedSubkeysPerEvent) + } file.Add(creEventFiltersStruct(event.Name, filterFields)) file.Line() diff --git a/cmd/generate-bindings/solana/bindings_test.go b/cmd/generate-bindings/solana/bindings_test.go index cafeaea7..00e3dec1 100644 --- a/cmd/generate-bindings/solana/bindings_test.go +++ b/cmd/generate-bindings/solana/bindings_test.go @@ -235,6 +235,41 @@ func TestLogTrigger(t *testing.T) { require.Equal(t, mockLog, decodedLog.Log) }) + t.Run("AccessLogged subkey comparer limit", func(t *testing.T) { + pk1, err := solana.NewRandomPrivateKey() + require.NoError(t, err) + pk2, err := solana.NewRandomPrivateKey() + require.NoError(t, err) + pk3, err := solana.NewRandomPrivateKey() + require.NoError(t, err) + pk4, err := solana.NewRandomPrivateKey() + require.NoError(t, err) + pk5, err := solana.NewRandomPrivateKey() + require.NoError(t, err) + + caller1 := pk1.PublicKey() + caller2 := pk2.PublicKey() + caller3 := pk3.PublicKey() + caller4 := pk4.PublicKey() + caller5 := pk5.PublicKey() + + filters := []datastorage.AccessLoggedFilters{ + {Caller: &caller1}, + {Caller: &caller2}, + {Caller: &caller3}, + {Caller: &caller4}, + {Caller: &caller5}, + } + + _, err = ds.Codec.EncodeAccessLoggedSubkeys(filters) + require.Error(t, err) + require.Contains(t, err.Error(), "too many comparers for subkey Caller") + + _, err = ds.LogTriggerAccessLoggedLog(anyChainSelector, "too-many-comparers", filters, nil) + require.Error(t, err) + require.Contains(t, err.Error(), "too many comparers for subkey Caller") + }) + t.Run("DynamicEvent scalar filters exclude nested and vec fields", func(t *testing.T) { key := "lookup-key" sender := "alice" diff --git a/cmd/generate-bindings/solana/gen_test.go b/cmd/generate-bindings/solana/gen_test.go index 1988a727..d23109eb 100644 --- a/cmd/generate-bindings/solana/gen_test.go +++ b/cmd/generate-bindings/solana/gen_test.go @@ -46,3 +46,29 @@ func TestGenerateBindings_MissingAddress(t *testing.T) { _, statErr := os.Stat(filepath.Join(outDir, "program_id.go")) assert.True(t, os.IsNotExist(statErr), "program_id.go should not be generated when IDL has no address") } + +func TestGenerateBindings_RejectsMoreThanFourIndexedFields(t *testing.T) { + idl := `{ + "address": "ECL8142j2YQAvs9R9geSsRnkVH2wLEi7soJCRyJ74cfL", + "metadata": {"name": "over_indexed", "version": "0.1.0", "spec": "0.1.0"}, + "instructions": [ + {"name": "on_report", "discriminator": [214,173,18,221,173,148,151,208], "accounts": [], "args": []} + ], + "accounts": [], + "events": [{"name": "OverIndexed", "discriminator": [1,2,3,4,5,6,7,8]}], + "errors": [], + "types": [{"name": "OverIndexed", "type": {"kind": "struct", "fields": [ + {"name": "a", "type": "u8"}, + {"name": "b", "type": "u16"}, + {"name": "c", "type": "u32"}, + {"name": "d", "type": "string"}, + {"name": "e", "type": "bytes"} + ]}}] +}` + idlPath := filepath.Join(t.TempDir(), "over_indexed.json") + require.NoError(t, os.WriteFile(idlPath, []byte(idl), 0o600)) + + err := solana.GenerateBindings(idlPath, "over_indexed", t.TempDir()) + require.Error(t, err) + assert.Contains(t, err.Error(), "maximum supported is 4") +} diff --git a/cmd/generate-bindings/solana/sourcecre.ts.tpl b/cmd/generate-bindings/solana/sourcecre.ts.tpl index 43add2a9..0ee0cc55 100644 --- a/cmd/generate-bindings/solana/sourcecre.ts.tpl +++ b/cmd/generate-bindings/solana/sourcecre.ts.tpl @@ -167,6 +167,9 @@ export const encode{{.Name}}Subkeys = (filters: {{.Name}}Filters[]): SolanaSubke } const subkeys: SolanaSubkeyConfigJson[] = [] {{- range .FilterFields}} + if ({{.Name}}Comparers.length > 4) { + throw new Error(`too many comparers for subkey {{.PathName}}: maximum supported is 4, got ${ {{.Name}}Comparers.length }`) + } if ({{.Name}}Comparers.length > 0) { subkeys.push({ path: ['{{.PathName}}'], comparers: {{.Name}}Comparers }) } diff --git a/cmd/generate-bindings/solana/testdata/data_storage/triggers.go b/cmd/generate-bindings/solana/testdata/data_storage/triggers.go index a2acde60..eceaddb0 100644 --- a/cmd/generate-bindings/solana/testdata/data_storage/triggers.go +++ b/cmd/generate-bindings/solana/testdata/data_storage/triggers.go @@ -56,6 +56,12 @@ func (c *Codec) EncodeAccessLoggedSubkeys(filters []AccessLoggedFilters) ([]*sol } } + if len(CallerComparers) > 4 { + return nil, fmt.Errorf("too many comparers for subkey Caller: maximum supported is %d, got %d", 4, len(CallerComparers)) + } + if len(MessageComparers) > 4 { + return nil, fmt.Errorf("too many comparers for subkey Message: maximum supported is %d, got %d", 4, len(MessageComparers)) + } subkeys := make([]*solana.SubkeyConfig, 0) if len(CallerComparers) > 0 { subkeys = append(subkeys, &solana.SubkeyConfig{ @@ -160,6 +166,15 @@ func (c *Codec) EncodeDynamicEventSubkeys(filters []DynamicEventFilters) ([]*sol } } + if len(KeyComparers) > 4 { + return nil, fmt.Errorf("too many comparers for subkey Key: maximum supported is %d, got %d", 4, len(KeyComparers)) + } + if len(SenderComparers) > 4 { + return nil, fmt.Errorf("too many comparers for subkey Sender: maximum supported is %d, got %d", 4, len(SenderComparers)) + } + if len(MetadataComparers) > 4 { + return nil, fmt.Errorf("too many comparers for subkey Metadata: maximum supported is %d, got %d", 4, len(MetadataComparers)) + } subkeys := make([]*solana.SubkeyConfig, 0) if len(KeyComparers) > 0 { subkeys = append(subkeys, &solana.SubkeyConfig{ diff --git a/cmd/generate-bindings/solana/testdata/data_storage_ts/DataStorage.ts b/cmd/generate-bindings/solana/testdata/data_storage_ts/DataStorage.ts index e6338638..f64b0077 100644 --- a/cmd/generate-bindings/solana/testdata/data_storage_ts/DataStorage.ts +++ b/cmd/generate-bindings/solana/testdata/data_storage_ts/DataStorage.ts @@ -195,9 +195,15 @@ export const encodeAccessLoggedSubkeys = (filters: AccessLoggedFilters[]): Solan } } const subkeys: SolanaSubkeyConfigJson[] = [] + if (callerComparers.length > 4) { + throw new Error(`too many comparers for subkey Caller: maximum supported is 4, got ${ callerComparers.length }`) + } if (callerComparers.length > 0) { subkeys.push({ path: ['Caller'], comparers: callerComparers }) } + if (messageComparers.length > 4) { + throw new Error(`too many comparers for subkey Message: maximum supported is 4, got ${ messageComparers.length }`) + } if (messageComparers.length > 0) { subkeys.push({ path: ['Message'], comparers: messageComparers }) } @@ -241,12 +247,21 @@ export const encodeDynamicEventSubkeys = (filters: DynamicEventFilters[]): Solan } } const subkeys: SolanaSubkeyConfigJson[] = [] + if (keyComparers.length > 4) { + throw new Error(`too many comparers for subkey Key: maximum supported is 4, got ${ keyComparers.length }`) + } if (keyComparers.length > 0) { subkeys.push({ path: ['Key'], comparers: keyComparers }) } + if (senderComparers.length > 4) { + throw new Error(`too many comparers for subkey Sender: maximum supported is 4, got ${ senderComparers.length }`) + } if (senderComparers.length > 0) { subkeys.push({ path: ['Sender'], comparers: senderComparers }) } + if (metadataComparers.length > 4) { + throw new Error(`too many comparers for subkey Metadata: maximum supported is 4, got ${ metadataComparers.length }`) + } if (metadataComparers.length > 0) { subkeys.push({ path: ['Metadata'], comparers: metadataComparers }) } diff --git a/cmd/generate-bindings/solana/tsbindgen.go b/cmd/generate-bindings/solana/tsbindgen.go index 25a8f22f..5ec894d0 100644 --- a/cmd/generate-bindings/solana/tsbindgen.go +++ b/cmd/generate-bindings/solana/tsbindgen.go @@ -28,6 +28,8 @@ var ( tsSolanaMockTemplate = template.Must(template.New("mockcontract.ts").Parse(tsSolanaMockTpl)) ) +const maxGeneratedSubkeysPerEvent = 4 + // jsReservedWords are identifiers that cannot be used as TypeScript/JavaScript // names. Field names that collide get an underscore suffix; type/class names // that collide are rejected (they come from IDL type names, which are expected @@ -363,6 +365,10 @@ func buildTriggerDef( }) } + if len(trigger.FilterFields) > maxGeneratedSubkeysPerEvent { + return nil, fmt.Errorf("event %q has %d auto-filterable indexed fields; maximum supported is %d", eventName, len(trigger.FilterFields), maxGeneratedSubkeysPerEvent) + } + return trigger, nil } diff --git a/cmd/generate-bindings/solana/tsbindgen_test.go b/cmd/generate-bindings/solana/tsbindgen_test.go index 6b1d2689..af719bb0 100644 --- a/cmd/generate-bindings/solana/tsbindgen_test.go +++ b/cmd/generate-bindings/solana/tsbindgen_test.go @@ -162,6 +162,7 @@ func TestGenerateBindingsTS_LogTriggers(t *testing.T) { assert.Contains(t, source, "): Trigger> {") // Subkey paths use the Go bindings' PascalCase names. assert.Contains(t, source, "subkeys.push({ path: ['Caller'], comparers: callerComparers })") + assert.Contains(t, source, "too many comparers for subkey Caller: maximum supported is 4") // Pubkey filter values are converted from base58 before encoding. assert.Contains(t, source, "value: bytesToBase64(solanaAddressToBytes(f.caller))") // The compact IDL is embedded once as base64 and sent as contractIdlJson. @@ -211,7 +212,6 @@ func TestGenerateBindingsTS_TriggerFilterEncodings(t *testing.T) { {"name": "maybe_tag", "type": {"option": "string"}}, {"name": "flag", "type": "bool"}, {"name": "huge", "type": "u128"}, - {"name": "blob", "type": "bytes"}, {"name": "list", "type": {"vec": "u8"}}, {"name": "fixed", "type": {"array": ["u8", 32]}} ]}}] @@ -231,7 +231,6 @@ func TestGenerateBindingsTS_TriggerFilterEncodings(t *testing.T) { assert.Contains(t, source, "small?: number | null") assert.Contains(t, source, "amount?: bigint | null") assert.Contains(t, source, "ratio?: number | null") - assert.Contains(t, source, "blob?: Uint8Array | null") // Option is unwrapped for the filter value type. assert.Contains(t, source, "maybeTag?: string | null") assert.Contains(t, source, "value: bytesToBase64(prepareSubkeyValue(f.amount))") @@ -296,11 +295,38 @@ func TestGenerateBindingsTS_FailLoud(t *testing.T) { ]}}]`, wantErr: "both map to", }, + { + name: "more than four indexed event fields", + typesJSON: `[{"name": "OverIndexed", "type": {"kind": "struct", "fields": [ + {"name": "a", "type": "u8"}, + {"name": "b", "type": "u16"}, + {"name": "c", "type": "u32"}, + {"name": "d", "type": "string"}, + {"name": "e", "type": "bytes"} + ]}}, + {"name": "Unused", "type": {"kind": "struct", "fields": []}}]`, + wantErr: "maximum supported is 4", + }, } for _, tc := range cases { - t.Run(tc.name, func(t *testing.T) { - idlPath := writeTestIdl(t, tc.typesJSON) + t.Run(tc.name, func(t *testing.T) { + idlPath := writeTestIdl(t, tc.typesJSON) + if tc.name == "more than four indexed event fields" { + idlWithEvent := `{ + "address": "ECL8142j2YQAvs9R9geSsRnkVH2wLEi7soJCRyJ74cfL", + "metadata": {"name": "fail_loud", "version": "0.1.0", "spec": "0.1.0"}, + "instructions": [ + {"name": "on_report", "discriminator": [214,173,18,221,173,148,151,208], "accounts": [], "args": []} + ], + "accounts": [], + "events": [{"name": "OverIndexed", "discriminator": [1,2,3,4,5,6,7,8]}], + "errors": [], + "types": ` + tc.typesJSON + ` +}` + idlPath = filepath.Join(t.TempDir(), "over_indexed.json") + require.NoError(t, os.WriteFile(idlPath, []byte(idlWithEvent), 0o600)) + } _, err := GenerateBindingsTS(idlPath, "fail_loud", t.TempDir()) require.Error(t, err) assert.Contains(t, err.Error(), tc.wantErr)