diff --git a/cmd/common/compile_test.go b/cmd/common/compile_test.go index 1eef377d..51aa0edb 100644 --- a/cmd/common/compile_test.go +++ b/cmd/common/compile_test.go @@ -119,7 +119,7 @@ func TestCompileWorkflowToWasm_TS_Success(t *testing.T) { mainPath := filepath.Join(dir, "main.ts") require.NoError(t, os.WriteFile(mainPath, []byte(`export async function main() { return "ok"; } `), 0600)) - require.NoError(t, os.WriteFile(filepath.Join(dir, "package.json"), []byte(`{"name":"test","dependencies":{"@chainlink/cre-sdk":"^1.16.0"}} + require.NoError(t, os.WriteFile(filepath.Join(dir, "package.json"), []byte(`{"name":"test","dependencies":{"@chainlink/cre-sdk":"1.17.0-alpha.solana-log-trigger.1"}} `), 0600)) install := exec.Command("bun", "install") install.Dir = dir diff --git a/cmd/generate-bindings/solana/README.md b/cmd/generate-bindings/solana/README.md index 7e5ff83b..72f2ba62 100644 --- a/cmd/generate-bindings/solana/README.md +++ b/cmd/generate-bindings/solana/README.md @@ -31,20 +31,28 @@ generating both languages at once — use `--language` to disambiguate). ## What gets generated (CRE-reachable surface) -The Solana CRE capability is **write-only** through the keystone-forwarder: the -on-chain entrypoint is always `on_report`, and the payload is a bare -Borsh-encoded struct. Accordingly, both generators emit: +Writes go through the keystone-forwarder: the on-chain entrypoint is always +`on_report`, and the payload is a bare Borsh-encoded struct. Accordingly, both +generators emit: - per-struct write methods: `writeReportFrom` (single) and `writeReportFroms` (Borsh `Vec`, u32-LE count + concatenated elements), - a generic `writeReport(payload)` and `writeReportFromBorshEncodedVec(payloads)`, - pure account/event **decoders** (discriminator-checked) — there is no read/simulate capability, so these only decode bytes obtained elsewhere, +- per-event **log-trigger bindings**: an `Filters` type, + `encodeSubkeys` (EQ comparers, OR across filter rows), and a typed + `logTriggerLog(filterName, filters, opts)` method whose output adapts + the raw log into decoded event data (Go: `bindings.DecodedLog[T]`, TS: + `SolanaDecodedLog`). `opts.cpi` targets Anchor `emit_cpi!` events. Only + top-level scalar fields with supported subkey encodings are auto-filterable; + nested structs, vecs, arrays, bool, u128, and i128 need a manual + `SubkeyConfig`. - a program mock (`newMock`) that intercepts `writeReport` in the test framework. Native Anchor instruction builders and account fetchers are **not** generated -for TypeScript: they are unreachable through the write-only capability. +for TypeScript: they are unreachable through the CRE capability. The wire format mirrors the Go bindings (`cre-sdk-go` solana `bindings` package): diff --git a/cmd/generate-bindings/solana/sourcecre.ts.tpl b/cmd/generate-bindings/solana/sourcecre.ts.tpl index fcd4bfd8..43add2a9 100644 --- a/cmd/generate-bindings/solana/sourcecre.ts.tpl +++ b/cmd/generate-bindings/solana/sourcecre.ts.tpl @@ -10,22 +10,47 @@ import { import { getAddressCodec, type Address } from '@solana/addresses' {{- end}} import { +{{- if .Events}} + adaptTrigger, + anchorCPILogTriggerConfig, + bytesToBase64, +{{- end}} bytesToHex, calculateAccountsHash, encodeBorshVecU32, encodeForwarderReport, prepareSolanaReportRequest, +{{- if .UsesFloatSubkey}} + prepareSubkeyFloatValue, +{{- end}} +{{- if .UsesSubkeyValue}} + prepareSubkeyValue, +{{- end}} type Runtime, type SolanaAccountMeta, SolanaClient, solanaAccountMetasToJson, solanaAddressToBytes, type SolanaComputeConfig, +{{- if .Events}} + type SolanaDecodedLog, + type SolanaFilterLogTriggerRequestJson, + type SolanaLog, + type SolanaLogTriggerOptions, + type SolanaSubkeyConfigJson, + type SolanaValueComparatorJson, + type Trigger, +{{- end}} } from '@chainlink/cre-sdk' export const {{.ProgramIDConst}} = '{{.ProgramID}}' export const {{.IdlConst}} = {{.IdlJSON}} as const +{{- if .Events}} + +// Base64 of the compact IDL JSON, passed to log triggers as contractIdlJson. +const {{.IdlB64Const}} = '{{.IdlB64}}' +{{- end}} {{- if or .Accounts .Events}} const DISCRIMINATOR_SIZE = 8 @@ -111,6 +136,49 @@ export const parseAnyEvent = (data: Uint8Array): {{range $i, $e := .Events}}{{if throw new Error(`unknown event discriminator: [${Array.from(disc).join(', ')}]`) } {{- end}} +{{- range .Triggers}} + +/** + * Optional filter values for {{.Name}} log triggers. Set a field to filter on + * that value (OR across filter rows). Leave unset for wildcard. Only top-level + * scalar fields with supported subkey encodings are auto-filterable — nested + * structs, vecs, arrays, bool, u128, and i128 need a manual SubkeyConfig. + */ +{{- if .FilterFields}} +export type {{.Name}}Filters = { +{{- range .FilterFields}} + {{.Name}}?: {{.TSType}} | null +{{- end}} +} + +export const encode{{.Name}}Subkeys = (filters: {{.Name}}Filters[]): SolanaSubkeyConfigJson[] => { +{{- range .FilterFields}} + const {{.Name}}Comparers: SolanaValueComparatorJson[] = [] +{{- end}} + for (const f of filters) { +{{- range .FilterFields}} + if (f.{{.Name}} != null) { + {{.Name}}Comparers.push({ + operator: 'COMPARISON_OPERATOR_EQ', + value: bytesToBase64({{.EncodeExpr}}), + }) + } +{{- end}} + } + const subkeys: SolanaSubkeyConfigJson[] = [] +{{- range .FilterFields}} + if ({{.Name}}Comparers.length > 0) { + subkeys.push({ path: ['{{.PathName}}'], comparers: {{.Name}}Comparers }) + } +{{- end}} + return subkeys +} +{{- else}} +export type {{.Name}}Filters = Record + +export const encode{{.Name}}Subkeys = (_filters: {{.Name}}Filters[]): SolanaSubkeyConfigJson[] => [] +{{- end}} +{{- end}} {{- if or .Accounts .Events}} {{end}} export class {{.ClassName}} { @@ -204,4 +272,32 @@ export class {{.ClassName}} { ) } {{- end}} +{{- range .Triggers}} + + /** + * Registers a typed log trigger for {{.Name}} events. The trigger + * output is adapted to the decoded {{.Name}} data alongside the raw log. + * Pass opts.cpi for events emitted via Anchor's emit_cpi!. + */ + logTrigger{{.Name}}Log( + filterName: string, + filters: {{.Name}}Filters[] = [], + opts?: SolanaLogTriggerOptions, + ): Trigger> { + const config: SolanaFilterLogTriggerRequestJson = { + name: filterName, + address: bytesToBase64(this.programId), + eventName: '{{.EventName}}', + contractIdlJson: {{$.IdlB64Const}}, + subkeys: encode{{.Name}}Subkeys(filters), + } + if (opts?.cpi) { + config.cpiFilterConfig = anchorCPILogTriggerConfig(this.programId) + } + return adaptTrigger(this.client.logTrigger(config), (log) => ({ + log, + data: decode{{.Name}}Event(log.data), + })) + } +{{- end}} } 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 f39ee95d..e6338638 100644 --- a/cmd/generate-bindings/solana/testdata/data_storage_ts/DataStorage.ts +++ b/cmd/generate-bindings/solana/testdata/data_storage_ts/DataStorage.ts @@ -10,23 +10,37 @@ import { } from '@solana/codecs' import { getAddressCodec, type Address } from '@solana/addresses' import { + adaptTrigger, + anchorCPILogTriggerConfig, + bytesToBase64, bytesToHex, calculateAccountsHash, encodeBorshVecU32, encodeForwarderReport, prepareSolanaReportRequest, + prepareSubkeyValue, type Runtime, type SolanaAccountMeta, SolanaClient, solanaAccountMetasToJson, solanaAddressToBytes, type SolanaComputeConfig, + type SolanaDecodedLog, + type SolanaFilterLogTriggerRequestJson, + type SolanaLog, + type SolanaLogTriggerOptions, + type SolanaSubkeyConfigJson, + type SolanaValueComparatorJson, + type Trigger, } from '@chainlink/cre-sdk' export const DATA_STORAGE_PROGRAM_ID = 'ECL8142j2YQAvs9R9geSsRnkVH2wLEi7soJCRyJ74cfL' export const DATA_STORAGE_IDL = {"address":"ECL8142j2YQAvs9R9geSsRnkVH2wLEi7soJCRyJ74cfL","metadata":{"name":"data_storage","version":"0.1.0","spec":"0.1.0","description":"Created with Anchor"},"instructions":[{"name":"get_multiple_reserves","discriminator":[104,122,140,104,175,151,70,42],"accounts":[],"args":[],"returns":{"vec":{"defined":{"name":"UpdateReserves"}}}},{"name":"get_reserves","discriminator":[121,140,237,84,218,105,48,17],"accounts":[],"args":[],"returns":{"defined":{"name":"UpdateReserves"}}},{"name":"get_tuple_reserves","discriminator":[189,83,186,20,127,80,109,49],"accounts":[],"args":[]},{"name":"initialize_data_account","discriminator":[9,64,78,49,71,193,15,250],"accounts":[{"name":"data_account","writable":true,"pda":{"seeds":[{"kind":"const","value":[100,97,116,97,95,97,99,99,111,117,110,116]},{"kind":"account","path":"user"}]}},{"name":"user","writable":true,"signer":true},{"name":"system_program","address":"11111111111111111111111111111111"}],"args":[{"name":"input","type":{"defined":{"name":"UserData"}}}]},{"name":"log_access","discriminator":[196,55,194,24,5,224,161,204],"accounts":[{"name":"user","signer":true}],"args":[{"name":"message","type":"string"}]},{"name":"on_report","discriminator":[214,173,18,221,173,148,151,208],"accounts":[{"name":"user","writable":true,"signer":true},{"name":"data_account","writable":true,"pda":{"seeds":[{"kind":"const","value":[100,97,116,97,95,97,99,99,111,117,110,116]},{"kind":"account","path":"user"}]}},{"name":"system_program","address":"11111111111111111111111111111111"}],"args":[{"name":"_metadata","type":"bytes"},{"name":"payload","type":"bytes"}]},{"name":"update_key_value_data","discriminator":[67,137,144,35,210,126,254,79],"accounts":[{"name":"user","writable":true,"signer":true},{"name":"data_account","writable":true,"pda":{"seeds":[{"kind":"const","value":[100,97,116,97,95,97,99,99,111,117,110,116]},{"kind":"account","path":"user"}]}}],"args":[{"name":"key","type":"string"},{"name":"value","type":"string"}]},{"name":"update_user_data","discriminator":[11,13,114,150,194,224,192,78],"accounts":[{"name":"user","writable":true,"signer":true},{"name":"data_account","writable":true,"pda":{"seeds":[{"kind":"const","value":[100,97,116,97,95,97,99,99,111,117,110,116]},{"kind":"account","path":"user"}]}}],"args":[{"name":"input","type":{"defined":{"name":"UserData"}}}]}],"accounts":[{"name":"DataAccount","discriminator":[85,240,182,158,76,7,18,233]}],"events":[{"name":"AccessLogged","discriminator":[243,53,225,71,64,120,109,25]},{"name":"DynamicEvent","discriminator":[236,145,224,161,9,222,218,237]},{"name":"NoFields","discriminator":[160,156,94,85,77,122,98,240]}],"errors":[{"code":6000,"name":"DataNotFound","msg":"data not found"}],"types":[{"name":"AccessLogged","type":{"kind":"struct","fields":[{"name":"caller","type":"pubkey"},{"name":"message","type":"string"}]}},{"name":"DataAccount","type":{"kind":"struct","fields":[{"name":"sender","type":"string"},{"name":"key","type":"string"},{"name":"value","type":"string"}]}},{"name":"DynamicEvent","type":{"kind":"struct","fields":[{"name":"key","type":"string"},{"name":"user_data","type":{"defined":{"name":"UserData"}}},{"name":"sender","type":"string"},{"name":"metadata","type":"bytes"},{"name":"metadata_array","type":{"vec":"bytes"}}]}},{"name":"NoFields","type":{"kind":"struct","fields":[]}},{"name":"UpdateReserves","type":{"kind":"struct","fields":[{"name":"total_minted","type":"u64"},{"name":"total_reserve","type":"u64"}]}},{"name":"UserData","type":{"kind":"struct","fields":[{"name":"key","type":"string"},{"name":"value","type":"string"}]}}]} as const +// Base64 of the compact IDL JSON, passed to log triggers as contractIdlJson. +const DATA_STORAGE_IDL_BASE64 = 'eyJhZGRyZXNzIjoiRUNMODE0MmoyWVFBdnM5UjlnZVNzUm5rVkgyd0xFaTdzb0pDUnlKNzRjZkwiLCJtZXRhZGF0YSI6eyJuYW1lIjoiZGF0YV9zdG9yYWdlIiwidmVyc2lvbiI6IjAuMS4wIiwic3BlYyI6IjAuMS4wIiwiZGVzY3JpcHRpb24iOiJDcmVhdGVkIHdpdGggQW5jaG9yIn0sImluc3RydWN0aW9ucyI6W3sibmFtZSI6ImdldF9tdWx0aXBsZV9yZXNlcnZlcyIsImRpc2NyaW1pbmF0b3IiOlsxMDQsMTIyLDE0MCwxMDQsMTc1LDE1MSw3MCw0Ml0sImFjY291bnRzIjpbXSwiYXJncyI6W10sInJldHVybnMiOnsidmVjIjp7ImRlZmluZWQiOnsibmFtZSI6IlVwZGF0ZVJlc2VydmVzIn19fX0seyJuYW1lIjoiZ2V0X3Jlc2VydmVzIiwiZGlzY3JpbWluYXRvciI6WzEyMSwxNDAsMjM3LDg0LDIxOCwxMDUsNDgsMTddLCJhY2NvdW50cyI6W10sImFyZ3MiOltdLCJyZXR1cm5zIjp7ImRlZmluZWQiOnsibmFtZSI6IlVwZGF0ZVJlc2VydmVzIn19fSx7Im5hbWUiOiJnZXRfdHVwbGVfcmVzZXJ2ZXMiLCJkaXNjcmltaW5hdG9yIjpbMTg5LDgzLDE4NiwyMCwxMjcsODAsMTA5LDQ5XSwiYWNjb3VudHMiOltdLCJhcmdzIjpbXX0seyJuYW1lIjoiaW5pdGlhbGl6ZV9kYXRhX2FjY291bnQiLCJkaXNjcmltaW5hdG9yIjpbOSw2NCw3OCw0OSw3MSwxOTMsMTUsMjUwXSwiYWNjb3VudHMiOlt7Im5hbWUiOiJkYXRhX2FjY291bnQiLCJ3cml0YWJsZSI6dHJ1ZSwicGRhIjp7InNlZWRzIjpbeyJraW5kIjoiY29uc3QiLCJ2YWx1ZSI6WzEwMCw5NywxMTYsOTcsOTUsOTcsOTksOTksMTExLDExNywxMTAsMTE2XX0seyJraW5kIjoiYWNjb3VudCIsInBhdGgiOiJ1c2VyIn1dfX0seyJuYW1lIjoidXNlciIsIndyaXRhYmxlIjp0cnVlLCJzaWduZXIiOnRydWV9LHsibmFtZSI6InN5c3RlbV9wcm9ncmFtIiwiYWRkcmVzcyI6IjExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExIn1dLCJhcmdzIjpbeyJuYW1lIjoiaW5wdXQiLCJ0eXBlIjp7ImRlZmluZWQiOnsibmFtZSI6IlVzZXJEYXRhIn19fV19LHsibmFtZSI6ImxvZ19hY2Nlc3MiLCJkaXNjcmltaW5hdG9yIjpbMTk2LDU1LDE5NCwyNCw1LDIyNCwxNjEsMjA0XSwiYWNjb3VudHMiOlt7Im5hbWUiOiJ1c2VyIiwic2lnbmVyIjp0cnVlfV0sImFyZ3MiOlt7Im5hbWUiOiJtZXNzYWdlIiwidHlwZSI6InN0cmluZyJ9XX0seyJuYW1lIjoib25fcmVwb3J0IiwiZGlzY3JpbWluYXRvciI6WzIxNCwxNzMsMTgsMjIxLDE3MywxNDgsMTUxLDIwOF0sImFjY291bnRzIjpbeyJuYW1lIjoidXNlciIsIndyaXRhYmxlIjp0cnVlLCJzaWduZXIiOnRydWV9LHsibmFtZSI6ImRhdGFfYWNjb3VudCIsIndyaXRhYmxlIjp0cnVlLCJwZGEiOnsic2VlZHMiOlt7ImtpbmQiOiJjb25zdCIsInZhbHVlIjpbMTAwLDk3LDExNiw5Nyw5NSw5Nyw5OSw5OSwxMTEsMTE3LDExMCwxMTZdfSx7ImtpbmQiOiJhY2NvdW50IiwicGF0aCI6InVzZXIifV19fSx7Im5hbWUiOiJzeXN0ZW1fcHJvZ3JhbSIsImFkZHJlc3MiOiIxMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMSJ9XSwiYXJncyI6W3sibmFtZSI6Il9tZXRhZGF0YSIsInR5cGUiOiJieXRlcyJ9LHsibmFtZSI6InBheWxvYWQiLCJ0eXBlIjoiYnl0ZXMifV19LHsibmFtZSI6InVwZGF0ZV9rZXlfdmFsdWVfZGF0YSIsImRpc2NyaW1pbmF0b3IiOls2NywxMzcsMTQ0LDM1LDIxMCwxMjYsMjU0LDc5XSwiYWNjb3VudHMiOlt7Im5hbWUiOiJ1c2VyIiwid3JpdGFibGUiOnRydWUsInNpZ25lciI6dHJ1ZX0seyJuYW1lIjoiZGF0YV9hY2NvdW50Iiwid3JpdGFibGUiOnRydWUsInBkYSI6eyJzZWVkcyI6W3sia2luZCI6ImNvbnN0IiwidmFsdWUiOlsxMDAsOTcsMTE2LDk3LDk1LDk3LDk5LDk5LDExMSwxMTcsMTEwLDExNl19LHsia2luZCI6ImFjY291bnQiLCJwYXRoIjoidXNlciJ9XX19XSwiYXJncyI6W3sibmFtZSI6ImtleSIsInR5cGUiOiJzdHJpbmcifSx7Im5hbWUiOiJ2YWx1ZSIsInR5cGUiOiJzdHJpbmcifV19LHsibmFtZSI6InVwZGF0ZV91c2VyX2RhdGEiLCJkaXNjcmltaW5hdG9yIjpbMTEsMTMsMTE0LDE1MCwxOTQsMjI0LDE5Miw3OF0sImFjY291bnRzIjpbeyJuYW1lIjoidXNlciIsIndyaXRhYmxlIjp0cnVlLCJzaWduZXIiOnRydWV9LHsibmFtZSI6ImRhdGFfYWNjb3VudCIsIndyaXRhYmxlIjp0cnVlLCJwZGEiOnsic2VlZHMiOlt7ImtpbmQiOiJjb25zdCIsInZhbHVlIjpbMTAwLDk3LDExNiw5Nyw5NSw5Nyw5OSw5OSwxMTEsMTE3LDExMCwxMTZdfSx7ImtpbmQiOiJhY2NvdW50IiwicGF0aCI6InVzZXIifV19fV0sImFyZ3MiOlt7Im5hbWUiOiJpbnB1dCIsInR5cGUiOnsiZGVmaW5lZCI6eyJuYW1lIjoiVXNlckRhdGEifX19XX1dLCJhY2NvdW50cyI6W3sibmFtZSI6IkRhdGFBY2NvdW50IiwiZGlzY3JpbWluYXRvciI6Wzg1LDI0MCwxODIsMTU4LDc2LDcsMTgsMjMzXX1dLCJldmVudHMiOlt7Im5hbWUiOiJBY2Nlc3NMb2dnZWQiLCJkaXNjcmltaW5hdG9yIjpbMjQzLDUzLDIyNSw3MSw2NCwxMjAsMTA5LDI1XX0seyJuYW1lIjoiRHluYW1pY0V2ZW50IiwiZGlzY3JpbWluYXRvciI6WzIzNiwxNDUsMjI0LDE2MSw5LDIyMiwyMTgsMjM3XX0seyJuYW1lIjoiTm9GaWVsZHMiLCJkaXNjcmltaW5hdG9yIjpbMTYwLDE1Niw5NCw4NSw3NywxMjIsOTgsMjQwXX1dLCJlcnJvcnMiOlt7ImNvZGUiOjYwMDAsIm5hbWUiOiJEYXRhTm90Rm91bmQiLCJtc2ciOiJkYXRhIG5vdCBmb3VuZCJ9XSwidHlwZXMiOlt7Im5hbWUiOiJBY2Nlc3NMb2dnZWQiLCJ0eXBlIjp7ImtpbmQiOiJzdHJ1Y3QiLCJmaWVsZHMiOlt7Im5hbWUiOiJjYWxsZXIiLCJ0eXBlIjoicHVia2V5In0seyJuYW1lIjoibWVzc2FnZSIsInR5cGUiOiJzdHJpbmcifV19fSx7Im5hbWUiOiJEYXRhQWNjb3VudCIsInR5cGUiOnsia2luZCI6InN0cnVjdCIsImZpZWxkcyI6W3sibmFtZSI6InNlbmRlciIsInR5cGUiOiJzdHJpbmcifSx7Im5hbWUiOiJrZXkiLCJ0eXBlIjoic3RyaW5nIn0seyJuYW1lIjoidmFsdWUiLCJ0eXBlIjoic3RyaW5nIn1dfX0seyJuYW1lIjoiRHluYW1pY0V2ZW50IiwidHlwZSI6eyJraW5kIjoic3RydWN0IiwiZmllbGRzIjpbeyJuYW1lIjoia2V5IiwidHlwZSI6InN0cmluZyJ9LHsibmFtZSI6InVzZXJfZGF0YSIsInR5cGUiOnsiZGVmaW5lZCI6eyJuYW1lIjoiVXNlckRhdGEifX19LHsibmFtZSI6InNlbmRlciIsInR5cGUiOiJzdHJpbmcifSx7Im5hbWUiOiJtZXRhZGF0YSIsInR5cGUiOiJieXRlcyJ9LHsibmFtZSI6Im1ldGFkYXRhX2FycmF5IiwidHlwZSI6eyJ2ZWMiOiJieXRlcyJ9fV19fSx7Im5hbWUiOiJOb0ZpZWxkcyIsInR5cGUiOnsia2luZCI6InN0cnVjdCIsImZpZWxkcyI6W119fSx7Im5hbWUiOiJVcGRhdGVSZXNlcnZlcyIsInR5cGUiOnsia2luZCI6InN0cnVjdCIsImZpZWxkcyI6W3sibmFtZSI6InRvdGFsX21pbnRlZCIsInR5cGUiOiJ1NjQifSx7Im5hbWUiOiJ0b3RhbF9yZXNlcnZlIiwidHlwZSI6InU2NCJ9XX19LHsibmFtZSI6IlVzZXJEYXRhIiwidHlwZSI6eyJraW5kIjoic3RydWN0IiwiZmllbGRzIjpbeyJuYW1lIjoia2V5IiwidHlwZSI6InN0cmluZyJ9LHsibmFtZSI6InZhbHVlIiwidHlwZSI6InN0cmluZyJ9XX19XX0=' + const DISCRIMINATOR_SIZE = 8 const expectDiscriminator = (label: string, expected: Uint8Array, data: Uint8Array): Uint8Array => { @@ -152,6 +166,103 @@ export const parseAnyEvent = (data: Uint8Array): AccessLogged | DynamicEvent | N throw new Error(`unknown event discriminator: [${Array.from(disc).join(', ')}]`) } +/** + * Optional filter values for AccessLogged log triggers. Set a field to filter on + * that value (OR across filter rows). Leave unset for wildcard. Only top-level + * scalar fields with supported subkey encodings are auto-filterable — nested + * structs, vecs, arrays, bool, u128, and i128 need a manual SubkeyConfig. + */ +export type AccessLoggedFilters = { + caller?: Address | null + message?: string | null +} + +export const encodeAccessLoggedSubkeys = (filters: AccessLoggedFilters[]): SolanaSubkeyConfigJson[] => { + const callerComparers: SolanaValueComparatorJson[] = [] + const messageComparers: SolanaValueComparatorJson[] = [] + for (const f of filters) { + if (f.caller != null) { + callerComparers.push({ + operator: 'COMPARISON_OPERATOR_EQ', + value: bytesToBase64(solanaAddressToBytes(f.caller)), + }) + } + if (f.message != null) { + messageComparers.push({ + operator: 'COMPARISON_OPERATOR_EQ', + value: bytesToBase64(prepareSubkeyValue(f.message)), + }) + } + } + const subkeys: SolanaSubkeyConfigJson[] = [] + if (callerComparers.length > 0) { + subkeys.push({ path: ['Caller'], comparers: callerComparers }) + } + if (messageComparers.length > 0) { + subkeys.push({ path: ['Message'], comparers: messageComparers }) + } + return subkeys +} + +/** + * Optional filter values for DynamicEvent log triggers. Set a field to filter on + * that value (OR across filter rows). Leave unset for wildcard. Only top-level + * scalar fields with supported subkey encodings are auto-filterable — nested + * structs, vecs, arrays, bool, u128, and i128 need a manual SubkeyConfig. + */ +export type DynamicEventFilters = { + key?: string | null + sender?: string | null + metadata?: Uint8Array | null +} + +export const encodeDynamicEventSubkeys = (filters: DynamicEventFilters[]): SolanaSubkeyConfigJson[] => { + const keyComparers: SolanaValueComparatorJson[] = [] + const senderComparers: SolanaValueComparatorJson[] = [] + const metadataComparers: SolanaValueComparatorJson[] = [] + for (const f of filters) { + if (f.key != null) { + keyComparers.push({ + operator: 'COMPARISON_OPERATOR_EQ', + value: bytesToBase64(prepareSubkeyValue(f.key)), + }) + } + if (f.sender != null) { + senderComparers.push({ + operator: 'COMPARISON_OPERATOR_EQ', + value: bytesToBase64(prepareSubkeyValue(f.sender)), + }) + } + if (f.metadata != null) { + metadataComparers.push({ + operator: 'COMPARISON_OPERATOR_EQ', + value: bytesToBase64(prepareSubkeyValue(f.metadata)), + }) + } + } + const subkeys: SolanaSubkeyConfigJson[] = [] + if (keyComparers.length > 0) { + subkeys.push({ path: ['Key'], comparers: keyComparers }) + } + if (senderComparers.length > 0) { + subkeys.push({ path: ['Sender'], comparers: senderComparers }) + } + if (metadataComparers.length > 0) { + subkeys.push({ path: ['Metadata'], comparers: metadataComparers }) + } + return subkeys +} + +/** + * Optional filter values for NoFields log triggers. Set a field to filter on + * that value (OR across filter rows). Leave unset for wildcard. Only top-level + * scalar fields with supported subkey encodings are auto-filterable — nested + * structs, vecs, arrays, bool, u128, and i128 need a manual SubkeyConfig. + */ +export type NoFieldsFilters = Record + +export const encodeNoFieldsSubkeys = (_filters: NoFieldsFilters[]): SolanaSubkeyConfigJson[] => [] + export class DataStorage { readonly programId: Uint8Array @@ -356,4 +467,82 @@ export class DataStorage { computeConfig, ) } + + /** + * Registers a typed log trigger for AccessLogged events. The trigger + * output is adapted to the decoded AccessLogged data alongside the raw log. + * Pass opts.cpi for events emitted via Anchor's emit_cpi!. + */ + logTriggerAccessLoggedLog( + filterName: string, + filters: AccessLoggedFilters[] = [], + opts?: SolanaLogTriggerOptions, + ): Trigger> { + const config: SolanaFilterLogTriggerRequestJson = { + name: filterName, + address: bytesToBase64(this.programId), + eventName: 'AccessLogged', + contractIdlJson: DATA_STORAGE_IDL_BASE64, + subkeys: encodeAccessLoggedSubkeys(filters), + } + if (opts?.cpi) { + config.cpiFilterConfig = anchorCPILogTriggerConfig(this.programId) + } + return adaptTrigger(this.client.logTrigger(config), (log) => ({ + log, + data: decodeAccessLoggedEvent(log.data), + })) + } + + /** + * Registers a typed log trigger for DynamicEvent events. The trigger + * output is adapted to the decoded DynamicEvent data alongside the raw log. + * Pass opts.cpi for events emitted via Anchor's emit_cpi!. + */ + logTriggerDynamicEventLog( + filterName: string, + filters: DynamicEventFilters[] = [], + opts?: SolanaLogTriggerOptions, + ): Trigger> { + const config: SolanaFilterLogTriggerRequestJson = { + name: filterName, + address: bytesToBase64(this.programId), + eventName: 'DynamicEvent', + contractIdlJson: DATA_STORAGE_IDL_BASE64, + subkeys: encodeDynamicEventSubkeys(filters), + } + if (opts?.cpi) { + config.cpiFilterConfig = anchorCPILogTriggerConfig(this.programId) + } + return adaptTrigger(this.client.logTrigger(config), (log) => ({ + log, + data: decodeDynamicEventEvent(log.data), + })) + } + + /** + * Registers a typed log trigger for NoFields events. The trigger + * output is adapted to the decoded NoFields data alongside the raw log. + * Pass opts.cpi for events emitted via Anchor's emit_cpi!. + */ + logTriggerNoFieldsLog( + filterName: string, + filters: NoFieldsFilters[] = [], + opts?: SolanaLogTriggerOptions, + ): Trigger> { + const config: SolanaFilterLogTriggerRequestJson = { + name: filterName, + address: bytesToBase64(this.programId), + eventName: 'NoFields', + contractIdlJson: DATA_STORAGE_IDL_BASE64, + subkeys: encodeNoFieldsSubkeys(filters), + } + if (opts?.cpi) { + config.cpiFilterConfig = anchorCPILogTriggerConfig(this.programId) + } + return adaptTrigger(this.client.logTrigger(config), (log) => ({ + log, + data: decodeNoFieldsEvent(log.data), + })) + } } diff --git a/cmd/generate-bindings/solana/tsbindgen.go b/cmd/generate-bindings/solana/tsbindgen.go index ce7fbf97..25a8f22f 100644 --- a/cmd/generate-bindings/solana/tsbindgen.go +++ b/cmd/generate-bindings/solana/tsbindgen.go @@ -3,6 +3,7 @@ package solana import ( "bytes" _ "embed" + "encoding/base64" "encoding/json" "fmt" "os" @@ -71,28 +72,51 @@ type tsDecoderDef struct { Discriminator string // JS array body, e.g. "85, 240, 182, ..." } +// tsTriggerFilterField is one auto-filterable event field of a log-trigger +// filters type. +type tsTriggerFilterField struct { + Name string // camelCase TS property name + TSType string // TS type of the filter value (option types unwrapped) + PathName string // PascalCase subkey path element (matches the Go bindings) + EncodeExpr string // TS expression producing the comparator value bytes from `f.` +} + +// tsTriggerDef describes the generated log-trigger surface for one IDL event. +type tsTriggerDef struct { + EventName string // IDL event name, sent verbatim as the request's eventName + Name string // PascalCase TS name + TypeName string // TS type the log data decodes into + FilterFields []tsTriggerFilterField +} + type tsBindingData struct { - ProgramName string - ClassName string - MockName string - ProgramIDConst string - IdlConst string - ProgramID string - IdlJSON string - CodecImports []string - UsesAddress bool - Types []tsTypeDef - StructTypes []tsTypeDef - Accounts []tsDecoderDef - Events []tsDecoderDef + ProgramName string + ClassName string + MockName string + ProgramIDConst string + IdlConst string + IdlB64Const string + ProgramID string + IdlJSON string + IdlB64 string + CodecImports []string + UsesAddress bool + UsesSubkeyValue bool + UsesFloatSubkey bool + Types []tsTypeDef + StructTypes []tsTypeDef + Accounts []tsDecoderDef + Events []tsDecoderDef + Triggers []tsTriggerDef } // GenerateBindingsTS generates the TypeScript CRE binding (.ts) and its // mock (_mock.ts) for one Anchor IDL file. It mirrors the CRE-reachable // surface of the Go generator: per-struct writeReportFrom(s) write -// methods plus pure account/event decoders. Native instruction builders and -// account fetchers are intentionally not generated — they are unreachable -// through the write-only Solana CRE capability. +// methods, pure account/event decoders, and per-event log-trigger bindings +// (Filters + encodeSubkeys + logTriggerLog). Native +// instruction builders and account fetchers are intentionally not generated — +// they are unreachable through the Solana CRE capability. func GenerateBindingsTS( pathToIdl string, programName string, @@ -190,8 +214,10 @@ func buildTSBindingData(parsedIdl *idl.Idl, programName, idlJSON string) (*tsBin MockName: className + "Mock", ProgramIDConst: toUpperSnake(programName) + "_PROGRAM_ID", IdlConst: toUpperSnake(programName) + "_IDL", + IdlB64Const: toUpperSnake(programName) + "_IDL_BASE64", ProgramID: programID, IdlJSON: idlJSON, + IdlB64: base64.StdEncoding.EncodeToString([]byte(idlJSON)), } typeNames := map[string]string{} // TS name -> IDL name (collision detection) @@ -248,14 +274,98 @@ func buildTSBindingData(parsedIdl *idl.Idl, programName, idlJSON string) (*tsBin return nil, err } data.Events = append(data.Events, *decoder) + + trigger, err := buildTriggerDef(event.Name, decoder, mapper, typeNames) + if err != nil { + return nil, err + } + data.Triggers = append(data.Triggers, *trigger) } data.CodecImports = mapper.sortedImports() data.UsesAddress = mapper.usesAddress + data.UsesSubkeyValue = mapper.usesSubkeyValue + data.UsesFloatSubkey = mapper.usesFloatSubkey return data, nil } +// buildTriggerDef builds the log-trigger binding data for one IDL event, +// mirroring the Go generator's trigger bindings: only top-level scalar fields +// with supported subkey encodings become filter fields (nested structs, vecs, +// arrays, bool, u128, and i128 need a manual SubkeyConfig). +func buildTriggerDef( + eventName string, + decoder *tsDecoderDef, + mapper *tsTypeMapper, + typeNames map[string]string, +) (*tsTriggerDef, error) { + filtersTypeName := decoder.Name + "Filters" + if prev, exists := typeNames[filtersTypeName]; exists { + return nil, fmt.Errorf("event %q: filters type %q collides with IDL type %q", eventName, filtersTypeName, prev) + } + + trigger := &tsTriggerDef{ + EventName: eventName, + Name: decoder.Name, + TypeName: decoder.TypeName, + } + + def, exists := mapper.defs[eventName] + if !exists { + // buildDecoderDef resolved the type via toPascalCase; find it the same way. + for idlName := range mapper.defs { + if toPascalCase(idlName) == decoder.Name { + def = mapper.defs[idlName] + break + } + } + } + if def == nil { + return nil, fmt.Errorf("event %q has no matching type definition in the IDL types section", eventName) + } + + structTy, ok := def.Ty.(*idl.IdlTypeDefTyStruct) + if !ok { + return nil, fmt.Errorf("event %q: type %q is not a struct", eventName, def.Name) + } + var named idl.IdlDefinedFieldsNamed + if structTy.Fields != nil { + named, ok = structTy.Fields.(idl.IdlDefinedFieldsNamed) + if !ok { + return nil, fmt.Errorf("event %q: tuple struct fields are not supported by the TypeScript generator yet", eventName) + } + } + + seen := map[string]string{} + for _, field := range named { + fieldName := toCamelCase(field.Name) + if jsReservedWords[fieldName] { + fieldName += "_" + } + tsType, encodeExpr, ok, err := mapper.filterField(field.Ty, "f."+fieldName, fmt.Sprintf("%s.%s", def.Name, field.Name)) + if err != nil { + return nil, err + } + if !ok { + continue + } + if prev, exists := seen[fieldName]; exists { + return nil, fmt.Errorf("event %q: fields %q and %q both map to %q", eventName, prev, field.Name, fieldName) + } + seen[fieldName] = field.Name + + trigger.FilterFields = append(trigger.FilterFields, tsTriggerFilterField{ + Name: fieldName, + TSType: tsType, + PathName: toPascalCase(field.Name), + EncodeExpr: encodeExpr, + }) + } + + return trigger, nil +} + func buildDecoderDef(kind, name string, discriminator []byte, typesByName map[string]tsTypeDef) (*tsDecoderDef, error) { tsName := toPascalCase(name) def, exists := typesByName[tsName] @@ -367,9 +477,43 @@ func definedRefsOfTypeDef(def *idl.IdlTypeDef) ([]string, error) { } type tsTypeMapper struct { - defs map[string]*idl.IdlTypeDef - imports map[string]bool - usesAddress bool + defs map[string]*idl.IdlTypeDef + imports map[string]bool + usesAddress bool + usesSubkeyValue bool + usesFloatSubkey bool +} + +// filterField maps an event field to its TS filter-value type and the +// expression encoding `access` (e.g. "f.caller") into the comparator value +// bytes. ok=false means the field is not auto-filterable (mirrors the Go +// generator's isFilterableField: scalars only, minus bool and 128/256-bit +// integers; option wrappers are unwrapped). Encodings match the Go bindings' +// PrepareSubkeyValue. +func (m *tsTypeMapper) filterField(t idltype.IdlType, access, owner string) (tsType, encodeExpr string, ok bool, err error) { + switch typed := t.(type) { + case *idltype.Option: + return m.filterField(typed.Option, access, owner) + case *idltype.COption: + return m.filterField(typed.COption, access, owner) + case *idltype.U8, *idltype.I8, *idltype.U16, *idltype.I16, *idltype.U32, *idltype.I32, + *idltype.U64, *idltype.I64, *idltype.String, *idltype.Bytes: + m.usesSubkeyValue = true + encodeExpr = fmt.Sprintf("prepareSubkeyValue(%s)", access) + case *idltype.F32, *idltype.F64: + m.usesFloatSubkey = true + encodeExpr = fmt.Sprintf("prepareSubkeyFloatValue(%s)", access) + case *idltype.Pubkey: + encodeExpr = fmt.Sprintf("solanaAddressToBytes(%s)", access) + default: + return "", "", false, nil + } + // t is unwrapped here; mapType owns the TS type (and usesAddress for pubkey). + tsType, _, err = m.mapType(t, owner) + if err != nil { + return "", "", false, err + } + return tsType, encodeExpr, true, nil } func (m *tsTypeMapper) sortedImports() []string { diff --git a/cmd/generate-bindings/solana/tsbindgen_test.go b/cmd/generate-bindings/solana/tsbindgen_test.go index 9c4070b4..6b1d2689 100644 --- a/cmd/generate-bindings/solana/tsbindgen_test.go +++ b/cmd/generate-bindings/solana/tsbindgen_test.go @@ -141,6 +141,111 @@ func TestGenerateBindingsTS_DispatchFunctions(t *testing.T) { assert.Contains(t, source, "unknown event discriminator") } +func TestGenerateBindingsTS_LogTriggers(t *testing.T) { + outDir := t.TempDir() + _, err := GenerateBindingsTS( + filepath.Join("testdata", "contracts", "idl", "data_storage.json"), + "data_storage", + outDir, + ) + require.NoError(t, err) + + content, err := os.ReadFile(filepath.Join(outDir, "DataStorage.ts")) + require.NoError(t, err) + source := string(content) + + // Per-event filters type, subkey encoder, and typed trigger method. + assert.Contains(t, source, "export type AccessLoggedFilters = {") + assert.Contains(t, source, "caller?: Address | null") + assert.Contains(t, source, "export const encodeAccessLoggedSubkeys = (filters: AccessLoggedFilters[]): SolanaSubkeyConfigJson[] =>") + assert.Contains(t, source, "logTriggerAccessLoggedLog(") + assert.Contains(t, source, "): Trigger> {") + // Subkey paths use the Go bindings' PascalCase names. + assert.Contains(t, source, "subkeys.push({ path: ['Caller'], comparers: callerComparers })") + // 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. + assert.Contains(t, source, "const DATA_STORAGE_IDL_BASE64 = '") + assert.Contains(t, source, "contractIdlJson: DATA_STORAGE_IDL_BASE64,") + // CPI opt-in wires the anchor:event self-CPI filter. + assert.Contains(t, source, "config.cpiFilterConfig = anchorCPILogTriggerConfig(this.programId)") + // The decoded data rides along with the raw log. + assert.Contains(t, source, "data: decodeAccessLoggedEvent(log.data),") + // Non-filterable fields (vec, defined) are excluded from DynamicEvent filters. + assert.NotContains(t, source, "userData?:") + assert.NotContains(t, source, "metadataArray?:") + // An event with no filterable fields still gets a trigger with empty filters. + assert.Contains(t, source, "export type NoFieldsFilters = Record") + assert.Contains(t, source, "export const encodeNoFieldsSubkeys = (_filters: NoFieldsFilters[]): SolanaSubkeyConfigJson[] => []") + assert.Contains(t, source, "logTriggerNoFieldsLog(") + + // An IDL without events must not emit trigger code or its imports. + fmOut := t.TempDir() + _, err = GenerateBindingsTS( + filepath.Join("testdata", "contracts", "idl", "feature_matrix.json"), + "feature_matrix", + fmOut, + ) + require.NoError(t, err) + fmContent, err := os.ReadFile(filepath.Join(fmOut, "FeatureMatrix.ts")) + require.NoError(t, err) + assert.NotContains(t, string(fmContent), "logTrigger") + assert.NotContains(t, string(fmContent), "anchorCPILogTriggerConfig") + assert.NotContains(t, string(fmContent), "prepareSubkeyValue") +} + +func TestGenerateBindingsTS_TriggerFilterEncodings(t *testing.T) { + idl := `{ + "address": "ECL8142j2YQAvs9R9geSsRnkVH2wLEi7soJCRyJ74cfL", + "metadata": {"name": "encodings", "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": "Mixed", "discriminator": [1,2,3,4,5,6,7,8]}], + "errors": [], + "types": [{"name": "Mixed", "type": {"kind": "struct", "fields": [ + {"name": "small", "type": "u8"}, + {"name": "amount", "type": "u64"}, + {"name": "ratio", "type": "f64"}, + {"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]}} + ]}}] +}` + idlPath := filepath.Join(t.TempDir(), "encodings.json") + require.NoError(t, os.WriteFile(idlPath, []byte(idl), 0o600)) + + outDir := t.TempDir() + _, err := GenerateBindingsTS(idlPath, "encodings", outDir) + require.NoError(t, err) + + content, err := os.ReadFile(filepath.Join(outDir, "Encodings.ts")) + require.NoError(t, err) + source := string(content) + + // Scalar filter fields with their subkey encoders. + 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))") + assert.Contains(t, source, "value: bytesToBase64(prepareSubkeyFloatValue(f.ratio))") + assert.Contains(t, source, "prepareSubkeyFloatValue,") + // Subkey paths use the Go bindings' PascalCase names. + assert.Contains(t, source, "path: ['MaybeTag']") + // bool, u128, vec, and fixed arrays are not auto-filterable. + assert.NotContains(t, source, "flag?:") + assert.NotContains(t, source, "huge?:") + assert.NotContains(t, source, "list?:") + assert.NotContains(t, source, "fixed?:") +} + func TestGenerateBindingsTS_FailLoud(t *testing.T) { cases := []struct { name string diff --git a/cmd/workflow/convert/convert_test.go b/cmd/workflow/convert/convert_test.go index 7bfc501d..71236363 100644 --- a/cmd/workflow/convert/convert_test.go +++ b/cmd/workflow/convert/convert_test.go @@ -303,7 +303,7 @@ production-settings: ` require.NoError(t, os.WriteFile(workflowYAML, []byte(yamlContent), 0600)) require.NoError(t, os.WriteFile(mainTS, []byte("export default function run() { return Promise.resolve({ result: \"ok\" }); }\n"), 0600)) - require.NoError(t, os.WriteFile(packageJSON, []byte(`{"name":"test","private":true,"dependencies":{"@chainlink/cre-sdk":"^1.16.0"}}`), 0600)) + require.NoError(t, os.WriteFile(packageJSON, []byte(`{"name":"test","private":true,"dependencies":{"@chainlink/cre-sdk":"1.17.0-alpha.solana-log-trigger.1"}}`), 0600)) h := newHandler(nil) err := h.Execute(Inputs{WorkflowFolder: dir, Force: true}) diff --git a/internal/templaterepo/builtin/hello-world-ts/workflow/package.json b/internal/templaterepo/builtin/hello-world-ts/workflow/package.json index ed40abe9..60595f85 100644 --- a/internal/templaterepo/builtin/hello-world-ts/workflow/package.json +++ b/internal/templaterepo/builtin/hello-world-ts/workflow/package.json @@ -8,7 +8,7 @@ }, "license": "UNLICENSED", "dependencies": { - "@chainlink/cre-sdk": "^1.16.0" + "@chainlink/cre-sdk": "1.17.0-alpha.solana-log-trigger.1" }, "devDependencies": { "typescript": "5.9.3"