From 378419f48ae12c41b705beb71b6202062b0c91bb Mon Sep 17 00:00:00 2001 From: ernest-nowacki Date: Mon, 22 Jun 2026 21:03:22 +0200 Subject: [PATCH 1/7] Prepare ts solana binding generator --- cmd/generate-bindings/solana/README.md | 85 +++ .../solana/mockcontract.ts.tpl | 19 + cmd/generate-bindings/solana/solana.go | 261 +++++--- cmd/generate-bindings/solana/solana_test.go | 19 +- cmd/generate-bindings/solana/sourcecre.ts.tpl | 207 ++++++ .../contracts/idl/feature_matrix.json | 78 +++ .../testdata/data_storage_ts/DataStorage.ts | 359 ++++++++++ .../data_storage_ts/DataStorage_mock.ts | 19 + .../feature_matrix_ts/FeatureMatrix.ts | 227 +++++++ .../feature_matrix_ts/FeatureMatrix_mock.ts | 19 + .../solana/testdata/gen/main.go | 16 + cmd/generate-bindings/solana/tsbindgen.go | 620 ++++++++++++++++++ .../solana/tsbindgen_test.go | 196 ++++++ .../hello-world-ts/workflow/package.json | 2 +- 14 files changed, 2042 insertions(+), 85 deletions(-) create mode 100644 cmd/generate-bindings/solana/README.md create mode 100644 cmd/generate-bindings/solana/mockcontract.ts.tpl create mode 100644 cmd/generate-bindings/solana/sourcecre.ts.tpl create mode 100644 cmd/generate-bindings/solana/testdata/contracts/idl/feature_matrix.json create mode 100644 cmd/generate-bindings/solana/testdata/data_storage_ts/DataStorage.ts create mode 100644 cmd/generate-bindings/solana/testdata/data_storage_ts/DataStorage_mock.ts create mode 100644 cmd/generate-bindings/solana/testdata/feature_matrix_ts/FeatureMatrix.ts create mode 100644 cmd/generate-bindings/solana/testdata/feature_matrix_ts/FeatureMatrix_mock.ts create mode 100644 cmd/generate-bindings/solana/tsbindgen.go create mode 100644 cmd/generate-bindings/solana/tsbindgen_test.go diff --git a/cmd/generate-bindings/solana/README.md b/cmd/generate-bindings/solana/README.md new file mode 100644 index 00000000..7e5ff83b --- /dev/null +++ b/cmd/generate-bindings/solana/README.md @@ -0,0 +1,85 @@ +# CRE Solana Generated Bindings + +Generates CRE workflow bindings from Anchor IDL files, for **Go** and +**TypeScript**. + +The Go generator wraps a forked [anchor-go](https://github.com/gagliardetto/anchor-go) +(vendored under `./anchor-go`) and adds CRE write-report extensions. The +TypeScript generator (`tsbindgen.go`) consumes the same IDLs and emits bindings +on top of `@chainlink/cre-sdk` + `@solana/codecs`. + +## Usage + +```bash +# auto-detects go/typescript from project files +cre generate-bindings solana + +# explicit +cre generate-bindings solana --language typescript +cre generate-bindings solana --language go +``` + +Defaults: + +| | IDL input | Output | +|---|---|---| +| Go | `contracts/solana/src/idl/*.json` | `contracts/solana/src/generated//` (one package per program) | +| TypeScript | `contracts/solana/src/idl/*.json` | `contracts/solana/ts/generated/.ts` + `_mock.ts` + `index.ts` barrel | + +`--out` overrides the output directory for the selected language (rejected when +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: + +- 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, +- 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. + +The wire format mirrors the Go bindings (`cre-sdk-go` solana `bindings` package): + +``` +ForwarderReport = [32-byte sha256(concat account pubkeys)][u32-LE payload len][payload] +report request: EncoderName="solana", SigningAlgo="ecdsa", HashingAlgo="keccak256" +``` + +## TypeScript type coverage (v1) + +| Anchor type | TypeScript | Codec | +|---|---|---| +| bool | boolean | `getBooleanCodec()` | +| u8…u32 / i8…i32 | number | `getU8Codec()`… | +| u64/u128 / i64/i128 | bigint | `getU64Codec()`… | +| f32 / f64 | number | `getF32Codec()` / `getF64Codec()` | +| string | string | `addCodecSizePrefix(getUtf8Codec(), getU32Codec())` | +| bytes | Uint8Array | `addCodecSizePrefix(getBytesCodec(), getU32Codec())` | +| pubkey | Address | `getAddressCodec()` | +| vec\ | T[] | `getArrayCodec(inner, { size: getU32Codec() })` | +| array\ | T[] | `getArrayCodec(inner, { size: N })` | +| option\ | T \| null | `getNullableCodec(inner)` | +| defined struct | type ref | generated codec const | +| enum (scalar) | TS enum | `getEnumCodec(Enum)` (u8 tag) | + +Unsupported types **fail loudly** at generation time (never silently +mis-encode): `u256`/`i256`, `COption`, data-carrying enums, tuple structs, +generics, cyclic type references. + +## Tests + +Golden source-compare tests live in `tsbindgen_test.go` against +`testdata/data_storage_ts/` and `testdata/feature_matrix_ts/`. Regenerate +goldens (Go + TS) with: + +```bash +go generate ./cmd/generate-bindings/solana +``` diff --git a/cmd/generate-bindings/solana/mockcontract.ts.tpl b/cmd/generate-bindings/solana/mockcontract.ts.tpl new file mode 100644 index 00000000..e3669a12 --- /dev/null +++ b/cmd/generate-bindings/solana/mockcontract.ts.tpl @@ -0,0 +1,19 @@ +// Code generated — DO NOT EDIT. +import { addSolanaContractMock, type SolanaContractMock, type SolanaMock } from '@chainlink/cre-sdk/test' + +import { {{.ProgramIDConst}} } from './{{.ClassName}}' + +export type {{.MockName}} = SolanaContractMock + +/** + * Registers a {{.ClassName}} program mock on a SolanaMock instance. + * The Solana CRE capability is write-only, so the mock routes writeReport + * calls targeting this program's ID; set the returned mock's writeReport + * property to define the reply. + */ +export function new{{.MockName}}( + solanaMock: SolanaMock, + programId: string | Uint8Array = {{.ProgramIDConst}}, +): {{.MockName}} { + return addSolanaContractMock(solanaMock, { programId }) +} diff --git a/cmd/generate-bindings/solana/solana.go b/cmd/generate-bindings/solana/solana.go index 32ee92b4..d310f307 100644 --- a/cmd/generate-bindings/solana/solana.go +++ b/cmd/generate-bindings/solana/solana.go @@ -5,6 +5,7 @@ import ( "os" "os/exec" "path/filepath" + "strings" "github.com/rs/zerolog" "github.com/spf13/cobra" @@ -18,9 +19,11 @@ import ( type Inputs struct { ProjectRoot string `validate:"required,dir" cli:"--project-root"` - Language string `validate:"required,oneof=go" cli:"--language"` + GoLang bool + TypeScript bool IdlPath string `validate:"required,path_read" cli:"--idl"` - OutPath string `validate:"required" cli:"--out"` + GoOutPath string // contracts/solana/src/generated — set when GoLang is true + TSOutPath string // contracts/solana/ts/generated — set when TypeScript is true } func New(runtimeContext *runtime.Context) *cobra.Command { @@ -28,10 +31,14 @@ func New(runtimeContext *runtime.Context) *cobra.Command { Use: "solana", Short: "Generate bindings from contract IDL", Long: `This command generates bindings from contract IDL files. -Supports Solana chain family and Go language. -Each contract gets its own package subdirectory to avoid naming conflicts. -For example, data_storage.json generates bindings in generated/data_storage/ package.`, - Example: " cre generate-bindings-solana", +Supports Solana chain family with Go and TypeScript languages. +The target language is auto-detected from project files, or can be +specified explicitly with --language. +For Go, each contract gets its own package subdirectory to avoid naming +conflicts: data_storage.json generates bindings in generated/data_storage/. +For TypeScript, each contract generates a flat .ts + _mock.ts +pair plus an index.ts barrel.`, + Example: " cre generate-bindings solana", RunE: func(cmd *cobra.Command, args []string) error { handler := newHandler(runtimeContext) inputs, err := handler.ResolveInputs(runtimeContext.Viper) @@ -46,9 +53,9 @@ For example, data_storage.json generates bindings in generated/data_storage/ pac } generateBindingsCmd.Flags().StringP("project-root", "p", "", "Path to project root directory (defaults to current directory)") - generateBindingsCmd.Flags().StringP("language", "l", "go", "Target language (go)") + generateBindingsCmd.Flags().StringP("language", "l", "", "Target language: go, typescript (auto-detected from project files when omitted)") generateBindingsCmd.Flags().StringP("idl", "i", "", "Path to IDL directory (defaults to contracts/solana/src/idl/)") - generateBindingsCmd.Flags().StringP("out", "o", "", "Path to output directory (defaults to contracts/solana/src/generated/)") + generateBindingsCmd.Flags().StringP("out", "o", "", "Path to output directory (defaults to contracts/solana/src/generated/ for Go, contracts/solana/ts/generated/ for TypeScript)") return generateBindingsCmd } @@ -63,6 +70,34 @@ func newHandler(ctx *runtime.Context) *handler { } } +// detectLanguages mirrors the EVM generate-bindings auto-detection: a project +// containing .go files targets Go, one containing .ts files targets TypeScript. +func detectLanguages(projectRoot string) (goLang, typescript bool) { + _ = filepath.WalkDir(projectRoot, func(path string, d os.DirEntry, err error) error { + if err != nil { + return nil + } + if d.IsDir() { + if d.Name() == "node_modules" || d.Name() == ".git" { + return filepath.SkipDir + } + return nil + } + base := filepath.Base(path) + if strings.HasSuffix(base, ".go") { + goLang = true + } + if strings.HasSuffix(base, ".ts") && !strings.HasSuffix(base, ".d.ts") { + typescript = true + } + if goLang && typescript { + return filepath.SkipAll + } + return nil + }) + return goLang, typescript +} + func (h *handler) ResolveInputs(v *viper.Viper) (Inputs, error) { // Get current working directory as default project root currentDir, err := os.Getwd() @@ -81,8 +116,22 @@ func (h *handler) ResolveInputs(v *viper.Viper) (Inputs, error) { return Inputs{}, fmt.Errorf("contracts folder not found in project root: %s", contractsPath) } - // Language defaults are handled by StringP - language := v.GetString("language") + // Resolve languages: --language flag takes precedence, else auto-detect + var goLang, typescript bool + langFlag := strings.ToLower(strings.TrimSpace(v.GetString("language"))) + switch langFlag { + case "": + goLang, typescript = detectLanguages(projectRoot) + if !goLang && !typescript { + return Inputs{}, fmt.Errorf("no target language detected (use --language go or --language typescript, or ensure project contains .go or .ts files)") + } + case constants.WorkflowLanguageGolang: + goLang = true + case constants.WorkflowLanguageTypeScript: + typescript = true + default: + return Inputs{}, fmt.Errorf("unsupported language %q (supported: go, typescript)", langFlag) + } // Resolve IDL path with fallback to contracts/solana/src/idl/ idlPath := v.GetString("idl") @@ -90,17 +139,35 @@ func (h *handler) ResolveInputs(v *viper.Viper) (Inputs, error) { idlPath = filepath.Join(projectRoot, "contracts", "solana", "src", "idl") } - // Resolve output path with fallback to contracts/solana/src/generated/ - outPath := v.GetString("out") - if outPath == "" { - outPath = filepath.Join(projectRoot, "contracts", "solana", "src", "generated") + // Separate output paths: Go uses src/, TS uses ts/ (typescript convention). + // --out overrides the default for the selected language; with both + // languages selected it would be ambiguous, so it is rejected. + outFlag := v.GetString("out") + if outFlag != "" && goLang && typescript { + return Inputs{}, fmt.Errorf("--out is ambiguous when generating for both go and typescript; use --language to select one") + } + + var goOutPath, tsOutPath string + if goLang { + goOutPath = outFlag + if goOutPath == "" { + goOutPath = filepath.Join(projectRoot, "contracts", "solana", "src", "generated") + } + } + if typescript { + tsOutPath = outFlag + if tsOutPath == "" { + tsOutPath = filepath.Join(projectRoot, "contracts", "solana", "ts", "generated") + } } return Inputs{ ProjectRoot: projectRoot, - Language: language, + GoLang: goLang, + TypeScript: typescript, IdlPath: idlPath, - OutPath: outPath, + GoOutPath: goOutPath, + TSOutPath: tsOutPath, }, nil } @@ -136,6 +203,48 @@ func (h *handler) ValidateInputs(inputs Inputs) error { return nil } +// contractNameFromIdlFile returns the contract name by stripping the .json +// extension from the base filename. +func contractNameFromIdlFile(path string) string { + return strings.TrimSuffix(filepath.Base(path), ".json") +} + +func (h *handler) generateForIdl(inputs Inputs, idlFile string) (className string, err error) { + contractName := contractNameFromIdlFile(idlFile) + + if inputs.TypeScript { + ui.Dim(fmt.Sprintf("Processing IDL file: %s, contract: %s, output: %s\n", idlFile, contractName, inputs.TSOutPath)) + className, err = GenerateBindingsTS( + idlFile, + contractName, + inputs.TSOutPath, + ) + if err != nil { + return "", fmt.Errorf("failed to generate TypeScript bindings for %s: %w", idlFile, err) + } + } + + if inputs.GoLang { + // Create per-contract output directory + contractOutDir := filepath.Join(inputs.GoOutPath, contractName) + if err := os.MkdirAll(contractOutDir, 0o755); err != nil { + return "", fmt.Errorf("failed to create contract output directory %s: %w", contractOutDir, err) + } + + ui.Dim(fmt.Sprintf("Processing IDL file: %s, contract: %s, output: %s\n", idlFile, contractName, contractOutDir)) + + if err := GenerateBindings( + idlFile, + contractName, + contractOutDir, + ); err != nil { + return "", fmt.Errorf("failed to generate bindings for %s: %w", idlFile, err) + } + } + + return className, nil +} + func (h *handler) processIdlDirectory(inputs Inputs) error { // Read all .json files in the directory files, err := filepath.Glob(filepath.Join(inputs.IdlPath, "*.json")) @@ -147,70 +256,69 @@ func (h *handler) processIdlDirectory(inputs Inputs) error { return fmt.Errorf("no .json files found in directory: %s", inputs.IdlPath) } - // Process each IDL file + var generatedTSClasses []string for _, idlFile := range files { - // Extract contract name from filename (remove .json extension) - contractName := filepath.Base(idlFile) - contractName = contractName[:len(contractName)-5] // Remove .json extension - - // Create per-contract output directory - contractOutDir := filepath.Join(inputs.OutPath, contractName) - if err := os.MkdirAll(contractOutDir, 0755); err != nil { - return fmt.Errorf("failed to create contract output directory %s: %w", contractOutDir, err) - } - - // Create output file path in contract-specific directory - outputFile := filepath.Join(contractOutDir, contractName+".go") - - ui.Dim(fmt.Sprintf("Processing IDL file: %s, contract: %s, output: %s\n", idlFile, contractName, outputFile)) - - err = GenerateBindings( - idlFile, - contractName, - contractOutDir, - ) + className, err := h.generateForIdl(inputs, idlFile) if err != nil { - return fmt.Errorf("failed to generate bindings for %s: %w", idlFile, err) + return err + } + if className != "" { + generatedTSClasses = append(generatedTSClasses, className) } } - return nil + return h.writeTSBarrel(inputs, generatedTSClasses) } func (h *handler) processSingleIdl(inputs Inputs) error { - // Extract contract name from IDL file path - contractName := filepath.Base(inputs.IdlPath) - if filepath.Ext(contractName) == ".json" { - contractName = contractName[:len(contractName)-5] // Remove .json extension + className, err := h.generateForIdl(inputs, inputs.IdlPath) + if err != nil { + return err } - - // Create per-contract output directory - contractOutDir := filepath.Join(inputs.OutPath, contractName) - if err := os.MkdirAll(contractOutDir, 0755); err != nil { - return fmt.Errorf("failed to create contract output directory %s: %w", contractOutDir, err) + var classNames []string + if className != "" { + classNames = []string{className} } + return h.writeTSBarrel(inputs, classNames) +} - ui.Dim(fmt.Sprintf("Processing single IDL file: %s, contract: %s, output: %s\n", inputs.IdlPath, contractName, contractOutDir)) - - return GenerateBindings( - inputs.IdlPath, - contractName, - contractOutDir, - ) +// writeTSBarrel generates the index.ts barrel re-exporting every generated +// binding and mock, matching the EVM TypeScript output convention. +func (h *handler) writeTSBarrel(inputs Inputs, classNames []string) error { + if !inputs.TypeScript || len(classNames) == 0 { + return nil + } + indexPath := filepath.Join(inputs.TSOutPath, "index.ts") + var sb strings.Builder + sb.WriteString("// Code generated — DO NOT EDIT.\n") + for _, name := range classNames { + fmt.Fprintf(&sb, "export * from './%s'\nexport * from './%s_mock'\n", name, name) + } + if err := os.WriteFile(indexPath, []byte(sb.String()), 0o600); err != nil { + return fmt.Errorf("failed to write index.ts: %w", err) + } + return nil } func (h *handler) Execute(inputs Inputs) error { - // Validate language - switch inputs.Language { - case "go": - // Language supported, continue - default: - return fmt.Errorf("unsupported language: %s", inputs.Language) + var langs []string + if inputs.GoLang { + langs = append(langs, "go") + } + if inputs.TypeScript { + langs = append(langs, "typescript") } + ui.Dim(fmt.Sprintf("Project: %s, Chain: solana, Languages: %v", inputs.ProjectRoot, langs)) - // Create output directory if it doesn't exist - if err := os.MkdirAll(inputs.OutPath, 0755); err != nil { - return fmt.Errorf("failed to create output directory: %w", err) + if inputs.GoLang { + if err := os.MkdirAll(inputs.GoOutPath, 0o755); err != nil { + return fmt.Errorf("failed to create Go output directory: %w", err) + } + } + if inputs.TypeScript { + if err := os.MkdirAll(inputs.TSOutPath, 0o755); err != nil { + return fmt.Errorf("failed to create TypeScript output directory: %w", err) + } } // Check if IDL path is a directory or file @@ -229,19 +337,22 @@ func (h *handler) Execute(inputs Inputs) error { } } - err = runCommand(inputs.ProjectRoot, "go", "get", "github.com/smartcontractkit/cre-sdk-go@"+constants.SdkVersion) - if err != nil { - return err - } - err = runCommand(inputs.ProjectRoot, "go", "get", "github.com/smartcontractkit/cre-sdk-go/capabilities/blockchain/solana@"+constants.SolanaCapabilitiesVersion) - if err != nil { - return err - } - err = runCommand(inputs.ProjectRoot, "go", "mod", "tidy") - if err != nil { - return err + if inputs.GoLang { + err = runCommand(inputs.ProjectRoot, "go", "get", "github.com/smartcontractkit/cre-sdk-go@"+constants.SdkVersion) + if err != nil { + return err + } + err = runCommand(inputs.ProjectRoot, "go", "get", "github.com/smartcontractkit/cre-sdk-go/capabilities/blockchain/solana@"+constants.SolanaCapabilitiesVersion) + if err != nil { + return err + } + err = runCommand(inputs.ProjectRoot, "go", "mod", "tidy") + if err != nil { + return err + } } + ui.Success("Bindings generated successfully") return nil } diff --git a/cmd/generate-bindings/solana/solana_test.go b/cmd/generate-bindings/solana/solana_test.go index e3eebfa9..ca8bf09d 100644 --- a/cmd/generate-bindings/solana/solana_test.go +++ b/cmd/generate-bindings/solana/solana_test.go @@ -53,12 +53,13 @@ func TestResolveSolanaInputs_DefaultFallbacks(t *testing.T) { expectedRoot, _ := filepath.EvalSymlinks(tempDir) actualRoot, _ := filepath.EvalSymlinks(inputs.ProjectRoot) assert.Equal(t, expectedRoot, actualRoot) - assert.Equal(t, "go", inputs.Language) + assert.True(t, inputs.GoLang) + assert.False(t, inputs.TypeScript) expectedIdl, _ := filepath.EvalSymlinks(filepath.Join(tempDir, "contracts", "solana", "src", "idl")) actualIdl, _ := filepath.EvalSymlinks(inputs.IdlPath) assert.Equal(t, expectedIdl, actualIdl) expectedOut, _ := filepath.EvalSymlinks(filepath.Join(tempDir, "contracts", "solana", "src", "generated")) - actualOut, _ := filepath.EvalSymlinks(inputs.OutPath) + actualOut, _ := filepath.EvalSymlinks(inputs.GoOutPath) assert.Equal(t, expectedOut, actualOut) } @@ -94,7 +95,7 @@ func TestResolveSolanaInputs_CustomOutPath(t *testing.T) { inputs, err := handler.ResolveInputs(v) require.NoError(t, err) - assert.Equal(t, customOut, inputs.OutPath) + assert.Equal(t, customOut, inputs.GoOutPath) } func TestProcessSolanaSingleIdl(t *testing.T) { @@ -144,9 +145,9 @@ func TestProcessSolanaSingleIdl(t *testing.T) { inputs := Inputs{ ProjectRoot: tempDir, - Language: "go", + GoLang: true, IdlPath: idlFile, - OutPath: outDir, + GoOutPath: outDir, } runtimeCtx := &runtime.Context{} @@ -233,9 +234,9 @@ func TestProcessSolanaIdlDirectory(t *testing.T) { inputs := Inputs{ ProjectRoot: tempDir, - Language: "go", + GoLang: true, IdlPath: idlDir, - OutPath: outDir, + GoOutPath: outDir, } runtimeCtx := &runtime.Context{} @@ -271,9 +272,9 @@ func TestProcessSolanaIdlDirectory_NoIdlFiles(t *testing.T) { inputs := Inputs{ ProjectRoot: tempDir, - Language: "go", + GoLang: true, IdlPath: idlDir, - OutPath: outDir, + GoOutPath: outDir, } runtimeCtx := &runtime.Context{} diff --git a/cmd/generate-bindings/solana/sourcecre.ts.tpl b/cmd/generate-bindings/solana/sourcecre.ts.tpl new file mode 100644 index 00000000..fcd4bfd8 --- /dev/null +++ b/cmd/generate-bindings/solana/sourcecre.ts.tpl @@ -0,0 +1,207 @@ +// Code generated — DO NOT EDIT. +{{- if .CodecImports}} +import { +{{- range .CodecImports}} + {{.}}, +{{- end}} +} from '@solana/codecs' +{{- end}} +{{- if .UsesAddress}} +import { getAddressCodec, type Address } from '@solana/addresses' +{{- end}} +import { + bytesToHex, + calculateAccountsHash, + encodeBorshVecU32, + encodeForwarderReport, + prepareSolanaReportRequest, + type Runtime, + type SolanaAccountMeta, + SolanaClient, + solanaAccountMetasToJson, + solanaAddressToBytes, + type SolanaComputeConfig, +} from '@chainlink/cre-sdk' + +export const {{.ProgramIDConst}} = '{{.ProgramID}}' + +export const {{.IdlConst}} = {{.IdlJSON}} as const +{{- if or .Accounts .Events}} + +const DISCRIMINATOR_SIZE = 8 + +const expectDiscriminator = (label: string, expected: Uint8Array, data: Uint8Array): Uint8Array => { + if (data.length < DISCRIMINATOR_SIZE) { + throw new Error(`${label}: data too short for discriminator (${data.length} bytes)`) + } + for (let i = 0; i < DISCRIMINATOR_SIZE; i++) { + if (data[i] !== expected[i]) { + throw new Error(`${label}: discriminator mismatch`) + } + } + return data.subarray(DISCRIMINATOR_SIZE) +} +{{- end}} +{{range .Types}} +{{- if .IsEnum}} +export enum {{.Name}} { +{{- range $i, $v := .Variants}} + {{$v.Name}} = {{$i}}, +{{- end}} +} + +export const {{.CodecConst}} = getEnumCodec({{.Name}}) +{{- else}} +{{- if .Fields}} +export type {{.Name}} = { +{{- range .Fields}} + {{.Name}}: {{.TSType}} +{{- end}} +} + +export const {{.CodecConst}} = getStructCodec([ +{{- range .Fields}} + ['{{.Name}}', {{.CodecExpr}}], +{{- end}} +]) +{{- else}} +export type {{.Name}} = Record + +export const {{.CodecConst}} = getStructCodec([]) +{{- end}} +{{- end}} +{{end}} +{{- range .Accounts}} +export const {{.ConstName}} = new Uint8Array([{{.Discriminator}}]) + +/** + * Decodes raw {{.Name}} account data (with its 8-byte discriminator) into {{.TypeName}}. + * Pure helper — there is no read capability; obtain the account bytes elsewhere. + */ +export const decode{{.Name}}Account = (data: Uint8Array): {{.TypeName}} => + {{.CodecConst}}.decode(expectDiscriminator('account {{.Name}}', {{.ConstName}}, data)) as {{.TypeName}} +{{end}} +{{- range .Events}} +export const {{.ConstName}} = new Uint8Array([{{.Discriminator}}]) + +/** + * Decodes raw {{.Name}} event data (with its 8-byte discriminator) into {{.TypeName}}. + */ +export const decode{{.Name}}Event = (data: Uint8Array): {{.TypeName}} => + {{.CodecConst}}.decode(expectDiscriminator('event {{.Name}}', {{.ConstName}}, data)) as {{.TypeName}} +{{end}} +{{- if .Accounts}} +export const parseAnyAccount = (data: Uint8Array): {{range $i, $a := .Accounts}}{{if $i}} | {{end}}{{$a.TypeName}}{{end}} => { + const disc = data.subarray(0, DISCRIMINATOR_SIZE) + const matches = (expected: Uint8Array) => expected.every((b, i) => disc[i] === b) +{{- range .Accounts}} + if (matches({{.ConstName}})) return decode{{.Name}}Account(data) +{{- end}} + throw new Error(`unknown account discriminator: [${Array.from(disc).join(', ')}]`) +} +{{- end}} +{{- if .Events}} + +export const parseAnyEvent = (data: Uint8Array): {{range $i, $e := .Events}}{{if $i}} | {{end}}{{$e.TypeName}}{{end}} => { + const disc = data.subarray(0, DISCRIMINATOR_SIZE) + const matches = (expected: Uint8Array) => expected.every((b, i) => disc[i] === b) +{{- range .Events}} + if (matches({{.ConstName}})) return decode{{.Name}}Event(data) +{{- end}} + throw new Error(`unknown event discriminator: [${Array.from(disc).join(', ')}]`) +} +{{- end}} +{{- if or .Accounts .Events}} +{{end}} +export class {{.ClassName}} { + readonly programId: Uint8Array + + // The program ID is baked into the IDL, so it defaults to the generated + // const — unlike EVM bindings where the address is a runtime value. + constructor( + private readonly client: SolanaClient, + programId: string | Uint8Array = {{.ProgramIDConst}}, + ) { + this.programId = typeof programId === 'string' ? solanaAddressToBytes(programId) : programId + } + + /** + * Publishes a pre-encoded Borsh payload through the CRE signer to this + * program's on_report entrypoint via the keystone-forwarder. + * + * remainingAccounts must follow the keystone-forwarder account layout: + * - Index 0: forwarderState – the forwarder program's state account. + * - Index 1: forwarderAuthority – PDA derived from seeds + * ["forwarder", forwarderState, receiverProgram] under the forwarder program ID. + * - Index 2+: receiver-specific accounts required by the target program. + * + * The full account list is hashed (via calculateAccountsHash) into the report. + * The on-chain forwarder strips indices 0 and 1 before CPI-ing into the + * receiver, so they must be present and correctly ordered. + */ + writeReport( + runtime: Runtime, + payload: Uint8Array, + remainingAccounts: SolanaAccountMeta[], + computeConfig?: SolanaComputeConfig, + ) { + const report = runtime + .report( + prepareSolanaReportRequest( + encodeForwarderReport({ + accountHash: calculateAccountsHash(remainingAccounts), + payload, + }), + ), + ) + .result() + + return this.client + .writeReport(runtime, { + remainingAccounts: solanaAccountMetasToJson(remainingAccounts), + receiver: bytesToHex(this.programId), + computeConfig, + report, + }) + .result() + } + + /** + * Publishes a Borsh Vec of pre-encoded element payloads (mirrors Go's + * WriteReportFromBorshEncodedVec). Each element must already be fully + * serialized for one Vec item on the wire. + */ + writeReportFromBorshEncodedVec( + runtime: Runtime, + elementPayloads: Uint8Array[], + remainingAccounts: SolanaAccountMeta[], + computeConfig?: SolanaComputeConfig, + ) { + return this.writeReport(runtime, encodeBorshVecU32(elementPayloads), remainingAccounts, computeConfig) + } +{{- range .StructTypes}} + + writeReportFrom{{.Name}}( + runtime: Runtime, + input: {{.Name}}, + remainingAccounts: SolanaAccountMeta[], + computeConfig?: SolanaComputeConfig, + ) { + return this.writeReport(runtime, new Uint8Array({{.CodecConst}}.encode(input)), remainingAccounts, computeConfig) + } + + writeReportFrom{{.Name}}s( + runtime: Runtime, + inputs: {{.Name}}[], + remainingAccounts: SolanaAccountMeta[], + computeConfig?: SolanaComputeConfig, + ) { + return this.writeReportFromBorshEncodedVec( + runtime, + inputs.map((input) => new Uint8Array({{.CodecConst}}.encode(input))), + remainingAccounts, + computeConfig, + ) + } +{{- end}} +} diff --git a/cmd/generate-bindings/solana/testdata/contracts/idl/feature_matrix.json b/cmd/generate-bindings/solana/testdata/contracts/idl/feature_matrix.json new file mode 100644 index 00000000..5b9abdb7 --- /dev/null +++ b/cmd/generate-bindings/solana/testdata/contracts/idl/feature_matrix.json @@ -0,0 +1,78 @@ +{ + "address": "ECL8142j2YQAvs9R9geSsRnkVH2wLEi7soJCRyJ74cfL", + "metadata": { + "name": "feature_matrix", + "version": "0.1.0", + "spec": "0.1.0", + "description": "Fixture exercising the v1 TypeScript type-coverage matrix" + }, + "instructions": [ + { + "name": "on_report", + "discriminator": [214, 173, 18, 221, 173, 148, 151, 208], + "accounts": [], + "args": [ + {"name": "_metadata", "type": "bytes"}, + {"name": "payload", "type": "bytes"} + ] + } + ], + "accounts": [], + "events": [], + "errors": [], + "types": [ + { + "name": "Color", + "type": { + "kind": "enum", + "variants": [ + {"name": "Red"}, + {"name": "Green"}, + {"name": "Blue"} + ] + } + }, + { + "name": "Inner", + "type": { + "kind": "struct", + "fields": [ + {"name": "id", "type": "u32"}, + {"name": "label", "type": "string"} + ] + } + }, + { + "name": "Everything", + "type": { + "kind": "struct", + "fields": [ + {"name": "flag", "type": "bool"}, + {"name": "tiny", "type": "u8"}, + {"name": "tiny_signed", "type": "i8"}, + {"name": "small", "type": "u16"}, + {"name": "small_signed", "type": "i16"}, + {"name": "medium", "type": "u32"}, + {"name": "medium_signed", "type": "i32"}, + {"name": "big", "type": "u64"}, + {"name": "big_signed", "type": "i64"}, + {"name": "huge", "type": "u128"}, + {"name": "huge_signed", "type": "i128"}, + {"name": "ratio", "type": "f32"}, + {"name": "precise_ratio", "type": "f64"}, + {"name": "title", "type": "string"}, + {"name": "blob", "type": "bytes"}, + {"name": "owner", "type": "pubkey"}, + {"name": "scores", "type": {"vec": "u64"}}, + {"name": "fixed_window", "type": {"array": ["u8", 32]}}, + {"name": "maybe_note", "type": {"option": "string"}}, + {"name": "nested", "type": {"defined": {"name": "Inner"}}}, + {"name": "maybe_nested", "type": {"option": {"defined": {"name": "Inner"}}}}, + {"name": "favorite_color", "type": {"defined": {"name": "Color"}}}, + {"name": "color_history", "type": {"vec": {"defined": {"name": "Color"}}}}, + {"name": "default", "type": "bool"} + ] + } + } + ] +} diff --git a/cmd/generate-bindings/solana/testdata/data_storage_ts/DataStorage.ts b/cmd/generate-bindings/solana/testdata/data_storage_ts/DataStorage.ts new file mode 100644 index 00000000..f39ee95d --- /dev/null +++ b/cmd/generate-bindings/solana/testdata/data_storage_ts/DataStorage.ts @@ -0,0 +1,359 @@ +// Code generated — DO NOT EDIT. +import { + addCodecSizePrefix, + getArrayCodec, + getBytesCodec, + getStructCodec, + getU32Codec, + getU64Codec, + getUtf8Codec, +} from '@solana/codecs' +import { getAddressCodec, type Address } from '@solana/addresses' +import { + bytesToHex, + calculateAccountsHash, + encodeBorshVecU32, + encodeForwarderReport, + prepareSolanaReportRequest, + type Runtime, + type SolanaAccountMeta, + SolanaClient, + solanaAccountMetasToJson, + solanaAddressToBytes, + type SolanaComputeConfig, +} 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 + +const DISCRIMINATOR_SIZE = 8 + +const expectDiscriminator = (label: string, expected: Uint8Array, data: Uint8Array): Uint8Array => { + if (data.length < DISCRIMINATOR_SIZE) { + throw new Error(`${label}: data too short for discriminator (${data.length} bytes)`) + } + for (let i = 0; i < DISCRIMINATOR_SIZE; i++) { + if (data[i] !== expected[i]) { + throw new Error(`${label}: discriminator mismatch`) + } + } + return data.subarray(DISCRIMINATOR_SIZE) +} + +export type AccessLogged = { + caller: Address + message: string +} + +export const accessLoggedCodec = getStructCodec([ + ['caller', getAddressCodec()], + ['message', addCodecSizePrefix(getUtf8Codec(), getU32Codec())], +]) + +export type DataAccount = { + sender: string + key: string + value: string +} + +export const dataAccountCodec = getStructCodec([ + ['sender', addCodecSizePrefix(getUtf8Codec(), getU32Codec())], + ['key', addCodecSizePrefix(getUtf8Codec(), getU32Codec())], + ['value', addCodecSizePrefix(getUtf8Codec(), getU32Codec())], +]) + +export type UserData = { + key: string + value: string +} + +export const userDataCodec = getStructCodec([ + ['key', addCodecSizePrefix(getUtf8Codec(), getU32Codec())], + ['value', addCodecSizePrefix(getUtf8Codec(), getU32Codec())], +]) + +export type DynamicEvent = { + key: string + userData: UserData + sender: string + metadata: Uint8Array + metadataArray: Uint8Array[] +} + +export const dynamicEventCodec = getStructCodec([ + ['key', addCodecSizePrefix(getUtf8Codec(), getU32Codec())], + ['userData', userDataCodec], + ['sender', addCodecSizePrefix(getUtf8Codec(), getU32Codec())], + ['metadata', addCodecSizePrefix(getBytesCodec(), getU32Codec())], + ['metadataArray', getArrayCodec(addCodecSizePrefix(getBytesCodec(), getU32Codec()), { size: getU32Codec() })], +]) + +export type NoFields = Record + +export const noFieldsCodec = getStructCodec([]) + +export type UpdateReserves = { + totalMinted: bigint + totalReserve: bigint +} + +export const updateReservesCodec = getStructCodec([ + ['totalMinted', getU64Codec()], + ['totalReserve', getU64Codec()], +]) + +export const ACCOUNT_DATA_ACCOUNT_DISCRIMINATOR = new Uint8Array([85, 240, 182, 158, 76, 7, 18, 233]) + +/** + * Decodes raw DataAccount account data (with its 8-byte discriminator) into DataAccount. + * Pure helper — there is no read capability; obtain the account bytes elsewhere. + */ +export const decodeDataAccountAccount = (data: Uint8Array): DataAccount => + dataAccountCodec.decode(expectDiscriminator('account DataAccount', ACCOUNT_DATA_ACCOUNT_DISCRIMINATOR, data)) as DataAccount + +export const EVENT_ACCESS_LOGGED_DISCRIMINATOR = new Uint8Array([243, 53, 225, 71, 64, 120, 109, 25]) + +/** + * Decodes raw AccessLogged event data (with its 8-byte discriminator) into AccessLogged. + */ +export const decodeAccessLoggedEvent = (data: Uint8Array): AccessLogged => + accessLoggedCodec.decode(expectDiscriminator('event AccessLogged', EVENT_ACCESS_LOGGED_DISCRIMINATOR, data)) as AccessLogged + +export const EVENT_DYNAMIC_EVENT_DISCRIMINATOR = new Uint8Array([236, 145, 224, 161, 9, 222, 218, 237]) + +/** + * Decodes raw DynamicEvent event data (with its 8-byte discriminator) into DynamicEvent. + */ +export const decodeDynamicEventEvent = (data: Uint8Array): DynamicEvent => + dynamicEventCodec.decode(expectDiscriminator('event DynamicEvent', EVENT_DYNAMIC_EVENT_DISCRIMINATOR, data)) as DynamicEvent + +export const EVENT_NO_FIELDS_DISCRIMINATOR = new Uint8Array([160, 156, 94, 85, 77, 122, 98, 240]) + +/** + * Decodes raw NoFields event data (with its 8-byte discriminator) into NoFields. + */ +export const decodeNoFieldsEvent = (data: Uint8Array): NoFields => + noFieldsCodec.decode(expectDiscriminator('event NoFields', EVENT_NO_FIELDS_DISCRIMINATOR, data)) as NoFields + +export const parseAnyAccount = (data: Uint8Array): DataAccount => { + const disc = data.subarray(0, DISCRIMINATOR_SIZE) + const matches = (expected: Uint8Array) => expected.every((b, i) => disc[i] === b) + if (matches(ACCOUNT_DATA_ACCOUNT_DISCRIMINATOR)) return decodeDataAccountAccount(data) + throw new Error(`unknown account discriminator: [${Array.from(disc).join(', ')}]`) +} + +export const parseAnyEvent = (data: Uint8Array): AccessLogged | DynamicEvent | NoFields => { + const disc = data.subarray(0, DISCRIMINATOR_SIZE) + const matches = (expected: Uint8Array) => expected.every((b, i) => disc[i] === b) + if (matches(EVENT_ACCESS_LOGGED_DISCRIMINATOR)) return decodeAccessLoggedEvent(data) + if (matches(EVENT_DYNAMIC_EVENT_DISCRIMINATOR)) return decodeDynamicEventEvent(data) + if (matches(EVENT_NO_FIELDS_DISCRIMINATOR)) return decodeNoFieldsEvent(data) + throw new Error(`unknown event discriminator: [${Array.from(disc).join(', ')}]`) +} + +export class DataStorage { + readonly programId: Uint8Array + + // The program ID is baked into the IDL, so it defaults to the generated + // const — unlike EVM bindings where the address is a runtime value. + constructor( + private readonly client: SolanaClient, + programId: string | Uint8Array = DATA_STORAGE_PROGRAM_ID, + ) { + this.programId = typeof programId === 'string' ? solanaAddressToBytes(programId) : programId + } + + /** + * Publishes a pre-encoded Borsh payload through the CRE signer to this + * program's on_report entrypoint via the keystone-forwarder. + * + * remainingAccounts must follow the keystone-forwarder account layout: + * - Index 0: forwarderState – the forwarder program's state account. + * - Index 1: forwarderAuthority – PDA derived from seeds + * ["forwarder", forwarderState, receiverProgram] under the forwarder program ID. + * - Index 2+: receiver-specific accounts required by the target program. + * + * The full account list is hashed (via calculateAccountsHash) into the report. + * The on-chain forwarder strips indices 0 and 1 before CPI-ing into the + * receiver, so they must be present and correctly ordered. + */ + writeReport( + runtime: Runtime, + payload: Uint8Array, + remainingAccounts: SolanaAccountMeta[], + computeConfig?: SolanaComputeConfig, + ) { + const report = runtime + .report( + prepareSolanaReportRequest( + encodeForwarderReport({ + accountHash: calculateAccountsHash(remainingAccounts), + payload, + }), + ), + ) + .result() + + return this.client + .writeReport(runtime, { + remainingAccounts: solanaAccountMetasToJson(remainingAccounts), + receiver: bytesToHex(this.programId), + computeConfig, + report, + }) + .result() + } + + /** + * Publishes a Borsh Vec of pre-encoded element payloads (mirrors Go's + * WriteReportFromBorshEncodedVec). Each element must already be fully + * serialized for one Vec item on the wire. + */ + writeReportFromBorshEncodedVec( + runtime: Runtime, + elementPayloads: Uint8Array[], + remainingAccounts: SolanaAccountMeta[], + computeConfig?: SolanaComputeConfig, + ) { + return this.writeReport(runtime, encodeBorshVecU32(elementPayloads), remainingAccounts, computeConfig) + } + + writeReportFromAccessLogged( + runtime: Runtime, + input: AccessLogged, + remainingAccounts: SolanaAccountMeta[], + computeConfig?: SolanaComputeConfig, + ) { + return this.writeReport(runtime, new Uint8Array(accessLoggedCodec.encode(input)), remainingAccounts, computeConfig) + } + + writeReportFromAccessLoggeds( + runtime: Runtime, + inputs: AccessLogged[], + remainingAccounts: SolanaAccountMeta[], + computeConfig?: SolanaComputeConfig, + ) { + return this.writeReportFromBorshEncodedVec( + runtime, + inputs.map((input) => new Uint8Array(accessLoggedCodec.encode(input))), + remainingAccounts, + computeConfig, + ) + } + + writeReportFromDataAccount( + runtime: Runtime, + input: DataAccount, + remainingAccounts: SolanaAccountMeta[], + computeConfig?: SolanaComputeConfig, + ) { + return this.writeReport(runtime, new Uint8Array(dataAccountCodec.encode(input)), remainingAccounts, computeConfig) + } + + writeReportFromDataAccounts( + runtime: Runtime, + inputs: DataAccount[], + remainingAccounts: SolanaAccountMeta[], + computeConfig?: SolanaComputeConfig, + ) { + return this.writeReportFromBorshEncodedVec( + runtime, + inputs.map((input) => new Uint8Array(dataAccountCodec.encode(input))), + remainingAccounts, + computeConfig, + ) + } + + writeReportFromUserData( + runtime: Runtime, + input: UserData, + remainingAccounts: SolanaAccountMeta[], + computeConfig?: SolanaComputeConfig, + ) { + return this.writeReport(runtime, new Uint8Array(userDataCodec.encode(input)), remainingAccounts, computeConfig) + } + + writeReportFromUserDatas( + runtime: Runtime, + inputs: UserData[], + remainingAccounts: SolanaAccountMeta[], + computeConfig?: SolanaComputeConfig, + ) { + return this.writeReportFromBorshEncodedVec( + runtime, + inputs.map((input) => new Uint8Array(userDataCodec.encode(input))), + remainingAccounts, + computeConfig, + ) + } + + writeReportFromDynamicEvent( + runtime: Runtime, + input: DynamicEvent, + remainingAccounts: SolanaAccountMeta[], + computeConfig?: SolanaComputeConfig, + ) { + return this.writeReport(runtime, new Uint8Array(dynamicEventCodec.encode(input)), remainingAccounts, computeConfig) + } + + writeReportFromDynamicEvents( + runtime: Runtime, + inputs: DynamicEvent[], + remainingAccounts: SolanaAccountMeta[], + computeConfig?: SolanaComputeConfig, + ) { + return this.writeReportFromBorshEncodedVec( + runtime, + inputs.map((input) => new Uint8Array(dynamicEventCodec.encode(input))), + remainingAccounts, + computeConfig, + ) + } + + writeReportFromNoFields( + runtime: Runtime, + input: NoFields, + remainingAccounts: SolanaAccountMeta[], + computeConfig?: SolanaComputeConfig, + ) { + return this.writeReport(runtime, new Uint8Array(noFieldsCodec.encode(input)), remainingAccounts, computeConfig) + } + + writeReportFromNoFieldss( + runtime: Runtime, + inputs: NoFields[], + remainingAccounts: SolanaAccountMeta[], + computeConfig?: SolanaComputeConfig, + ) { + return this.writeReportFromBorshEncodedVec( + runtime, + inputs.map((input) => new Uint8Array(noFieldsCodec.encode(input))), + remainingAccounts, + computeConfig, + ) + } + + writeReportFromUpdateReserves( + runtime: Runtime, + input: UpdateReserves, + remainingAccounts: SolanaAccountMeta[], + computeConfig?: SolanaComputeConfig, + ) { + return this.writeReport(runtime, new Uint8Array(updateReservesCodec.encode(input)), remainingAccounts, computeConfig) + } + + writeReportFromUpdateReservess( + runtime: Runtime, + inputs: UpdateReserves[], + remainingAccounts: SolanaAccountMeta[], + computeConfig?: SolanaComputeConfig, + ) { + return this.writeReportFromBorshEncodedVec( + runtime, + inputs.map((input) => new Uint8Array(updateReservesCodec.encode(input))), + remainingAccounts, + computeConfig, + ) + } +} diff --git a/cmd/generate-bindings/solana/testdata/data_storage_ts/DataStorage_mock.ts b/cmd/generate-bindings/solana/testdata/data_storage_ts/DataStorage_mock.ts new file mode 100644 index 00000000..b4ed26b9 --- /dev/null +++ b/cmd/generate-bindings/solana/testdata/data_storage_ts/DataStorage_mock.ts @@ -0,0 +1,19 @@ +// Code generated — DO NOT EDIT. +import { addSolanaContractMock, type SolanaContractMock, type SolanaMock } from '@chainlink/cre-sdk/test' + +import { DATA_STORAGE_PROGRAM_ID } from './DataStorage' + +export type DataStorageMock = SolanaContractMock + +/** + * Registers a DataStorage program mock on a SolanaMock instance. + * The Solana CRE capability is write-only, so the mock routes writeReport + * calls targeting this program's ID; set the returned mock's writeReport + * property to define the reply. + */ +export function newDataStorageMock( + solanaMock: SolanaMock, + programId: string | Uint8Array = DATA_STORAGE_PROGRAM_ID, +): DataStorageMock { + return addSolanaContractMock(solanaMock, { programId }) +} diff --git a/cmd/generate-bindings/solana/testdata/feature_matrix_ts/FeatureMatrix.ts b/cmd/generate-bindings/solana/testdata/feature_matrix_ts/FeatureMatrix.ts new file mode 100644 index 00000000..0f005e97 --- /dev/null +++ b/cmd/generate-bindings/solana/testdata/feature_matrix_ts/FeatureMatrix.ts @@ -0,0 +1,227 @@ +// Code generated — DO NOT EDIT. +import { + addCodecSizePrefix, + getArrayCodec, + getBooleanCodec, + getBytesCodec, + getEnumCodec, + getF32Codec, + getF64Codec, + getI128Codec, + getI16Codec, + getI32Codec, + getI64Codec, + getI8Codec, + getNullableCodec, + getStructCodec, + getU128Codec, + getU16Codec, + getU32Codec, + getU64Codec, + getU8Codec, + getUtf8Codec, +} from '@solana/codecs' +import { getAddressCodec, type Address } from '@solana/addresses' +import { + bytesToHex, + calculateAccountsHash, + encodeBorshVecU32, + encodeForwarderReport, + prepareSolanaReportRequest, + type Runtime, + type SolanaAccountMeta, + SolanaClient, + solanaAccountMetasToJson, + solanaAddressToBytes, + type SolanaComputeConfig, +} from '@chainlink/cre-sdk' + +export const FEATURE_MATRIX_PROGRAM_ID = 'ECL8142j2YQAvs9R9geSsRnkVH2wLEi7soJCRyJ74cfL' + +export const FEATURE_MATRIX_IDL = {"address":"ECL8142j2YQAvs9R9geSsRnkVH2wLEi7soJCRyJ74cfL","metadata":{"name":"feature_matrix","version":"0.1.0","spec":"0.1.0","description":"Fixture exercising the v1 TypeScript type-coverage matrix"},"instructions":[{"name":"on_report","discriminator":[214,173,18,221,173,148,151,208],"accounts":[],"args":[{"name":"_metadata","type":"bytes"},{"name":"payload","type":"bytes"}]}],"accounts":[],"events":[],"errors":[],"types":[{"name":"Color","type":{"kind":"enum","variants":[{"name":"Red"},{"name":"Green"},{"name":"Blue"}]}},{"name":"Inner","type":{"kind":"struct","fields":[{"name":"id","type":"u32"},{"name":"label","type":"string"}]}},{"name":"Everything","type":{"kind":"struct","fields":[{"name":"flag","type":"bool"},{"name":"tiny","type":"u8"},{"name":"tiny_signed","type":"i8"},{"name":"small","type":"u16"},{"name":"small_signed","type":"i16"},{"name":"medium","type":"u32"},{"name":"medium_signed","type":"i32"},{"name":"big","type":"u64"},{"name":"big_signed","type":"i64"},{"name":"huge","type":"u128"},{"name":"huge_signed","type":"i128"},{"name":"ratio","type":"f32"},{"name":"precise_ratio","type":"f64"},{"name":"title","type":"string"},{"name":"blob","type":"bytes"},{"name":"owner","type":"pubkey"},{"name":"scores","type":{"vec":"u64"}},{"name":"fixed_window","type":{"array":["u8",32]}},{"name":"maybe_note","type":{"option":"string"}},{"name":"nested","type":{"defined":{"name":"Inner"}}},{"name":"maybe_nested","type":{"option":{"defined":{"name":"Inner"}}}},{"name":"favorite_color","type":{"defined":{"name":"Color"}}},{"name":"color_history","type":{"vec":{"defined":{"name":"Color"}}}},{"name":"default","type":"bool"}]}}]} as const + +export enum Color { + Red = 0, + Green = 1, + Blue = 2, +} + +export const colorCodec = getEnumCodec(Color) + +export type Inner = { + id: number + label: string +} + +export const innerCodec = getStructCodec([ + ['id', getU32Codec()], + ['label', addCodecSizePrefix(getUtf8Codec(), getU32Codec())], +]) + +export type Everything = { + flag: boolean + tiny: number + tinySigned: number + small: number + smallSigned: number + medium: number + mediumSigned: number + big: bigint + bigSigned: bigint + huge: bigint + hugeSigned: bigint + ratio: number + preciseRatio: number + title: string + blob: Uint8Array + owner: Address + scores: bigint[] + fixedWindow: number[] + maybeNote: string | null + nested: Inner + maybeNested: Inner | null + favoriteColor: Color + colorHistory: Color[] + default_: boolean +} + +export const everythingCodec = getStructCodec([ + ['flag', getBooleanCodec()], + ['tiny', getU8Codec()], + ['tinySigned', getI8Codec()], + ['small', getU16Codec()], + ['smallSigned', getI16Codec()], + ['medium', getU32Codec()], + ['mediumSigned', getI32Codec()], + ['big', getU64Codec()], + ['bigSigned', getI64Codec()], + ['huge', getU128Codec()], + ['hugeSigned', getI128Codec()], + ['ratio', getF32Codec()], + ['preciseRatio', getF64Codec()], + ['title', addCodecSizePrefix(getUtf8Codec(), getU32Codec())], + ['blob', addCodecSizePrefix(getBytesCodec(), getU32Codec())], + ['owner', getAddressCodec()], + ['scores', getArrayCodec(getU64Codec(), { size: getU32Codec() })], + ['fixedWindow', getArrayCodec(getU8Codec(), { size: 32 })], + ['maybeNote', getNullableCodec(addCodecSizePrefix(getUtf8Codec(), getU32Codec()))], + ['nested', innerCodec], + ['maybeNested', getNullableCodec(innerCodec)], + ['favoriteColor', colorCodec], + ['colorHistory', getArrayCodec(colorCodec, { size: getU32Codec() })], + ['default_', getBooleanCodec()], +]) + +export class FeatureMatrix { + readonly programId: Uint8Array + + // The program ID is baked into the IDL, so it defaults to the generated + // const — unlike EVM bindings where the address is a runtime value. + constructor( + private readonly client: SolanaClient, + programId: string | Uint8Array = FEATURE_MATRIX_PROGRAM_ID, + ) { + this.programId = typeof programId === 'string' ? solanaAddressToBytes(programId) : programId + } + + /** + * Publishes a pre-encoded Borsh payload through the CRE signer to this + * program's on_report entrypoint via the keystone-forwarder. + * + * remainingAccounts must follow the keystone-forwarder account layout: + * - Index 0: forwarderState – the forwarder program's state account. + * - Index 1: forwarderAuthority – PDA derived from seeds + * ["forwarder", forwarderState, receiverProgram] under the forwarder program ID. + * - Index 2+: receiver-specific accounts required by the target program. + * + * The full account list is hashed (via calculateAccountsHash) into the report. + * The on-chain forwarder strips indices 0 and 1 before CPI-ing into the + * receiver, so they must be present and correctly ordered. + */ + writeReport( + runtime: Runtime, + payload: Uint8Array, + remainingAccounts: SolanaAccountMeta[], + computeConfig?: SolanaComputeConfig, + ) { + const report = runtime + .report( + prepareSolanaReportRequest( + encodeForwarderReport({ + accountHash: calculateAccountsHash(remainingAccounts), + payload, + }), + ), + ) + .result() + + return this.client + .writeReport(runtime, { + remainingAccounts: solanaAccountMetasToJson(remainingAccounts), + receiver: bytesToHex(this.programId), + computeConfig, + report, + }) + .result() + } + + /** + * Publishes a Borsh Vec of pre-encoded element payloads (mirrors Go's + * WriteReportFromBorshEncodedVec). Each element must already be fully + * serialized for one Vec item on the wire. + */ + writeReportFromBorshEncodedVec( + runtime: Runtime, + elementPayloads: Uint8Array[], + remainingAccounts: SolanaAccountMeta[], + computeConfig?: SolanaComputeConfig, + ) { + return this.writeReport(runtime, encodeBorshVecU32(elementPayloads), remainingAccounts, computeConfig) + } + + writeReportFromInner( + runtime: Runtime, + input: Inner, + remainingAccounts: SolanaAccountMeta[], + computeConfig?: SolanaComputeConfig, + ) { + return this.writeReport(runtime, new Uint8Array(innerCodec.encode(input)), remainingAccounts, computeConfig) + } + + writeReportFromInners( + runtime: Runtime, + inputs: Inner[], + remainingAccounts: SolanaAccountMeta[], + computeConfig?: SolanaComputeConfig, + ) { + return this.writeReportFromBorshEncodedVec( + runtime, + inputs.map((input) => new Uint8Array(innerCodec.encode(input))), + remainingAccounts, + computeConfig, + ) + } + + writeReportFromEverything( + runtime: Runtime, + input: Everything, + remainingAccounts: SolanaAccountMeta[], + computeConfig?: SolanaComputeConfig, + ) { + return this.writeReport(runtime, new Uint8Array(everythingCodec.encode(input)), remainingAccounts, computeConfig) + } + + writeReportFromEverythings( + runtime: Runtime, + inputs: Everything[], + remainingAccounts: SolanaAccountMeta[], + computeConfig?: SolanaComputeConfig, + ) { + return this.writeReportFromBorshEncodedVec( + runtime, + inputs.map((input) => new Uint8Array(everythingCodec.encode(input))), + remainingAccounts, + computeConfig, + ) + } +} diff --git a/cmd/generate-bindings/solana/testdata/feature_matrix_ts/FeatureMatrix_mock.ts b/cmd/generate-bindings/solana/testdata/feature_matrix_ts/FeatureMatrix_mock.ts new file mode 100644 index 00000000..20d01309 --- /dev/null +++ b/cmd/generate-bindings/solana/testdata/feature_matrix_ts/FeatureMatrix_mock.ts @@ -0,0 +1,19 @@ +// Code generated — DO NOT EDIT. +import { addSolanaContractMock, type SolanaContractMock, type SolanaMock } from '@chainlink/cre-sdk/test' + +import { FEATURE_MATRIX_PROGRAM_ID } from './FeatureMatrix' + +export type FeatureMatrixMock = SolanaContractMock + +/** + * Registers a FeatureMatrix program mock on a SolanaMock instance. + * The Solana CRE capability is write-only, so the mock routes writeReport + * calls targeting this program's ID; set the returned mock's writeReport + * property to define the reply. + */ +export function newFeatureMatrixMock( + solanaMock: SolanaMock, + programId: string | Uint8Array = FEATURE_MATRIX_PROGRAM_ID, +): FeatureMatrixMock { + return addSolanaContractMock(solanaMock, { programId }) +} diff --git a/cmd/generate-bindings/solana/testdata/gen/main.go b/cmd/generate-bindings/solana/testdata/gen/main.go index 30dc025e..626fe72d 100644 --- a/cmd/generate-bindings/solana/testdata/gen/main.go +++ b/cmd/generate-bindings/solana/testdata/gen/main.go @@ -14,4 +14,20 @@ func main() { ); err != nil { log.Fatal(err) } + + // TypeScript goldens (compared byte-for-byte by TestGenerateBindingsTS_Golden). + if _, err := solana.GenerateBindingsTS( + "./testdata/contracts/idl/data_storage.json", + "data_storage", + "./testdata/data_storage_ts", + ); err != nil { + log.Fatal(err) + } + if _, err := solana.GenerateBindingsTS( + "./testdata/contracts/idl/feature_matrix.json", + "feature_matrix", + "./testdata/feature_matrix_ts", + ); err != nil { + log.Fatal(err) + } } diff --git a/cmd/generate-bindings/solana/tsbindgen.go b/cmd/generate-bindings/solana/tsbindgen.go new file mode 100644 index 00000000..7415c69e --- /dev/null +++ b/cmd/generate-bindings/solana/tsbindgen.go @@ -0,0 +1,620 @@ +package solana + +import ( + "bytes" + _ "embed" + "encoding/json" + "fmt" + "os" + "path/filepath" + "sort" + "strings" + "text/template" + "unicode" + + "github.com/gagliardetto/anchor-go/idl" + "github.com/gagliardetto/anchor-go/idl/idltype" +) + +//go:embed sourcecre.ts.tpl +var tsSolanaTpl string + +//go:embed mockcontract.ts.tpl +var tsSolanaMockTpl string + +var ( + tsSolanaTemplate = template.Must(template.New("sourcecre.ts").Parse(tsSolanaTpl)) + tsSolanaMockTemplate = template.Must(template.New("mockcontract.ts").Parse(tsSolanaMockTpl)) +) + +// 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 +// to be PascalCase and never collide in practice). +var jsReservedWords = map[string]bool{ + "break": true, "case": true, "catch": true, "class": true, "const": true, + "continue": true, "debugger": true, "default": true, "delete": true, + "do": true, "else": true, "enum": true, "export": true, "extends": true, + "false": true, "finally": true, "for": true, "function": true, "if": true, + "import": true, "in": true, "instanceof": true, "new": true, "null": true, + "return": true, "super": true, "switch": true, "this": true, "throw": true, + "true": true, "try": true, "typeof": true, "var": true, "void": true, + "while": true, "with": true, "yield": true, "let": true, "static": true, + "implements": true, "interface": true, "package": true, "private": true, + "protected": true, "public": true, "await": true, "arguments": true, + "eval": true, +} + +type tsField struct { + Name string // camelCase TS property name + TSType string + CodecExpr string +} + +type tsEnumVariant struct { + Name string +} + +type tsTypeDef struct { + Name string // PascalCase TS type name + CodecConst string // e.g. userDataCodec + IsEnum bool + Fields []tsField + Variants []tsEnumVariant +} + +type tsDecoderDef struct { + Name string // PascalCase name from the IDL accounts/events section + TypeName string // TS type to decode into + CodecConst string + ConstName string // discriminator const, e.g. ACCOUNT_DATA_ACCOUNT_DISCRIMINATOR + Discriminator string // JS array body, e.g. "85, 240, 182, ..." +} + +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 +} + +// 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. +func GenerateBindingsTS( + pathToIdl string, + programName string, + outDir string, +) (className string, err error) { + if pathToIdl == "" { + return "", fmt.Errorf("pathToIdl is empty") + } + if programName == "" { + return "", fmt.Errorf("programName is empty") + } + if outDir == "" { + return "", fmt.Errorf("outDir is empty") + } + + parsedIdl, err := idl.ParseFromFilepath(pathToIdl) + if err != nil { + return "", fmt.Errorf("failed to parse IDL: %w", err) + } + if parsedIdl == nil { + return "", fmt.Errorf("parsedIdl is nil") + } + if err := parsedIdl.Validate(); err != nil { + return "", fmt.Errorf("invalid IDL: %w", err) + } + if parsedIdl.Address == nil || parsedIdl.Address.IsZero() { + return "", fmt.Errorf("address is empty in idl file: %s", pathToIdl) + } + + rawIdl, err := os.ReadFile(pathToIdl) //nolint:gosec // G703 -- path from trusted CLI flags + if err != nil { + return "", fmt.Errorf("read IDL %q: %w", pathToIdl, err) + } + var compactIdl bytes.Buffer + if err := json.Compact(&compactIdl, rawIdl); err != nil { + return "", fmt.Errorf("compact IDL JSON %q: %w", pathToIdl, err) + } + + data, err := buildTSBindingData(parsedIdl, programName, compactIdl.String()) + if err != nil { + return "", err + } + + if err := os.MkdirAll(outDir, 0o755); err != nil { + return "", fmt.Errorf("failed to create output directory: %w", err) + } + + if err := renderTSTemplate(tsSolanaTemplate, data, filepath.Join(outDir, data.ClassName+".ts")); err != nil { + return "", err + } + if err := renderTSTemplate(tsSolanaMockTemplate, data, filepath.Join(outDir, data.ClassName+"_mock.ts")); err != nil { + return "", err + } + + return data.ClassName, nil +} + +func renderTSTemplate(t *template.Template, data *tsBindingData, outPath string) error { + var buf bytes.Buffer + if err := t.Execute(&buf, data); err != nil { + return fmt.Errorf("render %q: %w", outPath, err) + } + if err := os.WriteFile(outPath, buf.Bytes(), 0o600); err != nil { //nolint:gosec // G703 -- derived from trusted CLI path + return fmt.Errorf("write %q: %w", outPath, err) + } + return nil +} + +func buildTSBindingData(parsedIdl *idl.Idl, programName, idlJSON string) (*tsBindingData, error) { + mapper := &tsTypeMapper{ + defs: map[string]*idl.IdlTypeDef{}, + imports: map[string]bool{}, + } + for i := range parsedIdl.Types { + def := &parsedIdl.Types[i] + if _, exists := mapper.defs[def.Name]; exists { + return nil, fmt.Errorf("duplicate type %q in IDL", def.Name) + } + mapper.defs[def.Name] = def + } + + ordered, err := topoSortTypes(parsedIdl.Types) + if err != nil { + return nil, err + } + + className := toPascalCase(programName) + if jsReservedWords[className] || !isValidJSIdentifier(className) { + return nil, fmt.Errorf("program name %q maps to invalid TypeScript class name %q", programName, className) + } + + data := &tsBindingData{ + ProgramName: programName, + ClassName: className, + MockName: className + "Mock", + ProgramIDConst: toUpperSnake(programName) + "_PROGRAM_ID", + IdlConst: toUpperSnake(programName) + "_IDL", + ProgramID: parsedIdl.Address.String(), + IdlJSON: idlJSON, + } + + typeNames := map[string]string{} // TS name -> IDL name (collision detection) + codecNames := map[string]bool{} + for _, def := range ordered { + tsName := toPascalCase(def.Name) + if prev, exists := typeNames[tsName]; exists { + return nil, fmt.Errorf("type name collision: IDL types %q and %q both map to TypeScript name %q", prev, def.Name, tsName) + } + typeNames[tsName] = def.Name + + tsDef, err := mapper.mapTypeDef(def, tsName) + if err != nil { + return nil, err + } + if codecNames[tsDef.CodecConst] { + return nil, fmt.Errorf("codec name collision: %q generated twice", tsDef.CodecConst) + } + codecNames[tsDef.CodecConst] = true + + data.Types = append(data.Types, *tsDef) + if !tsDef.IsEnum { + data.StructTypes = append(data.StructTypes, *tsDef) + } + } + + // Per-struct write method names must not collide (e.g. types Foo and Foos + // would both produce writeReportFromFoos). + writeMethods := map[string]string{} + for _, st := range data.StructTypes { + for _, method := range []string{"writeReportFrom" + st.Name, "writeReportFrom" + st.Name + "s"} { + if prev, exists := writeMethods[method]; exists { + return nil, fmt.Errorf("write method name collision: types %q and %q both produce %q", prev, st.Name, method) + } + writeMethods[method] = st.Name + } + } + + typesByName := make(map[string]tsTypeDef, len(data.Types)) + for _, td := range data.Types { + typesByName[td.Name] = td + } + + for _, account := range parsedIdl.Accounts { + decoder, err := buildDecoderDef("account", account.Name, account.Discriminator[:], typesByName) + if err != nil { + return nil, err + } + data.Accounts = append(data.Accounts, *decoder) + } + for _, event := range parsedIdl.Events { + decoder, err := buildDecoderDef("event", event.Name, event.Discriminator[:], typesByName) + if err != nil { + return nil, err + } + data.Events = append(data.Events, *decoder) + } + + data.CodecImports = mapper.sortedImports() + data.UsesAddress = mapper.usesAddress + + return data, nil +} + +func buildDecoderDef(kind, name string, discriminator []byte, typesByName map[string]tsTypeDef) (*tsDecoderDef, error) { + tsName := toPascalCase(name) + def, exists := typesByName[tsName] + if !exists { + return nil, fmt.Errorf("%s %q has no matching type definition in the IDL types section", kind, name) + } + if def.IsEnum { + return nil, fmt.Errorf("%s %q refers to enum type %q; only struct %ss are supported", kind, name, tsName, kind) + } + parts := make([]string, len(discriminator)) + for i, b := range discriminator { + parts[i] = fmt.Sprintf("%d", b) + } + return &tsDecoderDef{ + Name: tsName, + TypeName: tsName, + CodecConst: def.CodecConst, + ConstName: strings.ToUpper(kind) + "_" + toUpperSnake(name) + "_DISCRIMINATOR", + Discriminator: strings.Join(parts, ", "), + }, nil +} + +// topoSortTypes orders type definitions so that every `defined` reference +// points to an already-emitted codec const. Cyclic type references cannot be +// expressed with plain codec consts and fail loudly. +func topoSortTypes(types idl.IdTypeDef_slice) ([]*idl.IdlTypeDef, error) { + defsByName := make(map[string]*idl.IdlTypeDef, len(types)) + deps := map[string][]string{} + for i := range types { + def := &types[i] + defsByName[def.Name] = def + refs, err := definedRefsOfTypeDef(def) + if err != nil { + return nil, err + } + deps[def.Name] = refs + } + + var ordered []*idl.IdlTypeDef + state := map[string]int{} // 0 unvisited, 1 visiting, 2 done + var visit func(name string, path []string) error + visit = func(name string, path []string) error { + switch state[name] { + case 1: + return fmt.Errorf("cyclic type reference in IDL: %s", strings.Join(append(path, name), " -> ")) + case 2: + return nil + } + state[name] = 1 + for _, dep := range deps[name] { + if _, known := deps[dep]; !known { + return fmt.Errorf("type %q references undefined type %q", name, dep) + } + if err := visit(dep, append(path, name)); err != nil { + return err + } + } + state[name] = 2 + ordered = append(ordered, defsByName[name]) + return nil + } + + for i := range types { + if err := visit(types[i].Name, nil); err != nil { + return nil, err + } + } + return ordered, nil +} + +func definedRefsOfTypeDef(def *idl.IdlTypeDef) ([]string, error) { + var refs []string + var walk func(t idltype.IdlType) + walk = func(t idltype.IdlType) { + switch typed := t.(type) { + case *idltype.Option: + walk(typed.Option) + case *idltype.COption: + walk(typed.COption) + case *idltype.Vec: + walk(typed.Vec) + case *idltype.Array: + walk(typed.Type) + case *idltype.Defined: + refs = append(refs, typed.Name) + } + } + + switch ty := def.Ty.(type) { + case *idl.IdlTypeDefTyStruct: + switch fields := ty.Fields.(type) { + case nil: + // empty struct + case idl.IdlDefinedFieldsNamed: + for _, field := range fields { + walk(field.Ty) + } + default: + return nil, fmt.Errorf("type %q: tuple struct fields are not supported by the TypeScript generator yet", def.Name) + } + case *idl.IdlTypeDefTyEnum: + if !ty.IsAllSimple() { + return nil, fmt.Errorf("type %q: data-carrying enums are not supported by the TypeScript generator yet (only scalar enums)", def.Name) + } + default: + return nil, fmt.Errorf("type %q: unsupported type definition kind %T", def.Name, def.Ty) + } + return refs, nil +} + +type tsTypeMapper struct { + defs map[string]*idl.IdlTypeDef + imports map[string]bool + usesAddress bool +} + +func (m *tsTypeMapper) sortedImports() []string { + out := make([]string, 0, len(m.imports)) + for name := range m.imports { + out = append(out, name) + } + sort.Strings(out) + return out +} + +func (m *tsTypeMapper) mapTypeDef(def *idl.IdlTypeDef, tsName string) (*tsTypeDef, error) { + codecConst := toCamelCase(def.Name) + "Codec" + + switch ty := def.Ty.(type) { + case *idl.IdlTypeDefTyEnum: + // definedRefsOfTypeDef already rejected data-carrying enums. + m.imports["getEnumCodec"] = true + out := &tsTypeDef{Name: tsName, CodecConst: codecConst, IsEnum: true} + seen := map[string]string{} + for _, variant := range ty.Variants { + variantName := toPascalCase(variant.Name) + if prev, exists := seen[variantName]; exists { + return nil, fmt.Errorf("type %q: enum variants %q and %q both map to %q", def.Name, prev, variant.Name, variantName) + } + seen[variantName] = variant.Name + out.Variants = append(out.Variants, tsEnumVariant{Name: variantName}) + } + return out, nil + + case *idl.IdlTypeDefTyStruct: + m.imports["getStructCodec"] = true + out := &tsTypeDef{Name: tsName, CodecConst: codecConst} + var named idl.IdlDefinedFieldsNamed + if ty.Fields != nil { + var ok bool + named, ok = ty.Fields.(idl.IdlDefinedFieldsNamed) + if !ok { + return nil, fmt.Errorf("type %q: tuple struct fields are not supported by the TypeScript generator yet", def.Name) + } + } + seen := map[string]string{} + for _, field := range named { + fieldName := toCamelCase(field.Name) + if jsReservedWords[fieldName] { + fieldName += "_" + } + if !isValidJSIdentifier(fieldName) { + return nil, fmt.Errorf("type %q: field %q maps to invalid TypeScript identifier %q", def.Name, field.Name, fieldName) + } + if prev, exists := seen[fieldName]; exists { + return nil, fmt.Errorf("type %q: fields %q and %q both map to %q", def.Name, prev, field.Name, fieldName) + } + seen[fieldName] = field.Name + + tsType, codecExpr, err := m.mapType(field.Ty, fmt.Sprintf("%s.%s", def.Name, field.Name)) + if err != nil { + return nil, err + } + out.Fields = append(out.Fields, tsField{Name: fieldName, TSType: tsType, CodecExpr: codecExpr}) + } + return out, nil + + default: + return nil, fmt.Errorf("type %q: unsupported type definition kind %T", def.Name, def.Ty) + } +} + +// mapType maps an Anchor IDL type to its TypeScript type and the +// @solana/codecs expression that encodes/decodes it (the v1 coverage matrix). +// Unsupported types fail loudly instead of silently mis-encoding. +func (m *tsTypeMapper) mapType(t idltype.IdlType, owner string) (tsType, codecExpr string, err error) { + simple := func(ts, codecFn string) (string, string, error) { + m.imports[codecFn] = true + return ts, codecFn + "()", nil + } + + switch typed := t.(type) { + case *idltype.Bool: + return simple("boolean", "getBooleanCodec") + case *idltype.U8: + return simple("number", "getU8Codec") + case *idltype.I8: + return simple("number", "getI8Codec") + case *idltype.U16: + return simple("number", "getU16Codec") + case *idltype.I16: + return simple("number", "getI16Codec") + case *idltype.U32: + return simple("number", "getU32Codec") + case *idltype.I32: + return simple("number", "getI32Codec") + case *idltype.U64: + return simple("bigint", "getU64Codec") + case *idltype.I64: + return simple("bigint", "getI64Codec") + case *idltype.U128: + return simple("bigint", "getU128Codec") + case *idltype.I128: + return simple("bigint", "getI128Codec") + case *idltype.F32: + return simple("number", "getF32Codec") + case *idltype.F64: + return simple("number", "getF64Codec") + case *idltype.U256, *idltype.I256: + return "", "", fmt.Errorf("%s: %s is not supported by the TypeScript generator yet (no @solana/codecs codec; needs a hand-written 32-byte little-endian codec)", owner, t.String()) + case *idltype.String: + m.imports["addCodecSizePrefix"] = true + m.imports["getUtf8Codec"] = true + m.imports["getU32Codec"] = true + return "string", "addCodecSizePrefix(getUtf8Codec(), getU32Codec())", nil + case *idltype.Bytes: + m.imports["addCodecSizePrefix"] = true + m.imports["getBytesCodec"] = true + m.imports["getU32Codec"] = true + return "Uint8Array", "addCodecSizePrefix(getBytesCodec(), getU32Codec())", nil + case *idltype.Pubkey: + m.usesAddress = true + return "Address", "getAddressCodec()", nil + case *idltype.Option: + innerTS, innerCodec, err := m.mapType(typed.Option, owner) + if err != nil { + return "", "", err + } + m.imports["getNullableCodec"] = true + return fmt.Sprintf("%s | null", innerTS), fmt.Sprintf("getNullableCodec(%s)", innerCodec), nil + case *idltype.COption: + return "", "", fmt.Errorf("%s: COption is not supported by the TypeScript generator yet", owner) + case *idltype.Vec: + innerTS, innerCodec, err := m.mapType(typed.Vec, owner) + if err != nil { + return "", "", err + } + m.imports["getArrayCodec"] = true + m.imports["getU32Codec"] = true + return arrayTSType(innerTS), fmt.Sprintf("getArrayCodec(%s, { size: getU32Codec() })", innerCodec), nil + case *idltype.Array: + size, ok := typed.Size.(*idltype.IdlArrayLenValue) + if !ok { + return "", "", fmt.Errorf("%s: generic array lengths are not supported by the TypeScript generator yet", owner) + } + innerTS, innerCodec, err := m.mapType(typed.Type, owner) + if err != nil { + return "", "", err + } + m.imports["getArrayCodec"] = true + return arrayTSType(innerTS), fmt.Sprintf("getArrayCodec(%s, { size: %d })", innerCodec, size.Value), nil + case *idltype.Defined: + if len(typed.Generics) > 0 { + return "", "", fmt.Errorf("%s: generic type %q is not supported by the TypeScript generator yet", owner, typed.Name) + } + def, exists := m.defs[typed.Name] + if !exists { + return "", "", fmt.Errorf("%s: references undefined type %q", owner, typed.Name) + } + return toPascalCase(def.Name), toCamelCase(def.Name) + "Codec", nil + case *idltype.Generic: + return "", "", fmt.Errorf("%s: generic types are not supported by the TypeScript generator yet", owner) + default: + return "", "", fmt.Errorf("%s: unsupported IDL type %T", owner, t) + } +} + +// arrayTSType wraps union element types in parentheses so `T | null` becomes +// `(T | null)[]` instead of the wrong `T | null[]`. +func arrayTSType(inner string) string { + if strings.Contains(inner, "|") { + return "(" + inner + ")[]" + } + return inner + "[]" +} + +func splitNameWords(name string) []string { + var words []string + var current []rune + flush := func() { + if len(current) > 0 { + words = append(words, string(current)) + current = nil + } + } + runes := []rune(name) + for i, r := range runes { + switch { + case r == '_' || r == '-' || r == ' ': + flush() + case unicode.IsUpper(r): + // Split on lower→Upper and on the last upper of an acronym + // followed by lower (e.g. HTTPServer -> HTTP, Server). + if i > 0 && (unicode.IsLower(runes[i-1]) || unicode.IsDigit(runes[i-1])) { + flush() + } else if i > 0 && unicode.IsUpper(runes[i-1]) && i+1 < len(runes) && unicode.IsLower(runes[i+1]) { + flush() + } + current = append(current, r) + default: + current = append(current, r) + } + } + flush() + return words +} + +func toPascalCase(name string) string { + var b strings.Builder + for _, word := range splitNameWords(name) { + runes := []rune(strings.ToLower(word)) + runes[0] = unicode.ToUpper(runes[0]) + b.WriteString(string(runes)) + } + return b.String() +} + +func toCamelCase(name string) string { + pascal := toPascalCase(name) + if pascal == "" { + return "" + } + runes := []rune(pascal) + runes[0] = unicode.ToLower(runes[0]) + return string(runes) +} + +func toUpperSnake(name string) string { + words := splitNameWords(name) + for i, word := range words { + words[i] = strings.ToUpper(word) + } + return strings.Join(words, "_") +} + +func isValidJSIdentifier(name string) bool { + if name == "" { + return false + } + for i, r := range name { + if i == 0 { + if !unicode.IsLetter(r) && r != '_' && r != '$' { + return false + } + continue + } + if !unicode.IsLetter(r) && !unicode.IsDigit(r) && r != '_' && r != '$' { + return false + } + } + return true +} diff --git a/cmd/generate-bindings/solana/tsbindgen_test.go b/cmd/generate-bindings/solana/tsbindgen_test.go new file mode 100644 index 00000000..65ad7437 --- /dev/null +++ b/cmd/generate-bindings/solana/tsbindgen_test.go @@ -0,0 +1,196 @@ +package solana + +import ( + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// writeTestIdl writes a minimal valid IDL with the given types JSON fragment +// and returns its path. +func writeTestIdl(t *testing.T, typesJSON string) string { + t.Helper() + idl := `{ + "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": [], + "errors": [], + "types": ` + typesJSON + ` +}` + path := filepath.Join(t.TempDir(), "fail_loud.json") + require.NoError(t, os.WriteFile(path, []byte(idl), 0o600)) + return path +} + +// TestGenerateBindingsTS_Golden regenerates the TypeScript bindings for the +// checked-in fixture IDLs and compares them byte-for-byte against the golden +// files. Regenerate goldens with `go generate ./cmd/generate-bindings/solana`. +func TestGenerateBindingsTS_Golden(t *testing.T) { + cases := []struct { + program string + className string + goldenDir string + }{ + {program: "data_storage", className: "DataStorage", goldenDir: "testdata/data_storage_ts"}, + {program: "feature_matrix", className: "FeatureMatrix", goldenDir: "testdata/feature_matrix_ts"}, + } + + for _, tc := range cases { + t.Run(tc.program, func(t *testing.T) { + outDir := t.TempDir() + className, err := GenerateBindingsTS( + filepath.Join("testdata", "contracts", "idl", tc.program+".json"), + tc.program, + outDir, + ) + require.NoError(t, err) + assert.Equal(t, tc.className, className) + + for _, file := range []string{tc.className + ".ts", tc.className + "_mock.ts"} { + generated, err := os.ReadFile(filepath.Join(outDir, file)) + require.NoError(t, err) + golden, err := os.ReadFile(filepath.Join(tc.goldenDir, file)) + require.NoError(t, err) + assert.Equal(t, string(golden), string(generated), + "%s differs from golden; regenerate with `go generate ./cmd/generate-bindings/solana` if the change is intended", file) + } + }) + } +} + +func TestGenerateBindingsTS_GeneratedContent(t *testing.T) { + outDir := t.TempDir() + _, err := GenerateBindingsTS( + filepath.Join("testdata", "contracts", "idl", "feature_matrix.json"), + "feature_matrix", + outDir, + ) + require.NoError(t, err) + + content, err := os.ReadFile(filepath.Join(outDir, "FeatureMatrix.ts")) + require.NoError(t, err) + source := string(content) + + // Spot-check the v1 type-coverage matrix mappings. + assert.Contains(t, source, "export enum Color {") + assert.Contains(t, source, "getEnumCodec(Color)") + assert.Contains(t, source, "huge: bigint") + assert.Contains(t, source, "getU128Codec()") + assert.Contains(t, source, "owner: Address") + assert.Contains(t, source, "getAddressCodec()") + assert.Contains(t, source, "maybeNote: string | null") + assert.Contains(t, source, "getNullableCodec(addCodecSizePrefix(getUtf8Codec(), getU32Codec()))") + assert.Contains(t, source, "getArrayCodec(getU8Codec(), { size: 32 })") + assert.Contains(t, source, "['nested', innerCodec],") + assert.Contains(t, source, "maybeNested: Inner | null") + assert.Contains(t, source, "colorHistory: Color[]") + // JS reserved word field gets an underscore suffix. + assert.Contains(t, source, "default_: boolean") + // Per-struct write methods (single and slice). + assert.Contains(t, source, "writeReportFromEverything(") + assert.Contains(t, source, "writeReportFromEverythings(") + assert.Contains(t, source, "writeReportFromBorshEncodedVec(") + // No dispatch functions for feature_matrix (no accounts or events in IDL). + assert.NotContains(t, source, "parseAnyAccount") + assert.NotContains(t, source, "parseAnyEvent") +} + +func TestGenerateBindingsTS_DispatchFunctions(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) + + assert.Contains(t, source, "export const parseAnyAccount = (data: Uint8Array): DataAccount =>") + assert.Contains(t, source, "export const parseAnyEvent = (data: Uint8Array): AccessLogged | DynamicEvent | NoFields =>") + assert.Contains(t, source, "if (matches(ACCOUNT_DATA_ACCOUNT_DISCRIMINATOR)) return decodeDataAccountAccount(data)") + assert.Contains(t, source, "if (matches(EVENT_ACCESS_LOGGED_DISCRIMINATOR)) return decodeAccessLoggedEvent(data)") + assert.Contains(t, source, "unknown account discriminator") + assert.Contains(t, source, "unknown event discriminator") +} + +func TestGenerateBindingsTS_FailLoud(t *testing.T) { + cases := []struct { + name string + typesJSON string + wantErr string + }{ + { + name: "u256", + typesJSON: `[{"name": "Big", "type": {"kind": "struct", "fields": [{"name": "x", "type": "u256"}]}}]`, + wantErr: "u256", + }, + { + name: "i256", + typesJSON: `[{"name": "Big", "type": {"kind": "struct", "fields": [{"name": "x", "type": "i256"}]}}]`, + wantErr: "i256", + }, + { + name: "coption", + typesJSON: `[{"name": "Opt", "type": {"kind": "struct", "fields": [{"name": "x", "type": {"coption": "u64"}}]}}]`, + wantErr: "COption", + }, + { + name: "data-carrying enum", + typesJSON: `[{"name": "Shape", "type": {"kind": "enum", "variants": [ + {"name": "Circle", "fields": [{"name": "radius", "type": "u64"}]}, + {"name": "Point"} + ]}}]`, + wantErr: "data-carrying enums", + }, + { + name: "tuple struct", + typesJSON: `[{"name": "Pair", "type": {"kind": "struct", "fields": ["u64", "string"]}}]`, + wantErr: "tuple struct fields", + }, + { + name: "cyclic types", + typesJSON: `[ + {"name": "A", "type": {"kind": "struct", "fields": [{"name": "b", "type": {"defined": {"name": "B"}}}]}}, + {"name": "B", "type": {"kind": "struct", "fields": [{"name": "a", "type": {"defined": {"name": "A"}}}]}} + ]`, + wantErr: "cyclic type reference", + }, + { + name: "field name collision after camelCase", + typesJSON: `[{"name": "Collide", "type": {"kind": "struct", "fields": [ + {"name": "user_data", "type": "u8"}, + {"name": "userData", "type": "u8"} + ]}}]`, + wantErr: "both map to", + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + idlPath := writeTestIdl(t, tc.typesJSON) + _, err := GenerateBindingsTS(idlPath, "fail_loud", t.TempDir()) + require.Error(t, err) + assert.Contains(t, err.Error(), tc.wantErr) + }) + } +} + +func TestGenerateBindingsTS_WriteMethodCollision(t *testing.T) { + idlPath := writeTestIdl(t, `[ + {"name": "Foo", "type": {"kind": "struct", "fields": [{"name": "x", "type": "u8"}]}}, + {"name": "Foos", "type": {"kind": "struct", "fields": [{"name": "y", "type": "u8"}]}} + ]`) + _, err := GenerateBindingsTS(idlPath, "fail_loud", t.TempDir()) + require.Error(t, err) + assert.Contains(t, err.Error(), "write method name collision") +} diff --git a/internal/templaterepo/builtin/hello-world-ts/workflow/package.json b/internal/templaterepo/builtin/hello-world-ts/workflow/package.json index e0c1970a..492cc464 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.9.0" + "@chainlink/cre-sdk": "^1.11.0" }, "devDependencies": { "typescript": "5.9.3" From b9d5bba9298abadc106438b7d1e167c246c3b375 Mon Sep 17 00:00:00 2001 From: ernest-nowacki Date: Wed, 24 Jun 2026 17:45:05 +0200 Subject: [PATCH 2/7] Handle IDL without the address in there --- cmd/generate-bindings/solana/bindgen.go | 13 ++++--- cmd/generate-bindings/solana/gen_test.go | 31 ++++++++++++++++ cmd/generate-bindings/solana/tsbindgen.go | 9 +++-- .../solana/tsbindgen_test.go | 36 +++++++++++++++++++ 4 files changed, 83 insertions(+), 6 deletions(-) diff --git a/cmd/generate-bindings/solana/bindgen.go b/cmd/generate-bindings/solana/bindgen.go index f9f5b98e..bf39d48a 100644 --- a/cmd/generate-bindings/solana/bindgen.go +++ b/cmd/generate-bindings/solana/bindgen.go @@ -49,9 +49,11 @@ func GenerateBindings( return fmt.Errorf("invalid IDL: %w", err) } if parsedIdl.Address == nil || parsedIdl.Address.IsZero() { - return fmt.Errorf("address is empty in idl file: %s", pathToIdl) + slog.Warn("IDL has no address field; program_id.go will not be generated — pass the program ID at runtime", + "path", pathToIdl) + } else { + slog.Info("Using IDL address as program ID", "address", parsedIdl.Address.String()) } - slog.Info("Using IDL address as program ID", "address", parsedIdl.Address.String()) parsedIdl.Metadata.Name = bin.ToSnakeForSighash(parsedIdl.Metadata.Name) // check that the name is not a reserved keyword: @@ -85,11 +87,14 @@ func GenerateBindings( ProgramId: parsedIdl.Address, } + programIDStr := "" + if parsedIdl.Address != nil && !parsedIdl.Address.IsZero() { + programIDStr = parsedIdl.Address.String() + } slog.Info("Parsed IDL successfully", "version", parsedIdl.Metadata.Version, "name", parsedIdl.Metadata.Name, - "address", parsedIdl.Address, - "programId", parsedIdl.Address.String(), + "programId", programIDStr, "instructionsCount", len(parsedIdl.Instructions), "accountsCount", len(parsedIdl.Accounts), "eventsCount", len(parsedIdl.Events), diff --git a/cmd/generate-bindings/solana/gen_test.go b/cmd/generate-bindings/solana/gen_test.go index 1a97f327..1988a727 100644 --- a/cmd/generate-bindings/solana/gen_test.go +++ b/cmd/generate-bindings/solana/gen_test.go @@ -1,8 +1,13 @@ package solana_test import ( + "os" + "path/filepath" "testing" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/smartcontractkit/cre-cli/cmd/generate-bindings/solana" ) @@ -15,3 +20,29 @@ func TestGenerateBindings(t *testing.T) { t.Fatal(err) } } + +// TestGenerateBindings_MissingAddress verifies that an IDL without an address +// field succeeds (previously it returned an error). program_id.go must not be +// generated since there is no address to embed. +func TestGenerateBindings_MissingAddress(t *testing.T) { + idl := `{ + "metadata": {"name": "no_addr", "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": [], + "errors": [], + "types": [] +}` + idlPath := filepath.Join(t.TempDir(), "no_addr.json") + require.NoError(t, os.WriteFile(idlPath, []byte(idl), 0o600)) + + outDir := t.TempDir() + err := solana.GenerateBindings(idlPath, "no_addr", outDir) + require.NoError(t, err) + + // program_id.go must not be generated when there is no address in the IDL. + _, 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") +} diff --git a/cmd/generate-bindings/solana/tsbindgen.go b/cmd/generate-bindings/solana/tsbindgen.go index 7415c69e..ca7a2ba5 100644 --- a/cmd/generate-bindings/solana/tsbindgen.go +++ b/cmd/generate-bindings/solana/tsbindgen.go @@ -119,7 +119,8 @@ func GenerateBindingsTS( return "", fmt.Errorf("invalid IDL: %w", err) } if parsedIdl.Address == nil || parsedIdl.Address.IsZero() { - return "", fmt.Errorf("address is empty in idl file: %s", pathToIdl) + slog.Warn("IDL has no address field; generated program ID constant will be empty — pass the program ID at construction time", + "path", pathToIdl) } rawIdl, err := os.ReadFile(pathToIdl) //nolint:gosec // G703 -- path from trusted CLI flags @@ -184,13 +185,17 @@ func buildTSBindingData(parsedIdl *idl.Idl, programName, idlJSON string) (*tsBin return nil, fmt.Errorf("program name %q maps to invalid TypeScript class name %q", programName, className) } + programID := "" + if parsedIdl.Address != nil && !parsedIdl.Address.IsZero() { + programID = parsedIdl.Address.String() + } data := &tsBindingData{ ProgramName: programName, ClassName: className, MockName: className + "Mock", ProgramIDConst: toUpperSnake(programName) + "_PROGRAM_ID", IdlConst: toUpperSnake(programName) + "_IDL", - ProgramID: parsedIdl.Address.String(), + ProgramID: programID, IdlJSON: idlJSON, } diff --git a/cmd/generate-bindings/solana/tsbindgen_test.go b/cmd/generate-bindings/solana/tsbindgen_test.go index 65ad7437..9c4070b4 100644 --- a/cmd/generate-bindings/solana/tsbindgen_test.go +++ b/cmd/generate-bindings/solana/tsbindgen_test.go @@ -9,6 +9,24 @@ import ( "github.com/stretchr/testify/require" ) +// writeTestIdlNoAddress writes a minimal valid IDL without an address field. +func writeTestIdlNoAddress(t *testing.T) string { + t.Helper() + idl := `{ + "metadata": {"name": "no_addr", "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": [], + "errors": [], + "types": [] +}` + path := filepath.Join(t.TempDir(), "no_addr.json") + require.NoError(t, os.WriteFile(path, []byte(idl), 0o600)) + return path +} + // writeTestIdl writes a minimal valid IDL with the given types JSON fragment // and returns its path. func writeTestIdl(t *testing.T, typesJSON string) string { @@ -185,6 +203,24 @@ func TestGenerateBindingsTS_FailLoud(t *testing.T) { } } +// TestGenerateBindingsTS_MissingAddress verifies that an IDL without an address +// field succeeds (previously it returned an error). The generated program ID +// constant must be empty so callers know to supply the address at construction time. +func TestGenerateBindingsTS_MissingAddress(t *testing.T) { + idlPath := writeTestIdlNoAddress(t) + outDir := t.TempDir() + className, err := GenerateBindingsTS(idlPath, "no_addr", outDir) + require.NoError(t, err) + assert.Equal(t, "NoAddr", className) + + content, err := os.ReadFile(filepath.Join(outDir, "NoAddr.ts")) + require.NoError(t, err) + source := string(content) + + // Program ID constant must be empty — caller must supply it at construction time. + assert.Contains(t, source, "export const NO_ADDR_PROGRAM_ID = ''") +} + func TestGenerateBindingsTS_WriteMethodCollision(t *testing.T) { idlPath := writeTestIdl(t, `[ {"name": "Foo", "type": {"kind": "struct", "fields": [{"name": "x", "type": "u8"}]}}, From 1d71294b8edf324ff4efc2232c2c005d6fff51d9 Mon Sep 17 00:00:00 2001 From: ernest-nowacki Date: Wed, 24 Jun 2026 18:23:01 +0200 Subject: [PATCH 3/7] Drop slog --- cmd/generate-bindings/solana/tsbindgen.go | 5 ----- 1 file changed, 5 deletions(-) diff --git a/cmd/generate-bindings/solana/tsbindgen.go b/cmd/generate-bindings/solana/tsbindgen.go index ca7a2ba5..ce7fbf97 100644 --- a/cmd/generate-bindings/solana/tsbindgen.go +++ b/cmd/generate-bindings/solana/tsbindgen.go @@ -118,11 +118,6 @@ func GenerateBindingsTS( if err := parsedIdl.Validate(); err != nil { return "", fmt.Errorf("invalid IDL: %w", err) } - if parsedIdl.Address == nil || parsedIdl.Address.IsZero() { - slog.Warn("IDL has no address field; generated program ID constant will be empty — pass the program ID at construction time", - "path", pathToIdl) - } - rawIdl, err := os.ReadFile(pathToIdl) //nolint:gosec // G703 -- path from trusted CLI flags if err != nil { return "", fmt.Errorf("read IDL %q: %w", pathToIdl, err) From 73cbad60a0bfe66734b944704f0d48a2dcec53d3 Mon Sep 17 00:00:00 2001 From: ernest-nowacki Date: Mon, 29 Jun 2026 18:16:03 +0200 Subject: [PATCH 4/7] Do not include mock in the barrel --- cmd/generate-bindings/evm/evm.go | 1 - cmd/generate-bindings/solana/solana.go | 2 +- 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/cmd/generate-bindings/evm/evm.go b/cmd/generate-bindings/evm/evm.go index f0850b40..e6abb35f 100644 --- a/cmd/generate-bindings/evm/evm.go +++ b/cmd/generate-bindings/evm/evm.go @@ -343,7 +343,6 @@ func (h *handler) processAbiDirectory(inputs Inputs) error { indexContent += "// Code generated — DO NOT EDIT.\n" for _, name := range generatedContracts { indexContent += fmt.Sprintf("export * from './%s'\n", name) - indexContent += fmt.Sprintf("export * from './%s_mock'\n", name) } if err := os.WriteFile(indexPath, []byte(indexContent), 0o600); err != nil { return fmt.Errorf("failed to write index.ts: %w", err) diff --git a/cmd/generate-bindings/solana/solana.go b/cmd/generate-bindings/solana/solana.go index d310f307..10176ac2 100644 --- a/cmd/generate-bindings/solana/solana.go +++ b/cmd/generate-bindings/solana/solana.go @@ -292,7 +292,7 @@ func (h *handler) writeTSBarrel(inputs Inputs, classNames []string) error { var sb strings.Builder sb.WriteString("// Code generated — DO NOT EDIT.\n") for _, name := range classNames { - fmt.Fprintf(&sb, "export * from './%s'\nexport * from './%s_mock'\n", name, name) + fmt.Fprintf(&sb, "export * from './%s'\n", name) } if err := os.WriteFile(indexPath, []byte(sb.String()), 0o600); err != nil { return fmt.Errorf("failed to write index.ts: %w", err) From d1769a58fda52f073a9bfce5e1c223dc99eba2bd Mon Sep 17 00:00:00 2001 From: ernest-nowacki Date: Tue, 30 Jun 2026 18:33:32 +0200 Subject: [PATCH 5/7] Run doc build --- docs/cre_generate-bindings_solana.md | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/docs/cre_generate-bindings_solana.md b/docs/cre_generate-bindings_solana.md index b95b9d83..86c36ecd 100644 --- a/docs/cre_generate-bindings_solana.md +++ b/docs/cre_generate-bindings_solana.md @@ -5,9 +5,13 @@ Generate bindings from contract IDL ### Synopsis This command generates bindings from contract IDL files. -Supports Solana chain family and Go language. -Each contract gets its own package subdirectory to avoid naming conflicts. -For example, data_storage.json generates bindings in generated/data_storage/ package. +Supports Solana chain family with Go and TypeScript languages. +The target language is auto-detected from project files, or can be +specified explicitly with --language. +For Go, each contract gets its own package subdirectory to avoid naming +conflicts: data_storage.json generates bindings in generated/data_storage/. +For TypeScript, each contract generates a flat .ts + _mock.ts +pair plus an index.ts barrel. ``` cre generate-bindings solana [optional flags] @@ -16,7 +20,7 @@ cre generate-bindings solana [optional flags] ### Examples ``` - cre generate-bindings-solana + cre generate-bindings solana ``` ### Options @@ -24,8 +28,8 @@ cre generate-bindings solana [optional flags] ``` -h, --help help for solana -i, --idl string Path to IDL directory (defaults to contracts/solana/src/idl/) - -l, --language string Target language (go) (default "go") - -o, --out string Path to output directory (defaults to contracts/solana/src/generated/) + -l, --language string Target language: go, typescript (auto-detected from project files when omitted) + -o, --out string Path to output directory (defaults to contracts/solana/src/generated/ for Go, contracts/solana/ts/generated/ for TypeScript) -p, --project-root string Path to project root directory (defaults to current directory) ``` From b50032c5d476e4f0b500209dd13b3f88b2c98eb4 Mon Sep 17 00:00:00 2001 From: ernest-nowacki Date: Thu, 2 Jul 2026 11:09:11 +0200 Subject: [PATCH 6/7] Bump sdk to 1.15.0 --- cmd/common/compile_test.go | 2 +- cmd/workflow/convert/convert_test.go | 2 +- internal/constants/constants.go | 2 +- .../templaterepo/builtin/hello-world-ts/workflow/package.json | 4 ++-- 4 files changed, 5 insertions(+), 5 deletions(-) diff --git a/cmd/common/compile_test.go b/cmd/common/compile_test.go index 284949ab..2ed64dc6 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.14.0"}} + require.NoError(t, os.WriteFile(filepath.Join(dir, "package.json"), []byte(`{"name":"test","dependencies":{"@chainlink/cre-sdk":"^1.15.0"}} `), 0600)) install := exec.Command("bun", "install") install.Dir = dir diff --git a/cmd/workflow/convert/convert_test.go b/cmd/workflow/convert/convert_test.go index 3a813e66..3c188ee7 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.14.0"}}`), 0600)) + require.NoError(t, os.WriteFile(packageJSON, []byte(`{"name":"test","private":true,"dependencies":{"@chainlink/cre-sdk":"^1.15.0"}}`), 0600)) h := newHandler(nil) err := h.Execute(Inputs{WorkflowFolder: dir, Force: true}) diff --git a/internal/constants/constants.go b/internal/constants/constants.go index 1a1c5fe6..1323556e 100644 --- a/internal/constants/constants.go +++ b/internal/constants/constants.go @@ -58,7 +58,7 @@ const ( WorkflowLanguageWasm = "wasm" // SDK dependency versions (used by generate-bindings and go module init) - SdkVersion = "v1.14.0" + SdkVersion = "v1.15.0" EVMCapabilitiesVersion = "v1.0.0-beta.14" HTTPCapabilitiesVersion = "v1.3.0" CronCapabilitiesVersion = "v1.3.0" diff --git a/internal/templaterepo/builtin/hello-world-ts/workflow/package.json b/internal/templaterepo/builtin/hello-world-ts/workflow/package.json index 13955939..9de67739 100644 --- a/internal/templaterepo/builtin/hello-world-ts/workflow/package.json +++ b/internal/templaterepo/builtin/hello-world-ts/workflow/package.json @@ -8,9 +8,9 @@ }, "license": "UNLICENSED", "dependencies": { - "@chainlink/cre-sdk": "^1.14.0" + "@chainlink/cre-sdk": "^1.15.0" }, "devDependencies": { "typescript": "5.9.3" } -} \ No newline at end of file +} From 5850d98cc331347cb88263a977cb8256b6c15db6 Mon Sep 17 00:00:00 2001 From: ernest-nowacki Date: Thu, 2 Jul 2026 11:22:11 +0200 Subject: [PATCH 7/7] Revert go sdk to 1.14.0 --- internal/constants/constants.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/constants/constants.go b/internal/constants/constants.go index 1323556e..1a1c5fe6 100644 --- a/internal/constants/constants.go +++ b/internal/constants/constants.go @@ -58,7 +58,7 @@ const ( WorkflowLanguageWasm = "wasm" // SDK dependency versions (used by generate-bindings and go module init) - SdkVersion = "v1.15.0" + SdkVersion = "v1.14.0" EVMCapabilitiesVersion = "v1.0.0-beta.14" HTTPCapabilitiesVersion = "v1.3.0" CronCapabilitiesVersion = "v1.3.0"