From 4c9907c7393e26192521996a09a6af70d2ed667d Mon Sep 17 00:00:00 2001 From: ayomidearegbeshola29-dev Date: Fri, 26 Jun 2026 15:09:32 +0000 Subject: [PATCH] feat(soroban): add automated ABI binding generator for type-safe contract invocation Co-authored-by: opencode --- .../stellar/src/abi-binding-generator.test.ts | 486 ++++++++++++++++ packages/stellar/src/abi-binding-generator.ts | 518 ++++++++++++++++++ packages/stellar/src/index.ts | 1 + 3 files changed, 1005 insertions(+) create mode 100644 packages/stellar/src/abi-binding-generator.test.ts create mode 100644 packages/stellar/src/abi-binding-generator.ts diff --git a/packages/stellar/src/abi-binding-generator.test.ts b/packages/stellar/src/abi-binding-generator.test.ts new file mode 100644 index 0000000..202d6ba --- /dev/null +++ b/packages/stellar/src/abi-binding-generator.test.ts @@ -0,0 +1,486 @@ +import { describe, it, expect } from 'vitest'; +import { xdr, ContractSpec } from 'stellar-sdk'; +import { + xdrTypeToTypeScript, + parseContractAbi, + generateBinding, +} from './abi-binding-generator'; + +// --------------------------------------------------------------------------- +// Helpers: build minimal spec entries +// --------------------------------------------------------------------------- + +function buildSpecEntries( + overrides?: Partial<{ + functions: { + doc?: string; + name: string; + inputs: { doc?: string; name: string; type: xdr.ScSpecTypeDef }[]; + outputs: xdr.ScSpecTypeDef[]; + }[]; + }>, +): xdr.ScSpecEntry[] { + const entries: xdr.ScSpecEntry[] = []; + for (const fn of overrides?.functions ?? []) { + entries.push( + xdr.ScSpecEntry.scSpecEntryFunctionV0( + new xdr.ScSpecFunctionV0({ + doc: fn.doc ?? '', + name: fn.name, + inputs: fn.inputs.map( + (i) => + new xdr.ScSpecFunctionInputV0({ + doc: i.doc ?? '', + name: i.name, + type: i.type, + }), + ), + outputs: fn.outputs, + }), + ), + ); + } + return entries; +} + +function buildStructUdt( + name: string, + fields: { doc?: string; name: string; type: xdr.ScSpecTypeDef }[], +): xdr.ScSpecEntry { + return xdr.ScSpecEntry.scSpecEntryUdtStructV0( + new xdr.ScSpecUdtStructV0({ + doc: '', + lib: '', + name, + fields: fields.map( + (f) => + new xdr.ScSpecUdtStructFieldV0({ + doc: f.doc ?? '', + name: f.name, + type: f.type, + }), + ), + }), + ); +} + +function buildCompoundType( + arm: string, + inner: xdr.ScSpecTypeDef, +): xdr.ScSpecTypeDef { + switch (arm) { + case 'option': + return xdr.ScSpecTypeDef.scSpecTypeOption( + new xdr.ScSpecTypeOption({ valueType: inner }), + ); + case 'vec': + return xdr.ScSpecTypeDef.scSpecTypeVec( + new xdr.ScSpecTypeVec({ elementType: inner }), + ); + case 'result': + return xdr.ScSpecTypeDef.scSpecTypeResult( + new xdr.ScSpecTypeResult({ okType: inner, errorType: xdr.ScSpecTypeDef.scSpecTypeError() }), + ); + default: + return inner; + } +} + +// --------------------------------------------------------------------------- +// xdrTypeToTypeScript — pure type mapping +// --------------------------------------------------------------------------- + +describe('xdrTypeToTypeScript', () => { + const emptyUdtNames = new Set(); + + const primitives: [string, string, string][] = [ + ['bool', 'scSpecTypeBool', 'boolean'], + ['void', 'scSpecTypeVoid', 'void'], + ['u32', 'scSpecTypeU32', 'number'], + ['i32', 'scSpecTypeI32', 'number'], + ['u64', 'scSpecTypeU64', 'bigint'], + ['i64', 'scSpecTypeI64', 'bigint'], + ['u128', 'scSpecTypeU128', 'bigint'], + ['i128', 'scSpecTypeI128', 'bigint'], + ['u256', 'scSpecTypeU256', 'bigint'], + ['i256', 'scSpecTypeI256', 'bigint'], + ['timepoint', 'scSpecTypeTimepoint', 'bigint'], + ['duration', 'scSpecTypeDuration', 'bigint'], + ['address', 'scSpecTypeAddress', 'string'], + ['string', 'scSpecTypeString', 'string'], + ['symbol', 'scSpecTypeSymbol', 'string'], + ['bytes', 'scSpecTypeBytes', 'Buffer'], + ['val', 'scSpecTypeVal', 'xdr.ScVal'], + ['error', 'scSpecTypeError', 'Error'], + ]; + + for (const [label, arm, expected] of primitives) { + it(`maps ${label}`, () => { + const td = (xdr.ScSpecTypeDef as any)[arm](); + expect(xdrTypeToTypeScript(td, emptyUdtNames)).toBe(expected); + }); + } + + it('maps bytesN', () => { + const td = xdr.ScSpecTypeDef.scSpecTypeBytesN( + new xdr.ScSpecTypeBytesN({ n: 32 }), + ); + expect(xdrTypeToTypeScript(td, emptyUdtNames)).toBe('Buffer'); + }); + + it('maps option type', () => { + const opt = buildCompoundType('option', xdr.ScSpecTypeDef.scSpecTypeAddress()); + expect(xdrTypeToTypeScript(opt, emptyUdtNames)).toBe('string | undefined'); + }); + + it('maps vec type', () => { + const vec = buildCompoundType('vec', xdr.ScSpecTypeDef.scSpecTypeBool()); + expect(xdrTypeToTypeScript(vec, emptyUdtNames)).toBe('boolean[]'); + }); + + it('maps result type', () => { + const res = buildCompoundType('result', xdr.ScSpecTypeDef.scSpecTypeI32()); + expect(xdrTypeToTypeScript(res, emptyUdtNames)).toBe('Result'); + }); + + it('maps tuple type', () => { + const tuple = xdr.ScSpecTypeDef.scSpecTypeTuple( + new xdr.ScSpecTypeTuple({ valueTypes: [ + xdr.ScSpecTypeDef.scSpecTypeAddress(), + xdr.ScSpecTypeDef.scSpecTypeI128(), + ]}), + ); + expect(xdrTypeToTypeScript(tuple, emptyUdtNames)).toBe('[string, bigint]'); + }); + + it('maps map type', () => { + const map = xdr.ScSpecTypeDef.scSpecTypeMap( + new xdr.ScSpecTypeMap({ keyType: xdr.ScSpecTypeDef.scSpecTypeSymbol(), valueType: xdr.ScSpecTypeDef.scSpecTypeString() }), + ); + expect(xdrTypeToTypeScript(map, emptyUdtNames)).toBe('Record'); + }); + + it('maps udt type when name is in set', () => { + const udt = xdr.ScSpecTypeDef.scSpecTypeUdt( + new xdr.ScSpecTypeUdt({ name: 'AllowanceData' }), + ); + const names = new Set(['AllowanceData']); + expect(xdrTypeToTypeScript(udt, names)).toBe('AllowanceData'); + }); + + it('enforces recursion depth limit', () => { + let deep = xdr.ScSpecTypeDef.scSpecTypeBool(); + for (let i = 0; i < 12; i++) { + deep = buildCompoundType('option', deep); + } + const result = xdrTypeToTypeScript(deep, emptyUdtNames); + expect(result).toContain('unknown'); + expect(result.split('|').length).toBeGreaterThan(10); + }); +}); + +// --------------------------------------------------------------------------- +// parseContractAbi +// --------------------------------------------------------------------------- + +describe('parseContractAbi', () => { + it('parses a simple function spec', () => { + const entries = buildSpecEntries({ + functions: [ + { + name: 'hello', + inputs: [ + { name: 'who', type: xdr.ScSpecTypeDef.scSpecTypeSymbol() }, + ], + outputs: [xdr.ScSpecTypeDef.scSpecTypeString()], + }, + ], + }); + + const parsed = parseContractAbi(entries, 'Greeter'); + expect(parsed.contractName).toBe('Greeter'); + expect(parsed.functions).toHaveLength(1); + expect(parsed.functions[0].name).toBe('hello'); + expect(parsed.functions[0].params).toHaveLength(1); + expect(parsed.functions[0].params[0].name).toBe('who'); + expect(parsed.functions[0].params[0].type).toBe('string'); + expect(parsed.functions[0].returnType).toBe('string'); + }); + + it('parses functions with no inputs', () => { + const entries = buildSpecEntries({ + functions: [ + { + name: 'status', + inputs: [], + outputs: [xdr.ScSpecTypeDef.scSpecTypeString()], + }, + ], + }); + + const parsed = parseContractAbi(entries); + expect(parsed.functions[0].params).toHaveLength(0); + expect(parsed.functions[0].returnType).toBe('string'); + }); + + it('parses functions with void return', () => { + const entries = buildSpecEntries({ + functions: [ + { + name: 'init', + inputs: [ + { name: 'admin', type: xdr.ScSpecTypeDef.scSpecTypeAddress() }, + ], + outputs: [], + }, + ], + }); + + const parsed = parseContractAbi(entries); + expect(parsed.functions[0].returnType).toBe('void'); + }); + + it('resolves UDT references from function return types', () => { + const entries = [ + buildStructUdt('AllowanceData', [ + { name: 'spender', type: xdr.ScSpecTypeDef.scSpecTypeAddress() }, + { name: 'amount', type: xdr.ScSpecTypeDef.scSpecTypeI128() }, + ]), + ...buildSpecEntries({ + functions: [ + { + name: 'query_allowance', + inputs: [ + { name: 'from', type: xdr.ScSpecTypeDef.scSpecTypeAddress() }, + { name: 'spender', type: xdr.ScSpecTypeDef.scSpecTypeAddress() }, + ], + outputs: [ + xdr.ScSpecTypeDef.scSpecTypeUdt( + new xdr.ScSpecTypeUdt({ name: 'AllowanceData' }), + ), + ], + }, + ], + }), + ]; + + const parsed = parseContractAbi(entries); + expect(parsed.functions[0].returnType).toBe('AllowanceData'); + }); + + it('parses contract spec from base64 strings', () => { + const entries = buildSpecEntries({ + functions: [ + { + name: 'greet', + inputs: [], + outputs: [xdr.ScSpecTypeDef.scSpecTypeString()], + }, + ], + }); + const spec = new ContractSpec(entries); + const base64 = spec.entries.map((e: xdr.ScSpecEntry) => e.toXDR('base64')); + + const parsed = parseContractAbi(base64); + expect(parsed.functions).toHaveLength(1); + expect(parsed.functions[0].name).toBe('greet'); + }); +}); + +// --------------------------------------------------------------------------- +// generateBinding +// --------------------------------------------------------------------------- + +describe('generateBinding', () => { + it('produces a string with header', () => { + const entries = buildSpecEntries({ + functions: [ + { + name: 'hello', + inputs: [], + outputs: [xdr.ScSpecTypeDef.scSpecTypeString()], + }, + ], + }); + + const source = generateBinding(entries, 'Greeter'); + expect(source).toContain('Auto-generated by abi-binding-generator'); + expect(source).toContain('Contract: Greeter'); + }); + + it('includes TypeScript interfaces for struct UDTs', () => { + const entries = [ + buildStructUdt('AllowanceData', [ + { name: 'spender', type: xdr.ScSpecTypeDef.scSpecTypeAddress() }, + { name: 'amount', type: xdr.ScSpecTypeDef.scSpecTypeI128() }, + ]), + ...buildSpecEntries({ + functions: [ + { + name: 'allowance', + inputs: [], + outputs: [xdr.ScSpecTypeDef.scSpecTypeUdt(new xdr.ScSpecTypeUdt({ name: 'AllowanceData' }))], + }, + ], + }), + ]; + + const source = generateBinding(entries); + expect(source).toContain('export interface AllowanceData'); + expect(source).toContain('spender: string'); + expect(source).toContain('amount: bigint'); + }); + + it('includes argument interfaces for functions', () => { + const entries = buildSpecEntries({ + functions: [ + { + name: 'transfer', + inputs: [ + { name: 'to', type: xdr.ScSpecTypeDef.scSpecTypeAddress() }, + { name: 'amount', type: xdr.ScSpecTypeDef.scSpecTypeI128() }, + ], + outputs: [xdr.ScSpecTypeDef.scSpecTypeBool()], + }, + ], + }); + + const source = generateBinding(entries); + expect(source).toContain('export interface transferArgs'); + expect(source).toContain('to: string'); + expect(source).toContain('amount: bigint'); + }); + + it('skips argument interface for functions with no params', () => { + const entries = buildSpecEntries({ + functions: [ + { + name: 'status', + inputs: [], + outputs: [xdr.ScSpecTypeDef.scSpecTypeString()], + }, + ], + }); + + const source = generateBinding(entries); + expect(source).not.toContain('export interface StatusArgs'); + }); + + it('includes factory function', () => { + const entries = buildSpecEntries({ + functions: [ + { + name: 'hello', + inputs: [], + outputs: [xdr.ScSpecTypeDef.scSpecTypeString()], + }, + ], + }); + + const source = generateBinding(entries, 'Greeter'); + expect(source).toContain('export function createGreeter'); + expect(source).toContain('InvokeCallback'); + expect(source).toContain('contractId: string'); + }); + + it('generated factory includes async methods for each function', () => { + const entries = buildSpecEntries({ + functions: [ + { + name: 'balance', + inputs: [ + { name: 'id', type: xdr.ScSpecTypeDef.scSpecTypeAddress() }, + ], + outputs: [xdr.ScSpecTypeDef.scSpecTypeI128()], + }, + { + name: 'decimals', + inputs: [], + outputs: [xdr.ScSpecTypeDef.scSpecTypeU32()], + }, + ], + }); + + const source = generateBinding(entries, 'Token'); + expect(source).toContain('async balance('); + expect(source).toContain('async decimals('); + }); + + it('includes lazy spec singleton', () => { + const entries = buildSpecEntries({ + functions: [ + { + name: 'hello', + inputs: [], + outputs: [xdr.ScSpecTypeDef.scSpecTypeString()], + }, + ], + }); + + const source = generateBinding(entries); + expect(source).toContain('let _spec: ContractSpec | null = null'); + expect(source).toContain('function _getSpec()'); + }); + + it('passes function name to invoke callback', () => { + const entries = buildSpecEntries({ + functions: [ + { + name: 'foo', + inputs: [], + outputs: [xdr.ScSpecTypeDef.scSpecTypeVoid()], + }, + ], + }); + + const source = generateBinding(entries); + expect(source).toContain("invoke('foo'"); + }); + + it('includes InvokeCallback type', () => { + const entries = buildSpecEntries({ + functions: [ + { + name: 'ping', + inputs: [], + outputs: [xdr.ScSpecTypeDef.scSpecTypeBool()], + }, + ], + }); + + const source = generateBinding(entries, 'Ping'); + expect(source).toContain('export type InvokeCallback'); + expect(source).toContain('invoke: InvokeCallback'); + }); + + it('compiles to valid TypeScript with all primitive types', () => { + const entries = buildSpecEntries({ + functions: [ + { + name: 'all_types', + inputs: [ + { name: 'b', type: xdr.ScSpecTypeDef.scSpecTypeBool() }, + { name: 'u', type: xdr.ScSpecTypeDef.scSpecTypeU32() }, + { name: 'i', type: xdr.ScSpecTypeDef.scSpecTypeI32() }, + { name: 'l', type: xdr.ScSpecTypeDef.scSpecTypeI128() }, + { name: 's', type: xdr.ScSpecTypeDef.scSpecTypeString() }, + { name: 'a', type: xdr.ScSpecTypeDef.scSpecTypeAddress() }, + { name: 'opt', type: buildCompoundType('option', xdr.ScSpecTypeDef.scSpecTypeBool()) }, + ], + outputs: [xdr.ScSpecTypeDef.scSpecTypeI128()], + }, + ], + }); + + const source = generateBinding(entries, 'AllTypes'); + expect(source).toContain('b: boolean'); + expect(source).toContain('u: number'); + expect(source).toContain('i: number'); + expect(source).toContain('l: bigint'); + expect(source).toContain('s: string'); + expect(source).toContain('a: string'); + expect(source).toContain('opt: boolean | undefined'); + expect(source).toContain('async all_types('); + expect(source).toContain('Promise'); + }); +}); diff --git a/packages/stellar/src/abi-binding-generator.ts b/packages/stellar/src/abi-binding-generator.ts new file mode 100644 index 0000000..b8c8eef --- /dev/null +++ b/packages/stellar/src/abi-binding-generator.ts @@ -0,0 +1,518 @@ +import { xdr, ContractSpec } from 'stellar-sdk'; + +// --------------------------------------------------------------------------- +// Parsed ABI types +// --------------------------------------------------------------------------- + +export interface AbiParameter { + name: string; + doc: string; + type: string; +} + +export interface AbiFunction { + name: string; + doc: string; + params: AbiParameter[]; + returnType: string; +} + +export interface AbiUdt { + kind: 'struct' | 'union' | 'enum' | 'errorEnum'; + name: string; + doc: string; + fields?: { name: string; doc: string; type: string }[]; + cases?: { name: string; doc: string; value?: number; type?: string }[]; +} + +export interface ParsedContractAbi { + contractName: string; + functions: AbiFunction[]; + udts: AbiUdt[]; +} + +// --------------------------------------------------------------------------- +// XDR type → TypeScript type mapping +// --------------------------------------------------------------------------- + +const PRIMITIVE_MAP: Record = { + scSpecTypeVal: 'xdr.ScVal', + scSpecTypeBool: 'boolean', + scSpecTypeVoid: 'void', + scSpecTypeError: 'Error', + scSpecTypeU32: 'number', + scSpecTypeI32: 'number', + scSpecTypeU64: 'bigint', + scSpecTypeI64: 'bigint', + scSpecTypeU128: 'bigint', + scSpecTypeI128: 'bigint', + scSpecTypeU256: 'bigint', + scSpecTypeI256: 'bigint', + scSpecTypeTimepoint: 'bigint', + scSpecTypeDuration: 'bigint', + scSpecTypeBytes: 'Buffer', + scSpecTypeBytesN: 'Buffer', + scSpecTypeString: 'string', + scSpecTypeSymbol: 'string', + scSpecTypeAddress: 'string', +}; + +/** + * Resolve a (possibly-UDT) type name to its TypeScript representation. + * If `udtName` is in the `udts` set, use the UDT name directly (it will be + * emitted as a standalone interface/enum). Otherwise return a fallback. + */ +function resolveUdtTypeName( + rawName: string, + udtNames: ReadonlySet, +): string { + if (udtNames.has(rawName)) return rawName; + if (rawName.startsWith('ErrorEnum(')) return 'Error'; + return rawName; +} + +/** + * Recursively convert an XDR ScSpecTypeDef into a TypeScript type string. + * + * @param typeDef - The XDR type definition + * @param udtNames - Set of known UDT names in the contract (for cross-refs) + * @param depth - Recursion depth guard + */ +export function xdrTypeToTypeScript( + typeDef: xdr.ScSpecTypeDef, + udtNames: ReadonlySet = new Set(), + depth: number = 0, +): string { + if (depth >= 10) return 'unknown'; + + const arm = typeDef.switch().name; + + // Primitive types + const mapped = PRIMITIVE_MAP[arm]; + if (mapped) return mapped; + + switch (arm) { + case 'scSpecTypeOption': { + const optVal = typeDef.value() as xdr.ScSpecTypeOption; + const inner = optVal.valueType(); + return `${xdrTypeToTypeScript(inner, udtNames, depth + 1)} | undefined`; + } + + case 'scSpecTypeResult': { + const resVal = typeDef.value() as xdr.ScSpecTypeResult; + const okInner = resVal.okType(); + return `Result<${xdrTypeToTypeScript(okInner, udtNames, depth + 1)}, Error>`; + } + + case 'scSpecTypeVec': { + const vecVal = typeDef.value() as xdr.ScSpecTypeVec; + const inner = vecVal.elementType(); + return `${xdrTypeToTypeScript(inner, udtNames, depth + 1)}[]`; + } + + case 'scSpecTypeMap': { + const mapVal = typeDef.value() as xdr.ScSpecTypeMap; + const inner = mapVal.valueType(); + return `Record`; + } + + case 'scSpecTypeTuple': { + const tupleVal = typeDef.value() as xdr.ScSpecTypeTuple; + const innerArr = tupleVal.valueTypes(); + const elements = innerArr + ? Array.from(innerArr).map((t: xdr.ScSpecTypeDef) => + xdrTypeToTypeScript(t, udtNames, depth + 1), + ) + : []; + return elements.length > 0 ? `[${elements.join(', ')}]` : 'unknown[]'; + } + + case 'scSpecTypeUdt': { + const udtVal = typeDef.value() as xdr.ScSpecTypeUdt; + const rawName = udtVal.name().toString(); + return resolveUdtTypeName(rawName, udtNames); + } + + default: + return 'unknown'; + } +} + +// --------------------------------------------------------------------------- +// Spec entry parsing +// --------------------------------------------------------------------------- + +/** + * Parse ScSpecEntry[] into a `ParsedContractAbi` that can be consumed by the + * code generators. + */ +export function parseAbi( + entries: (xdr.ScSpecEntry | string)[], + contractName?: string, +): ParsedContractAbi { + const spec = new ContractSpec(entries as any); + + const functions: AbiFunction[] = []; + const udts: AbiUdt[] = []; + const udtNames = new Set(); + + // First pass: collect all UDT names so we can resolve cross-references. + for (const entry of spec.entries) { + const kind = entry.switch().name; + if (kind === 'scSpecEntryUdtStructV0') { + const s = entry.value() as xdr.ScSpecUdtStructV0; + udtNames.add(s.name().toString()); + } else if (kind === 'scSpecEntryUdtUnionV0') { + const u = entry.value() as xdr.ScSpecUdtUnionV0; + udtNames.add(u.name().toString()); + } else if (kind === 'scSpecEntryUdtEnumV0') { + const e = entry.value() as xdr.ScSpecUdtEnumV0; + udtNames.add(e.name().toString()); + } else if (kind === 'scSpecEntryUdtErrorEnumV0') { + const e = entry.value() as xdr.ScSpecUdtErrorEnumV0; + udtNames.add(`ErrorEnum(${e.name().toString()})`); + } + } + + // Second pass: build structured representations. + for (const entry of spec.entries) { + const kind = entry.switch().name; + + if (kind === 'scSpecEntryFunctionV0') { + const fn = entry.value() as xdr.ScSpecFunctionV0; + const inputs = fn.inputs() as xdr.ScSpecFunctionInputV0[]; + const outputs = fn.outputs() as xdr.ScSpecTypeDef[]; + + functions.push({ + name: fn.name().toString(), + doc: fn.doc().toString(), + params: Array.from(inputs).map((i) => ({ + name: i.name().toString(), + doc: i.doc().toString(), + type: xdrTypeToTypeScript(i.type(), udtNames), + })), + returnType: + outputs.length > 0 + ? outputs + .map((o) => xdrTypeToTypeScript(o, udtNames)) + .join(' | ') + : 'void', + }); + } else if (kind === 'scSpecEntryUdtStructV0') { + const s = entry.value() as xdr.ScSpecUdtStructV0; + udts.push({ + kind: 'struct', + name: s.name().toString(), + doc: s.doc().toString(), + fields: Array.from(s.fields() as xdr.ScSpecUdtStructFieldV0[]).map( + (f) => ({ + name: f.name().toString(), + doc: f.doc().toString(), + type: xdrTypeToTypeScript(f.type(), udtNames), + }), + ), + }); + } else if (kind === 'scSpecEntryUdtUnionV0') { + const u = entry.value() as xdr.ScSpecUdtUnionV0; + udts.push({ + kind: 'union', + name: u.name().toString(), + doc: u.doc().toString(), + cases: Array.from(u.cases() as xdr.ScSpecUdtUnionCaseV0[]).map( + (c, idx) => { + const caseType = c.type(); + return { + name: c.name().toString(), + doc: c.doc().toString(), + value: idx, + type: caseType + ? xdrTypeToTypeScript(caseType, udtNames) + : undefined, + }; + }, + ), + }); + } else if (kind === 'scSpecEntryUdtEnumV0') { + const e = entry.value() as xdr.ScSpecUdtEnumV0; + udts.push({ + kind: 'enum', + name: e.name().toString(), + doc: e.doc().toString(), + cases: Array.from(e.cases() as xdr.ScSpecUdtEnumCaseV0[]).map( + (c, idx) => ({ + name: c.name().toString(), + doc: c.doc().toString(), + value: idx, + }), + ), + }); + } else if (kind === 'scSpecEntryUdtErrorEnumV0') { + const e = entry.value() as xdr.ScSpecUdtErrorEnumV0; + udts.push({ + kind: 'errorEnum', + name: e.name().toString(), + doc: e.doc().toString(), + cases: Array.from(e.cases() as xdr.ScSpecUdtErrorEnumCaseV0[]).map( + (c, idx) => ({ + name: c.name().toString(), + doc: c.doc().toString(), + value: idx, + }), + ), + }); + } + } + + return { + contractName: contractName ?? 'Contract', + functions, + udts, + }; +} + +// --------------------------------------------------------------------------- +// Code generation +// --------------------------------------------------------------------------- + +/** + * Escape a string for use as a TypeScript/JS doc comment. + */ +function escapeDoc(doc: string, indent: string = ''): string { + if (!doc) return ''; + const lines = doc.split('\n').map((l) => `${indent} * ${l}`); + return [`${indent}/**`, ...lines, `${indent} */`].join('\n'); +} + +/** + * Sanitise a name so it can be used as a TypeScript identifier. + */ +function safeIdent(name: string): string { + return name.replace(/[^a-zA-Z0-9_$]/g, '_').replace(/^(\d)/, '_$1'); +} + +/** + * Generate TypeScript interface definitions for all UDTs. + */ +function generateTypeDefs(parsed: ParsedContractAbi): string { + const lines: string[] = []; + + for (const udt of parsed.udts) { + if (udt.doc) { + lines.push(escapeDoc(udt.doc)); + } + + if (udt.kind === 'struct') { + lines.push(`export interface ${safeIdent(udt.name)} {`); + for (const f of udt.fields ?? []) { + if (f.doc) lines.push(` ${escapeDoc(f.doc, ' ')}`); + lines.push(` ${safeIdent(f.name)}: ${f.type};`); + } + lines.push('}'); + } else if (udt.kind === 'enum' || udt.kind === 'errorEnum') { + lines.push(`export enum ${safeIdent(udt.name)} {`); + for (const c of udt.cases ?? []) { + if (c.doc) lines.push(` ${escapeDoc(c.doc, ' ')}`); + lines.push(` ${safeIdent(c.name)} = ${c.value},`); + } + lines.push('}'); + } else if (udt.kind === 'union') { + // Union → tagged union interface + lines.push( + `export interface ${safeIdent(udt.name)} {`, + ); + lines.push(' tag: string;'); + lines.push(' values?: T;'); + lines.push('}'); + } + lines.push(''); + } + + return lines.join('\n'); +} + +/** + * Generate TypeScript type-safe wrapper functions for each contract entry + * point. The wrappers use `ContractSpec` for native ↔ ScVal conversion and + * accept an `invoke` callback for the actual RPC call. + */ +function generateWrappers( + parsed: ParsedContractAbi, + specBase64: string[], +): string { + const { contractName, functions } = parsed; + const lines: string[] = []; + + // Import + lines.push("import { ContractSpec, xdr } from 'stellar-sdk';"); + lines.push(''); + + // Spec XDR constant + lines.push( + `const _SPEC_ENTRIES: string[] = ${JSON.stringify(specBase64, null, 2)};`, + ); + lines.push(''); + + // Lazy spec instance + lines.push('let _spec: ContractSpec | null = null;'); + lines.push('function _getSpec(): ContractSpec {'); + lines.push(' if (!_spec) _spec = new ContractSpec(_SPEC_ENTRIES);'); + lines.push(' return _spec;'); + lines.push('}'); + lines.push(''); + + // Type for the invoke callback + lines.push( + `export type InvokeCallback = (method: string, scArgs: xdr.ScVal[]) => Promise;`, + ); + lines.push(''); + + // Argument interfaces for each function + for (const fn of functions) { + if (fn.params.length === 0) continue; + const ifaceName = `${safeIdent(fn.name)}Args`; + + if (fn.doc) lines.push(escapeDoc(fn.doc)); + lines.push(`export interface ${ifaceName} {`); + for (const p of fn.params) { + if (p.doc) lines.push(` ${escapeDoc(p.doc, ' ')}`); + lines.push(` ${safeIdent(p.name)}: ${p.type};`); + } + lines.push('}'); + lines.push(''); + } + + // Factory function + const firstFnDoc = functions[0]?.doc; + if (firstFnDoc) lines.push(escapeDoc(firstFnDoc)); + lines.push( + `export function create${safeIdent(contractName)}(contractId: string) {`, + ); + lines.push(' const spec = _getSpec();'); + lines.push(' return {'); + + for (const fn of functions) { + const hasArgs = fn.params.length > 0; + const argsIface = `${safeIdent(fn.name)}Args`; + + if (fn.doc) { + for (const docLine of fn.doc.split('\n')) { + lines.push(` /** ${docLine} */`); + } + } + + lines.push(` async ${safeIdent(fn.name)}(`); + lines.push(` ${hasArgs ? `args: ${argsIface},` : ''}`); + lines.push(` source: string,`); + lines.push( + ` invoke: InvokeCallback,`, + ); + lines.push(` ): Promise<${fn.returnType}> {`); + + if (fn.params.length > 0) { + lines.push( + ` const scArgs = spec.funcArgsToScVals('${fn.name}', args as unknown as Record);`, + ); + } else { + lines.push( + ` const scArgs: xdr.ScVal[] = [];`, + ); + } + + lines.push( + ` const raw = await invoke('${fn.name}', scArgs);`, + ); + + if (fn.returnType === 'void') { + lines.push(' },'); + } else { + lines.push( + ` return spec.funcResToNative('${fn.name}', raw) as ${fn.returnType};`, + ); + } + lines.push(' },'); + } + + lines.push(' };'); + lines.push('}'); + lines.push(''); + + return lines.join('\n'); +} + +/** + * Serialise an array of ScSpecEntry XDR objects to an array of base64 + * strings so they can be embedded in the generated source. + */ +function specEntriesToBase64(entries: xdr.ScSpecEntry[]): string[] { + return entries.map((e) => e.toXDR('base64')); +} + +// --------------------------------------------------------------------------- +// Public API +// --------------------------------------------------------------------------- + +/** + * Generate a complete TypeScript source file that provides type-safe + * bindings for a Soroban contract from its XDR spec entries. + * + * @param specEntries - Array of ScSpecEntry objects or base64-encoded + * ScSpecEntry XDR strings + * @param contractName - Optional contract name used in the factory + * function (defaults to `'Contract'`) + * @returns A TypeScript source string suitable for writing to a .ts file. + * + * The generated code: + * - Defines TypeScript interfaces/enums for all user-defined types (UDTs) + * - Defines argument interfaces for each contract function + * - Exports an `InvokeCallback` type alias (for the RPC call abstraction) + * - Exports a factory function (`createContractName`) that returns an + * object with type-safe async methods for each entry point + * - Uses `ContractSpec` from stellar-sdk for native ↔ ScVal conversion + * - Is strict-mode compatible + * + * @example + * ```ts + * const { entries } = new ContractSpec(specXdrStrings); + * const source = generateBinding(entries, 'Token'); + * fs.writeFileSync('token-binding.ts', source); + * ``` + */ +export function generateBinding( + specEntries: (xdr.ScSpecEntry | string)[], + contractName?: string, +): string { + const parsed = parseAbi(specEntries, contractName); + + let base64: string[]; + if (typeof specEntries[0] === 'string') { + base64 = specEntries as string[]; + } else { + base64 = specEntriesToBase64(specEntries as xdr.ScSpecEntry[]); + } + + const header = `\ +// ============================================================ +// Auto-generated by abi-binding-generator +// Contract: ${parsed.contractName} +// Functions: ${parsed.functions.length} +// UDTs: ${parsed.udts.length} +// ============================================================ +`; + + const typeDefs = generateTypeDefs(parsed); + const wrappers = generateWrappers(parsed, base64); + + return [header, typeDefs, wrappers].join('\n'); +} + +/** + * Convenience: parse spec entries and return only the structured + * representation without generating code. Useful for tooling that + * needs to inspect the ABI. + */ +export function parseContractAbi( + entries: (xdr.ScSpecEntry | string)[], + contractName?: string, +): ParsedContractAbi { + return parseAbi(entries, contractName); +} diff --git a/packages/stellar/src/index.ts b/packages/stellar/src/index.ts index 718f9fe..1f431ee 100644 --- a/packages/stellar/src/index.ts +++ b/packages/stellar/src/index.ts @@ -11,3 +11,4 @@ export * from './soroban-budget-monitor'; export * from './soroban-xdr-deserializer'; export * from './dex-price-feed'; export * from './soroban-ttl-manager'; +export * from './abi-binding-generator';