diff --git a/bin/stellar-js b/bin/stellar-js new file mode 100755 index 000000000..6df4c18d8 --- /dev/null +++ b/bin/stellar-js @@ -0,0 +1,4 @@ +#!/usr/bin/env node + +// Entry point for the stellar CLI +require("../lib/cli/index.js").runCli(); diff --git a/package.json b/package.json index 550e2ffb7..635cd9319 100644 --- a/package.json +++ b/package.json @@ -21,8 +21,12 @@ "files": [ "/types", "/lib", - "/dist" + "/dist", + "/bin" ], + "bin": { + "stellar-js": "./bin/stellar-js" + }, "exports": { ".": { "browser": "./dist/stellar-sdk.min.js", @@ -108,6 +112,7 @@ "fmt": "yarn _prettier && yarn eslint src/ --fix", "preversion": "yarn clean && yarn _prettier && yarn build:prod && yarn test", "prepare": "yarn build:prod", + "download-sac-spec": "node scripts/download-sac-spec.js", "_build": "yarn build:node:all && yarn build:browser:all", "_babel": "babel --extensions '.ts' --out-dir ${OUTPUT_DIR:-lib} src/", "_nyc": "node node_modules/.bin/nyc --nycrc-path config/.nycrc", @@ -197,6 +202,7 @@ "@stellar/stellar-base": "^14.0.2", "axios": "^1.12.2", "bignumber.js": "^9.3.1", + "commander": "^14.0.2", "eventsource": "^2.0.2", "feaxios": "^0.0.23", "randombytes": "^2.1.0", diff --git a/scripts/download-sac-spec.js b/scripts/download-sac-spec.js new file mode 100755 index 000000000..d25fb50f8 --- /dev/null +++ b/scripts/download-sac-spec.js @@ -0,0 +1,82 @@ +#!/usr/bin/env node + +import https from "https"; +import fs from "fs"; +import path from "path"; +import { fileURLToPath } from "url"; + +// Get __dirname equivalent in ESM +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); + +// Get repo root +const REPO_ROOT = path.join(__dirname, ".."); + +// URLs and paths +const REPO_URL = + "https://raw.githubusercontent.com/stellar/stellar-asset-contract-spec/main/stellar-asset-spec.xdr"; +const TS_FILE = path.join(REPO_ROOT, "src/bindings/sac-spec.ts"); + +console.log("Downloading Stellar Asset Contract spec from GitHub..."); + +// Download file +https + .get(REPO_URL, (response) => { + if (response.statusCode !== 200) { + console.error(`✗ Failed to download: HTTP ${response.statusCode}`); + process.exit(1); + } + + const chunks = []; + + response.on("data", (chunk) => { + chunks.push(chunk); + }); + + response.on("end", () => { + const buffer = Buffer.concat(chunks); + + // Verify file has content + if (buffer.length === 0) { + console.error("✗ Downloaded file is empty"); + process.exit(1); + } + + console.log("✓ Successfully downloaded file"); + console.log(` Size: ${buffer.length} bytes`); + + // Convert to base64 + console.log("Converting to base64..."); + const base64Content = buffer.toString("base64"); + + if (!base64Content) { + console.error("✗ Base64 conversion failed"); + process.exit(1); + } + + // Update TypeScript file + console.log(`Updating ${path.relative(REPO_ROOT, TS_FILE)}...`); + const tsContent = [ + "// Auto-generated by scripts/download-sac-spec.sh", + "// Do not edit manually - run the script to update", + `export const SAC_SPEC = "${base64Content}";`, + "", // trailing newline + ].join("\n"); + + try { + fs.writeFileSync(TS_FILE, tsContent, "utf8"); + console.log( + `✓ Successfully updated ${path.relative(REPO_ROOT, TS_FILE)}`, + ); + } catch (error) { + console.error(`✗ Failed to write file: ${error.message}`); + process.exit(1); + } + + console.log("✓ Done"); + }); + }) + .on("error", (error) => { + console.error(`✗ Error: ${error.message}`); + process.exit(1); + }); diff --git a/src/bindings/client.ts b/src/bindings/client.ts new file mode 100644 index 000000000..286298d64 --- /dev/null +++ b/src/bindings/client.ts @@ -0,0 +1,213 @@ +import { xdr } from "@stellar/stellar-base"; +import { Spec } from "../contract"; +import { + parseTypeFromTypeDef, + generateTypeImports, + sanitizeName, + formatJSDocComment, +} from "./utils"; + +/** + * Generates TypeScript client class for contract methods + */ +export class ClientGenerator { + private spec: Spec; + + constructor(spec: Spec) { + this.spec = spec; + } + + /** + * Generate client class + */ + generate(): string { + // Generate constructor method if it exists + let deployMethod = ""; + try { + const constructorFunc = this.spec.getFunc("__constructor"); + deployMethod = this.generateDeployMethod(constructorFunc); + } catch { + // For specs without a constructor, generate a deploy method without params + deployMethod = this.generateDeployMethod(undefined); + } + // Generate interface methods + const interfaceMethods = this.spec + .funcs() + .filter((func) => func.name().toString() !== "__constructor") + .map((func) => this.generateInterfaceMethod(func)) + .join("\n"); + + const imports = this.generateImports(); + + const specEntries = this.spec.entries.map( + (entry) => `"${entry.toXDR("base64")}"`, + ); + + const fromJSON = this.spec + .funcs() + .filter((func) => func.name().toString() !== "__constructor") + .map((func) => this.generateFromJSONMethod(func)) + .join(","); + + return `${imports} + +export interface Client { +${interfaceMethods} +} + +export class Client extends ContractClient { + constructor(public readonly options: ContractClientOptions) { + super( + new Spec([${specEntries.join(", ")}]), + options + ); + } + + ${deployMethod} + public readonly fromJSON = { + ${fromJSON} + }; +}`; + } + + private generateImports(): string { + const { stellarContractImports, typeFileImports, stellarImports } = + generateTypeImports( + this.spec.funcs().flatMap((func) => { + const inputs = func.inputs(); + const outputs = func.outputs(); + const defs = inputs.map((input) => input.type()).concat(outputs); + return defs; + }), + ); + // Ensure necessary imports for Client class + stellarContractImports.push( + "Spec", + "AssembledTransaction", + "Client as ContractClient", + "ClientOptions as ContractClientOptions", + "MethodOptions", + ); + // Build imports + const importLines: string[] = []; + if (typeFileImports.length > 0) { + importLines.push( + `import {\n${typeFileImports.join(",\n")}\n} from './types.js';`, + ); + } + if (stellarContractImports.length > 0) { + importLines.push( + `import {\n${stellarContractImports.join( + ",\n", + )}\n} from '@stellar/stellar-sdk/contract';`, + ); + } + if (stellarImports.length > 0) { + importLines.push( + `import {\n${stellarImports.join( + ",\n", + )}\n} from '@stellar/stellar-sdk';`, + ); + } + importLines.push(`import { Buffer } from 'buffer';`); + const imports = importLines.join("\n"); + return imports; + } + + /** + * Generate interface method signature + */ + private generateInterfaceMethod(func: xdr.ScSpecFunctionV0): string { + const name = sanitizeName(func.name().toString()); + const inputs = func.inputs().map((input: any) => ({ + name: input.name().toString(), + type: parseTypeFromTypeDef(input.type()), + })); + const outputType = + func.outputs().length > 0 + ? parseTypeFromTypeDef(func.outputs()[0]) + : "void"; + const docs = formatJSDocComment(func.doc().toString(), 2); + const params = this.formatMethodParameters(inputs); + + return `${docs} ${name}(${params}): Promise>;`; + } + + private generateFromJSONMethod(func: xdr.ScSpecFunctionV0): string { + const name = func.name().toString(); + const outputType = + func.outputs().length > 0 + ? parseTypeFromTypeDef(func.outputs()[0]) + : "void"; + + return ` ${name} : this.txFromJSON<${outputType}>`; + } + /** + * Generate deploy method + */ + private generateDeployMethod( + constructorFunc: xdr.ScSpecFunctionV0 | undefined, + ): string { + // If no constructor, generate deploy with no params + if (!constructorFunc) { + const params = this.formatConstructorParameters([]); + return ` static deploy(${params}): Promise> { + return ContractClient.deploy(null, options); + }`; + } + const inputs = constructorFunc.inputs().map((input) => ({ + name: input.name().toString(), + type: parseTypeFromTypeDef(input.type()), + })); + + const params = this.formatConstructorParameters(inputs); + const inputsDestructure = + inputs.length > 0 ? `{ ${inputs.map((i) => i.name).join(", ")} }, ` : ""; + + return ` static deploy(${params}): Promise> { + return ContractClient.deploy(${inputsDestructure}options); + }`; + } + + /** + * Format method parameters + */ + private formatMethodParameters( + inputs: Array<{ name: string; type: string }>, + ): string { + const params: string[] = []; + + if (inputs.length > 0) { + const inputsParam = `{ ${inputs.map((i) => `${i.name}: ${i.type}`).join("; ")} }`; + params.push( + `{ ${inputs.map((i) => i.name).join(", ")} }: ${inputsParam}`, + ); + } + + params.push("options?: MethodOptions"); + + return params.join(", "); + } + + /** + * Format constructor parameters + */ + private formatConstructorParameters( + inputs: Array<{ name: string; type: string }>, + ): string { + const params: string[] = []; + + if (inputs.length > 0) { + const inputsParam = `{ ${inputs.map((i) => `${i.name}: ${i.type}`).join("; ")} }`; + params.push( + `{ ${inputs.map((i) => i.name).join(", ")} }: ${inputsParam}`, + ); + } + + params.push( + 'options: MethodOptions & Omit & { wasmHash: Buffer | string; salt?: Buffer | Uint8Array; format?: "hex" | "base64"; address?: string; }', + ); + + return params.join(", "); + } +} diff --git a/src/bindings/config.ts b/src/bindings/config.ts new file mode 100644 index 000000000..1f6bb8d95 --- /dev/null +++ b/src/bindings/config.ts @@ -0,0 +1,190 @@ +import packageJson from "../../package.json"; + +export interface ConfigGenerateOptions { + contractName: string; + contractAddress?: string; + rpcUrl?: string; + networkPassphrase?: string; +} + +export interface Configs { + packageJson: string; + tsConfig: string; + gitignore: string; + readme: string; +} + +/** + * Generates a complete TypeScript project structure with contract bindings + */ +export class ConfigGenerator { + /** + * Generate the complete TypeScript project + */ + generate(options: ConfigGenerateOptions): Configs { + const { contractName, contractAddress, rpcUrl, networkPassphrase } = + options; + + // Generate all project files + return { + packageJson: this.generatePackageJson(contractName), + tsConfig: this.generateTsConfig(), + gitignore: this.generateGitignore(), + readme: this.generateReadme( + contractName, + contractAddress, + rpcUrl, + networkPassphrase, + ), + }; + } + + /** + * Generate package.json + */ + private generatePackageJson(contractName: string): string { + const generatedPackageJson = { + name: contractName.toLowerCase().replace(/[^a-z0-9-]/g, "-"), + version: "0.0.1", + description: `Generated TypeScript bindings for ${contractName} Stellar contract`, + type: "module", + main: "dist/index.js", + types: "dist/index.d.ts", + scripts: { + build: "tsc", + }, + dependencies: { + "@stellar/stellar-sdk": `^${packageJson.version}`, + buffer: "6.0.3", + }, + devDependencies: { + typescript: "^5.6.3", + }, + }; + + return JSON.stringify(generatedPackageJson, null, 2); + } + + /** + * Generate tsconfig.json + */ + private generateTsConfig(): string { + const tsConfig = { + compilerOptions: { + target: "ESNext", + module: "NodeNext", + moduleResolution: "nodenext", + declaration: true, + outDir: "./dist", + strictNullChecks: true, + skipLibCheck: true, + }, + include: ["src/*"], + }; + + return JSON.stringify(tsConfig, null, 2); + } + + /** + * Generate .gitignore + */ + private generateGitignore(): string { + const gitignore = [ + "# Dependencies", + "node_modules/", + "", + "# Build outputs", + "dist/", + "*.tgz", + "", + "# IDE", + ".vscode/", + ".idea/", + "", + "# OS", + ".DS_Store", + "Thumbs.db", + "", + "# Logs", + "*.log", + "npm-debug.log*", + "", + "# Runtime data", + "*.pid", + "*.seed", + ].join("\n"); + + return gitignore; + } + + /** + * Generate README.md + */ + private generateReadme( + contractName: string, + contractAddress?: string, + rpcUrl?: string, + networkPassphrase?: string, + ): string { + const readme = [ + `# ${contractName} Contract Bindings`, + "", + `TypeScript bindings for the ${contractName} Stellar smart contract.`, + "", + "## Installation", + "", + "```bash", + "npm install", + "```", + "", + "## Build", + "", + "```bash", + "npm run build", + "```", + "", + "## Usage", + "", + "```typescript", + 'import { Client } from "./src";', + "", + "const client = new Client({", + contractAddress + ? ` contractId: "${contractAddress}",` + : ' contractId: "YOUR_CONTRACT_ID",', + rpcUrl + ? ` rpcUrl: "${rpcUrl}",` + : ' rpcUrl: "https://soroban-testnet.stellar.org:443",', + networkPassphrase + ? ` networkPassphrase: "${networkPassphrase}",` + : ' networkPassphrase: "Test SDF Network ; September 2015",', + "});", + "", + "// Call contract methods", + "// const result = await client.methodName();", + "```", + "", + "## Contract Information", + "", + contractAddress + ? `**Contract Address:** \`${contractAddress}\`` + : "**Contract Address:** _Not embedded_", + rpcUrl ? `**RPC URL:** \`${rpcUrl}\`` : "**RPC URL:** _Not embedded_", + networkPassphrase + ? `**Network:** \`${networkPassphrase}\`` + : "**Network:** _Not embedded_", + "", + "## Generated Files", + "", + "- `src/index.ts` - Entry point exporting the Client", + "- `src/types.ts` - Type definitions for contract structs, enums, and unions", + "- `src/contract.ts` - Client implementation", + "- `tsconfig.json` - TypeScript configuration", + "- `package.json` - NPM package configuration", + "", + "This package was generated using the Js-Stellar-SDK contract binding generator.", + ].join("\n"); + + return readme; + } +} diff --git a/src/bindings/generator.ts b/src/bindings/generator.ts new file mode 100644 index 000000000..00bad6dea --- /dev/null +++ b/src/bindings/generator.ts @@ -0,0 +1,149 @@ +import { Spec } from "../contract"; +import { ConfigGenerator } from "./config"; +import { TypeGenerator } from "./types"; +import { ClientGenerator } from "./client"; +import { specFromWasm } from "../contract/wasm_spec_parser"; + +// import { format } from "prettier/standalone"; +// import * as prettierPluginTypeScript from "prettier/plugins/typescript"; +// import * as prettierPluginEstree from "prettier/plugins/estree"; +import { fetchFromContractId, fetchFromWasmHash } from "./wasm_fetcher"; +import { SAC_SPEC } from "./sac-spec"; +// import { Options } from "prettier"; + +export type GenerateOptions = { + contractName: string; + contractAddress?: string; + rpcUrl?: string; + networkPassphrase?: string; +}; + +/** + * BindingGenerator generates TypeScript bindings from a Spec or WASM + * + * @property index - export file content + * @property types - contains the contract types and interfaces + * @property client - contains the client code for interacting with the contract + * @property packageJson - package.json content + * @property tsConfig - tsconfig.json content + * @property readme - README.md content + * @property gitignore - .gitignore content + */ +export type GeneratedBindings = { + index: string; + types: string; + client: string; + packageJson: string; + tsConfig: string; + readme: string; + gitignore: string; +}; + +/** + * Generates TypeScript bindings for Stellar contracts + */ +export class BindingGenerator { + private spec: Spec; + + /** + * Generates TypeScript bindings for Stellar contracts + * + * @param spec - The contract specification + */ + private constructor(spec: Spec) { + this.spec = spec; + } + + /** + * Create a generator from a Spec object + */ + static fromSpec(spec: Spec): BindingGenerator { + return new BindingGenerator(spec); + } + + /** + * Create a generator from a WASM buffer + */ + static async fromWasm(wasmBuffer: Buffer): Promise { + const spec = new Spec(await specFromWasm(wasmBuffer)); + return new BindingGenerator(spec); + } + + /** + * Create a generator by fetching WASM from a hash + */ + static async fromWasmHash( + wasmHash: string, + rpcUrl: string, + networkPassphrase: string, + ): Promise { + const wasm = await fetchFromWasmHash(wasmHash, rpcUrl, networkPassphrase); + if (wasm.contract.type !== "wasm") { + throw new Error("Fetched contract is not of type 'wasm'"); + } + return BindingGenerator.fromWasm(wasm.contract.wasmBytes); + } + + /** + * Create a generator by fetching WASM from a contract ID + */ + static async fromContractId( + contractId: string, + rpcUrl: string, + networkPassphrase: string, + ): Promise { + const wasm = await fetchFromContractId( + contractId, + rpcUrl, + networkPassphrase, + ); + if (wasm.contract.type === "wasm") { + return BindingGenerator.fromWasm(wasm.contract.wasmBytes); + } + // If it's not a wasm contract, assume it's SAC + const spec = new Spec(SAC_SPEC); + return BindingGenerator.fromSpec(spec); + } + + /** + * Generate TypeScript bindings (returns strings, does not write to disk) + */ + generate(options: GenerateOptions): GeneratedBindings { + this.validateOptions(options); + + // Generate type and client code using template strings (no ts-morph!) + const typeGenerator = new TypeGenerator(this.spec); + const clientGenerator = new ClientGenerator(this.spec); + + const types = typeGenerator.generate(); + const client = clientGenerator.generate(); + + const index = `export { Client } from "./client.js";`; + if (types !== "") { + index.concat(`\nexport * from "./types.js";`); + } + + // Generate config files + const configGenerator = new ConfigGenerator(); + const configs = configGenerator.generate(options); + + return { + index: index, + types: types, + client: client, + packageJson: configs.packageJson, + tsConfig: configs.tsConfig, + readme: configs.readme, + gitignore: configs.gitignore, + }; + } + + /** + * Validate generation options + */ + private validateOptions(options: GenerateOptions): void { + if (!options.contractName || options.contractName.trim() === "") { + throw new Error("contractName is required and cannot be empty"); + } + } +} diff --git a/src/bindings/index.ts b/src/bindings/index.ts new file mode 100644 index 000000000..78d4cc501 --- /dev/null +++ b/src/bindings/index.ts @@ -0,0 +1,6 @@ +export * from "./generator"; +export * from "./config"; +export * from "./types"; +export * from "./wasm_fetcher"; +export * from "./client"; +export { SAC_SPEC } from "./sac-spec"; diff --git a/src/bindings/sac-spec.ts b/src/bindings/sac-spec.ts new file mode 100644 index 000000000..e912d9ea5 --- /dev/null +++ b/src/bindings/sac-spec.ts @@ -0,0 +1,4 @@ +// Auto-generated by scripts/download-sac-spec.sh +// Do not edit manually - run the script to update +export const SAC_SPEC = + "AAAAAAAAAYpSZXR1cm5zIHRoZSBhbGxvd2FuY2UgZm9yIGBzcGVuZGVyYCB0byB0cmFuc2ZlciBmcm9tIGBmcm9tYC4KClRoZSBhbW91bnQgcmV0dXJuZWQgaXMgdGhlIGFtb3VudCB0aGF0IHNwZW5kZXIgaXMgYWxsb3dlZCB0byB0cmFuc2ZlcgpvdXQgb2YgZnJvbSdzIGJhbGFuY2UuIFdoZW4gdGhlIHNwZW5kZXIgdHJhbnNmZXJzIGFtb3VudHMsIHRoZSBhbGxvd2FuY2UKd2lsbCBiZSByZWR1Y2VkIGJ5IHRoZSBhbW91bnQgdHJhbnNmZXJyZWQuCgojIEFyZ3VtZW50cwoKKiBgZnJvbWAgLSBUaGUgYWRkcmVzcyBob2xkaW5nIHRoZSBiYWxhbmNlIG9mIHRva2VucyB0byBiZSBkcmF3biBmcm9tLgoqIGBzcGVuZGVyYCAtIFRoZSBhZGRyZXNzIHNwZW5kaW5nIHRoZSB0b2tlbnMgaGVsZCBieSBgZnJvbWAuAAAAAAAJYWxsb3dhbmNlAAAAAAAAAgAAAAAAAAAEZnJvbQAAABMAAAAAAAAAB3NwZW5kZXIAAAAAEwAAAAEAAAALAAAAAAAAAIlSZXR1cm5zIHRydWUgaWYgYGlkYCBpcyBhdXRob3JpemVkIHRvIHVzZSBpdHMgYmFsYW5jZS4KCiMgQXJndW1lbnRzCgoqIGBpZGAgLSBUaGUgYWRkcmVzcyBmb3Igd2hpY2ggdG9rZW4gYXV0aG9yaXphdGlvbiBpcyBiZWluZyBjaGVja2VkLgAAAAAAAAphdXRob3JpemVkAAAAAAABAAAAAAAAAAJpZAAAAAAAEwAAAAEAAAABAAAAAAAAA59TZXQgdGhlIGFsbG93YW5jZSBieSBgYW1vdW50YCBmb3IgYHNwZW5kZXJgIHRvIHRyYW5zZmVyL2J1cm4gZnJvbQpgZnJvbWAuCgpUaGUgYW1vdW50IHNldCBpcyB0aGUgYW1vdW50IHRoYXQgc3BlbmRlciBpcyBhcHByb3ZlZCB0byB0cmFuc2ZlciBvdXQgb2YKZnJvbSdzIGJhbGFuY2UuIFRoZSBzcGVuZGVyIHdpbGwgYmUgYWxsb3dlZCB0byB0cmFuc2ZlciBhbW91bnRzLCBhbmQKd2hlbiBhbiBhbW91bnQgaXMgdHJhbnNmZXJyZWQgdGhlIGFsbG93YW5jZSB3aWxsIGJlIHJlZHVjZWQgYnkgdGhlCmFtb3VudCB0cmFuc2ZlcnJlZC4KCiMgQXJndW1lbnRzCgoqIGBmcm9tYCAtIFRoZSBhZGRyZXNzIGhvbGRpbmcgdGhlIGJhbGFuY2Ugb2YgdG9rZW5zIHRvIGJlIGRyYXduIGZyb20uCiogYHNwZW5kZXJgIC0gVGhlIGFkZHJlc3MgYmVpbmcgYXV0aG9yaXplZCB0byBzcGVuZCB0aGUgdG9rZW5zIGhlbGQgYnkKYGZyb21gLgoqIGBhbW91bnRgIC0gVGhlIHRva2VucyB0byBiZSBtYWRlIGF2YWlsYWJsZSB0byBgc3BlbmRlcmAuCiogYGV4cGlyYXRpb25fbGVkZ2VyYCAtIFRoZSBsZWRnZXIgbnVtYmVyIHdoZXJlIHRoaXMgYWxsb3dhbmNlIGV4cGlyZXMuIENhbm5vdApiZSBsZXNzIHRoYW4gdGhlIGN1cnJlbnQgbGVkZ2VyIG51bWJlciB1bmxlc3MgdGhlIGFtb3VudCBpcyBiZWluZyBzZXQgdG8gMC4KQW4gZXhwaXJlZCBlbnRyeSAod2hlcmUgZXhwaXJhdGlvbl9sZWRnZXIgPCB0aGUgY3VycmVudCBsZWRnZXIgbnVtYmVyKQpzaG91bGQgYmUgdHJlYXRlZCBhcyBhIDAgYW1vdW50IGFsbG93YW5jZS4KCiMgRXZlbnRzCgpFbWl0cyBhbiBldmVudCB3aXRoIHRvcGljcyBgWyJhcHByb3ZlIiwgZnJvbTogQWRkcmVzcywKc3BlbmRlcjogQWRkcmVzc10sIGRhdGEgPSBbYW1vdW50OiBpMTI4LCBleHBpcmF0aW9uX2xlZGdlcjogdTMyXWAAAAAAB2FwcHJvdmUAAAAABAAAAAAAAAAEZnJvbQAAABMAAAAAAAAAB3NwZW5kZXIAAAAAEwAAAAAAAAAGYW1vdW50AAAAAAALAAAAAAAAABFleHBpcmF0aW9uX2xlZGdlcgAAAAAAAAQAAAAAAAAAAAAAAJhSZXR1cm5zIHRoZSBiYWxhbmNlIG9mIGBpZGAuCgojIEFyZ3VtZW50cwoKKiBgaWRgIC0gVGhlIGFkZHJlc3MgZm9yIHdoaWNoIGEgYmFsYW5jZSBpcyBiZWluZyBxdWVyaWVkLiBJZiB0aGUKYWRkcmVzcyBoYXMgbm8gZXhpc3RpbmcgYmFsYW5jZSwgcmV0dXJucyAwLgAAAAdiYWxhbmNlAAAAAAEAAAAAAAAAAmlkAAAAAAATAAAAAQAAAAsAAAAAAAABYkJ1cm4gYGFtb3VudGAgZnJvbSBgZnJvbWAuCgpSZWR1Y2VzIGZyb20ncyBiYWxhbmNlIGJ5IHRoZSBhbW91bnQsIHdpdGhvdXQgdHJhbnNmZXJyaW5nIHRoZSBiYWxhbmNlCnRvIGFub3RoZXIgaG9sZGVyJ3MgYmFsYW5jZS4KCiMgQXJndW1lbnRzCgoqIGBmcm9tYCAtIFRoZSBhZGRyZXNzIGhvbGRpbmcgdGhlIGJhbGFuY2Ugb2YgdG9rZW5zIHdoaWNoIHdpbGwgYmUKYnVybmVkIGZyb20uCiogYGFtb3VudGAgLSBUaGUgYW1vdW50IG9mIHRva2VucyB0byBiZSBidXJuZWQuCgojIEV2ZW50cwoKRW1pdHMgYW4gZXZlbnQgd2l0aCB0b3BpY3MgYFsiYnVybiIsIGZyb206IEFkZHJlc3NdLCBkYXRhID0gYW1vdW50OgppMTI4YAAAAAAABGJ1cm4AAAACAAAAAAAAAARmcm9tAAAAEwAAAAAAAAAGYW1vdW50AAAAAAALAAAAAAAAAAAAAALaQnVybiBgYW1vdW50YCBmcm9tIGBmcm9tYCwgY29uc3VtaW5nIHRoZSBhbGxvd2FuY2Ugb2YgYHNwZW5kZXJgLgoKUmVkdWNlcyBmcm9tJ3MgYmFsYW5jZSBieSB0aGUgYW1vdW50LCB3aXRob3V0IHRyYW5zZmVycmluZyB0aGUgYmFsYW5jZQp0byBhbm90aGVyIGhvbGRlcidzIGJhbGFuY2UuCgpUaGUgc3BlbmRlciB3aWxsIGJlIGFsbG93ZWQgdG8gYnVybiB0aGUgYW1vdW50IGZyb20gZnJvbSdzIGJhbGFuY2UsIGlmCnRoZSBhbW91bnQgaXMgbGVzcyB0aGFuIG9yIGVxdWFsIHRvIHRoZSBhbGxvd2FuY2UgdGhhdCB0aGUgc3BlbmRlciBoYXMKb24gdGhlIGZyb20ncyBiYWxhbmNlLiBUaGUgc3BlbmRlcidzIGFsbG93YW5jZSBvbiBmcm9tJ3MgYmFsYW5jZSB3aWxsIGJlCnJlZHVjZWQgYnkgdGhlIGFtb3VudC4KCiMgQXJndW1lbnRzCgoqIGBzcGVuZGVyYCAtIFRoZSBhZGRyZXNzIGF1dGhvcml6aW5nIHRoZSBidXJuLCBhbmQgaGF2aW5nIGl0cyBhbGxvd2FuY2UKY29uc3VtZWQgZHVyaW5nIHRoZSBidXJuLgoqIGBmcm9tYCAtIFRoZSBhZGRyZXNzIGhvbGRpbmcgdGhlIGJhbGFuY2Ugb2YgdG9rZW5zIHdoaWNoIHdpbGwgYmUKYnVybmVkIGZyb20uCiogYGFtb3VudGAgLSBUaGUgYW1vdW50IG9mIHRva2VucyB0byBiZSBidXJuZWQuCgojIEV2ZW50cwoKRW1pdHMgYW4gZXZlbnQgd2l0aCB0b3BpY3MgYFsiYnVybiIsIGZyb206IEFkZHJlc3NdLCBkYXRhID0gYW1vdW50OgppMTI4YAAAAAAACWJ1cm5fZnJvbQAAAAAAAAMAAAAAAAAAB3NwZW5kZXIAAAAAEwAAAAAAAAAEZnJvbQAAABMAAAAAAAAABmFtb3VudAAAAAAACwAAAAAAAAAAAAABUUNsYXdiYWNrIGBhbW91bnRgIGZyb20gYGZyb21gIGFjY291bnQuIGBhbW91bnRgIGlzIGJ1cm5lZCBpbiB0aGUKY2xhd2JhY2sgcHJvY2Vzcy4KCiMgQXJndW1lbnRzCgoqIGBmcm9tYCAtIFRoZSBhZGRyZXNzIGhvbGRpbmcgdGhlIGJhbGFuY2UgZnJvbSB3aGljaCB0aGUgY2xhd2JhY2sgd2lsbAp0YWtlIHRva2Vucy4KKiBgYW1vdW50YCAtIFRoZSBhbW91bnQgb2YgdG9rZW5zIHRvIGJlIGNsYXdlZCBiYWNrLgoKIyBFdmVudHMKCkVtaXRzIGFuIGV2ZW50IHdpdGggdG9waWNzIGBbImNsYXdiYWNrIiwgYWRtaW46IEFkZHJlc3MsIHRvOiBBZGRyZXNzXSwKZGF0YSA9IGFtb3VudDogaTEyOGAAAAAAAAAIY2xhd2JhY2sAAAACAAAAAAAAAARmcm9tAAAAEwAAAAAAAAAGYW1vdW50AAAAAAALAAAAAAAAAAAAAACAUmV0dXJucyB0aGUgbnVtYmVyIG9mIGRlY2ltYWxzIHVzZWQgdG8gcmVwcmVzZW50IGFtb3VudHMgb2YgdGhpcyB0b2tlbi4KCiMgUGFuaWNzCgpJZiB0aGUgY29udHJhY3QgaGFzIG5vdCB5ZXQgYmVlbiBpbml0aWFsaXplZC4AAAAIZGVjaW1hbHMAAAAAAAAAAQAAAAQAAAAAAAAA801pbnRzIGBhbW91bnRgIHRvIGB0b2AuCgojIEFyZ3VtZW50cwoKKiBgdG9gIC0gVGhlIGFkZHJlc3Mgd2hpY2ggd2lsbCByZWNlaXZlIHRoZSBtaW50ZWQgdG9rZW5zLgoqIGBhbW91bnRgIC0gVGhlIGFtb3VudCBvZiB0b2tlbnMgdG8gYmUgbWludGVkLgoKIyBFdmVudHMKCkVtaXRzIGFuIGV2ZW50IHdpdGggdG9waWNzIGBbIm1pbnQiLCBhZG1pbjogQWRkcmVzcywgdG86IEFkZHJlc3NdLCBkYXRhCj0gYW1vdW50OiBpMTI4YAAAAAAEbWludAAAAAIAAAAAAAAAAnRvAAAAAAATAAAAAAAAAAZhbW91bnQAAAAAAAsAAAAAAAAAAAAAAFlSZXR1cm5zIHRoZSBuYW1lIGZvciB0aGlzIHRva2VuLgoKIyBQYW5pY3MKCklmIHRoZSBjb250cmFjdCBoYXMgbm90IHlldCBiZWVuIGluaXRpYWxpemVkLgAAAAAAAARuYW1lAAAAAAAAAAEAAAAQAAAAAAAAAQxTZXRzIHRoZSBhZG1pbmlzdHJhdG9yIHRvIHRoZSBzcGVjaWZpZWQgYWRkcmVzcyBgbmV3X2FkbWluYC4KCiMgQXJndW1lbnRzCgoqIGBuZXdfYWRtaW5gIC0gVGhlIGFkZHJlc3Mgd2hpY2ggd2lsbCBoZW5jZWZvcnRoIGJlIHRoZSBhZG1pbmlzdHJhdG9yCm9mIHRoaXMgdG9rZW4gY29udHJhY3QuCgojIEV2ZW50cwoKRW1pdHMgYW4gZXZlbnQgd2l0aCB0b3BpY3MgYFsic2V0X2FkbWluIiwgYWRtaW46IEFkZHJlc3NdLCBkYXRhID0KW25ld19hZG1pbjogQWRkcmVzc11gAAAACXNldF9hZG1pbgAAAAAAAAEAAAAAAAAACW5ld19hZG1pbgAAAAAAABMAAAAAAAAAAAAAAEZSZXR1cm5zIHRoZSBhZG1pbiBvZiB0aGUgY29udHJhY3QuCgojIFBhbmljcwoKSWYgdGhlIGFkbWluIGlzIG5vdCBzZXQuAAAAAAAFYWRtaW4AAAAAAAAAAAAAAQAAABMAAAAAAAABUFNldHMgd2hldGhlciB0aGUgYWNjb3VudCBpcyBhdXRob3JpemVkIHRvIHVzZSBpdHMgYmFsYW5jZS4gSWYKYGF1dGhvcml6ZWRgIGlzIHRydWUsIGBpZGAgc2hvdWxkIGJlIGFibGUgdG8gdXNlIGl0cyBiYWxhbmNlLgoKIyBBcmd1bWVudHMKCiogYGlkYCAtIFRoZSBhZGRyZXNzIGJlaW5nIChkZS0pYXV0aG9yaXplZC4KKiBgYXV0aG9yaXplYCAtIFdoZXRoZXIgb3Igbm90IGBpZGAgY2FuIHVzZSBpdHMgYmFsYW5jZS4KCiMgRXZlbnRzCgpFbWl0cyBhbiBldmVudCB3aXRoIHRvcGljcyBgWyJzZXRfYXV0aG9yaXplZCIsIGlkOiBBZGRyZXNzXSwgZGF0YSA9ClthdXRob3JpemU6IGJvb2xdYAAAAA5zZXRfYXV0aG9yaXplZAAAAAAAAgAAAAAAAAACaWQAAAAAABMAAAAAAAAACWF1dGhvcml6ZQAAAAAAAAEAAAAAAAAAAAAAAFtSZXR1cm5zIHRoZSBzeW1ib2wgZm9yIHRoaXMgdG9rZW4uCgojIFBhbmljcwoKSWYgdGhlIGNvbnRyYWN0IGhhcyBub3QgeWV0IGJlZW4gaW5pdGlhbGl6ZWQuAAAAAAZzeW1ib2wAAAAAAAAAAAABAAAAEAAAAAAAAAFiVHJhbnNmZXIgYGFtb3VudGAgZnJvbSBgZnJvbWAgdG8gYHRvYC4KCiMgQXJndW1lbnRzCgoqIGBmcm9tYCAtIFRoZSBhZGRyZXNzIGhvbGRpbmcgdGhlIGJhbGFuY2Ugb2YgdG9rZW5zIHdoaWNoIHdpbGwgYmUKd2l0aGRyYXduIGZyb20uCiogYHRvYCAtIFRoZSBhZGRyZXNzIHdoaWNoIHdpbGwgcmVjZWl2ZSB0aGUgdHJhbnNmZXJyZWQgdG9rZW5zLgoqIGBhbW91bnRgIC0gVGhlIGFtb3VudCBvZiB0b2tlbnMgdG8gYmUgdHJhbnNmZXJyZWQuCgojIEV2ZW50cwoKRW1pdHMgYW4gZXZlbnQgd2l0aCB0b3BpY3MgYFsidHJhbnNmZXIiLCBmcm9tOiBBZGRyZXNzLCB0bzogQWRkcmVzc10sCmRhdGEgPSBhbW91bnQ6IGkxMjhgAAAAAAAIdHJhbnNmZXIAAAADAAAAAAAAAARmcm9tAAAAEwAAAAAAAAACdG8AAAAAABMAAAAAAAAABmFtb3VudAAAAAAACwAAAAAAAAAAAAADMVRyYW5zZmVyIGBhbW91bnRgIGZyb20gYGZyb21gIHRvIGB0b2AsIGNvbnN1bWluZyB0aGUgYWxsb3dhbmNlIHRoYXQKYHNwZW5kZXJgIGhhcyBvbiBgZnJvbWAncyBiYWxhbmNlLiBBdXRob3JpemVkIGJ5IHNwZW5kZXIKKGBzcGVuZGVyLnJlcXVpcmVfYXV0aCgpYCkuCgpUaGUgc3BlbmRlciB3aWxsIGJlIGFsbG93ZWQgdG8gdHJhbnNmZXIgdGhlIGFtb3VudCBmcm9tIGZyb20ncyBiYWxhbmNlCmlmIHRoZSBhbW91bnQgaXMgbGVzcyB0aGFuIG9yIGVxdWFsIHRvIHRoZSBhbGxvd2FuY2UgdGhhdCB0aGUgc3BlbmRlcgpoYXMgb24gdGhlIGZyb20ncyBiYWxhbmNlLiBUaGUgc3BlbmRlcidzIGFsbG93YW5jZSBvbiBmcm9tJ3MgYmFsYW5jZQp3aWxsIGJlIHJlZHVjZWQgYnkgdGhlIGFtb3VudC4KCiMgQXJndW1lbnRzCgoqIGBzcGVuZGVyYCAtIFRoZSBhZGRyZXNzIGF1dGhvcml6aW5nIHRoZSB0cmFuc2ZlciwgYW5kIGhhdmluZyBpdHMKYWxsb3dhbmNlIGNvbnN1bWVkIGR1cmluZyB0aGUgdHJhbnNmZXIuCiogYGZyb21gIC0gVGhlIGFkZHJlc3MgaG9sZGluZyB0aGUgYmFsYW5jZSBvZiB0b2tlbnMgd2hpY2ggd2lsbCBiZQp3aXRoZHJhd24gZnJvbS4KKiBgdG9gIC0gVGhlIGFkZHJlc3Mgd2hpY2ggd2lsbCByZWNlaXZlIHRoZSB0cmFuc2ZlcnJlZCB0b2tlbnMuCiogYGFtb3VudGAgLSBUaGUgYW1vdW50IG9mIHRva2VucyB0byBiZSB0cmFuc2ZlcnJlZC4KCiMgRXZlbnRzCgpFbWl0cyBhbiBldmVudCB3aXRoIHRvcGljcyBgWyJ0cmFuc2ZlciIsIGZyb206IEFkZHJlc3MsIHRvOiBBZGRyZXNzXSwKZGF0YSA9IGFtb3VudDogaTEyOGAAAAAAAAANdHJhbnNmZXJfZnJvbQAAAAAAAAQAAAAAAAAAB3NwZW5kZXIAAAAAEwAAAAAAAAAEZnJvbQAAABMAAAAAAAAAAnRvAAAAAAATAAAAAAAAAAZhbW91bnQAAAAAAAsAAAAAAAAABQAAAAAAAAAAAAAAB0FwcHJvdmUAAAAAAQAAAAdhcHByb3ZlAAAAAAQAAAAAAAAABGZyb20AAAATAAAAAQAAAAAAAAAHc3BlbmRlcgAAAAATAAAAAQAAAAAAAAAGYW1vdW50AAAAAAALAAAAAAAAAAAAAAARZXhwaXJhdGlvbl9sZWRnZXIAAAAAAAAEAAAAAAAAAAEAAAAFAAAAAAAAAAAAAAAIVHJhbnNmZXIAAAABAAAACHRyYW5zZmVyAAAAAwAAAAAAAAAEZnJvbQAAABMAAAABAAAAAAAAAAJ0bwAAAAAAEwAAAAEAAAAAAAAABmFtb3VudAAAAAAACwAAAAAAAAAAAAAABQAAAAAAAAAAAAAADVRyYW5zZmVyTXV4ZWQAAAAAAAABAAAACHRyYW5zZmVyAAAABAAAAAAAAAAEZnJvbQAAABMAAAABAAAAAAAAAAJ0bwAAAAAAEwAAAAEAAAAAAAAAC3RvX211eGVkX2lkAAAAAAQAAAAAAAAAAAAAAAZhbW91bnQAAAAAAAsAAAAAAAAAAgAAAAUAAAAAAAAAAAAAAARCdXJuAAAAAQAAAARidXJuAAAAAgAAAAAAAAAEZnJvbQAAABMAAAABAAAAAAAAAAZhbW91bnQAAAAAAAsAAAAAAAAAAAAAAAUAAAAAAAAAAAAAAARNaW50AAAAAQAAAARtaW50AAAAAgAAAAAAAAACdG8AAAAAABMAAAABAAAAAAAAAAZhbW91bnQAAAAAAAsAAAAAAAAAAAAAAAUAAAAAAAAAAAAAAAhDbGF3YmFjawAAAAEAAAAIY2xhd2JhY2sAAAACAAAAAAAAAARmcm9tAAAAEwAAAAEAAAAAAAAABmFtb3VudAAAAAAACwAAAAAAAAAAAAAABQAAAAAAAAAAAAAACFNldEFkbWluAAAAAQAAAAlzZXRfYWRtaW4AAAAAAAABAAAAAAAAAAluZXdfYWRtaW4AAAAAAAATAAAAAAAAAAAAAAAFAAAAAAAAAAAAAAANU2V0QXV0aG9yaXplZAAAAAAAAAEAAAAOc2V0X2F1dGhvcml6ZWQAAAAAAAIAAAAAAAAAAmlkAAAAAAATAAAAAQAAAAAAAAAJYXV0aG9yaXplAAAAAAAAAQAAAAAAAAAA"; diff --git a/src/bindings/types.ts b/src/bindings/types.ts new file mode 100644 index 000000000..075b654f7 --- /dev/null +++ b/src/bindings/types.ts @@ -0,0 +1,274 @@ +import { xdr } from "@stellar/stellar-base"; +import { Spec } from "../contract"; +import { + parseTypeFromTypeDef, + generateTypeImports, + sanitizeName, + formatJSDocComment, +} from "./utils"; + +/** + * Interface for struct fields + */ +export interface StructField { + doc: string; + name: string; + type: string; +} + +/** + * Interface for union cases + */ +export interface UnionCase { + doc: string; + name: string; + types: string[]; +} + +/** + * Interface for enum cases + */ +export interface EnumCase { + doc: string; + name: string; + value: number; +} + +/** + * Generates TypeScript type definitions from Stellar contract specs + */ +export class TypeGenerator { + private spec: Spec; + + constructor(spec: Spec) { + this.spec = spec; + } + + /** + * Generate all TypeScript type definitions + */ + generate(): string { + // Generate types for each entry in the spec + const types = this.spec.entries + .map((entry) => this.generateEntry(entry)) + .filter((t) => t) + .join("\n\n"); + // Generate imports for all types + const imports = this.generateImports(); + + return `${imports} + + ${types} + `; + } + + /** + * Generate TypeScript for a single spec entry + */ + private generateEntry(entry: xdr.ScSpecEntry): string | null { + switch (entry.switch()) { + case xdr.ScSpecEntryKind.scSpecEntryUdtStructV0(): + return this.generateStruct(entry.udtStructV0()); + case xdr.ScSpecEntryKind.scSpecEntryUdtUnionV0(): + return this.generateUnion(entry.udtUnionV0()); + case xdr.ScSpecEntryKind.scSpecEntryUdtEnumV0(): + return this.generateEnum(entry.udtEnumV0()); + case xdr.ScSpecEntryKind.scSpecEntryUdtErrorEnumV0(): + return this.generateErrorEnum(entry.udtErrorEnumV0()); + default: + return null; + } + } + + private generateImports(): string { + let imports = generateTypeImports( + this.spec.entries.flatMap((entry) => { + switch (entry.switch()) { + case xdr.ScSpecEntryKind.scSpecEntryUdtStructV0(): + return entry + .udtStructV0() + .fields() + .map((field) => field.type()); + case xdr.ScSpecEntryKind.scSpecEntryUdtUnionV0(): + return entry + .udtUnionV0() + .cases() + .flatMap((unionCase) => { + if ( + unionCase.switch() === + xdr.ScSpecUdtUnionCaseV0Kind.scSpecUdtUnionCaseTupleV0() + ) { + return unionCase.tupleCase().type(); + } + return []; + }); + case xdr.ScSpecEntryKind.scSpecEntryUdtEnumV0(): + // Enums do not have associated types + return []; + case xdr.ScSpecEntryKind.scSpecEntryUdtErrorEnumV0(): + // Enums do not have associated types + return []; + default: + return []; + } + }), + ); + + const importLines: string[] = []; + + if (imports.needsBufferImport) { + importLines.push(`import { Buffer } from 'buffer';`); + } + if (imports.stellarContractImports.length > 0) { + importLines.push( + `import {\n${imports.stellarContractImports.join(",\n")}\n} from '@stellar/stellar-sdk';`, + ); + } + if (imports.stellarImports.length > 0) { + importLines.push( + `import {\n${imports.stellarImports.join(",\n")}\n} from '@stellar/stellar-sdk';`, + ); + } + + return importLines.join("\n"); + } + + /** + * Generate TypeScript interface for a struct + */ + private generateStruct(struct: xdr.ScSpecUdtStructV0): string { + const name = sanitizeName(struct.name().toString()); + const doc = formatJSDocComment( + struct.doc().toString() || `Struct: ${name}`, + 0, + ); + + const fields = struct + .fields() + .map((field) => { + const fieldName = field.name().toString(); + const fieldType = parseTypeFromTypeDef(field.type()); + const fieldDoc = formatJSDocComment(field.doc().toString(), 2); + + return `${fieldDoc} ${fieldName}: ${fieldType};`; + }) + .join("\n"); + + return `${doc}export interface ${name} { +${fields} +}`; + } + + /** + * Generate TypeScript union type + */ + private generateUnion(union: xdr.ScSpecUdtUnionV0): string { + const name = sanitizeName(union.name().toString()); + const doc = formatJSDocComment( + union.doc().toString() || `Union: ${name}`, + 0, + ); + const cases = union + .cases() + .map((unionCase) => this.generateUnionCase(unionCase)); + + const caseTypes = cases + .map((c) => { + if (c.types.length > 0) { + return `${formatJSDocComment(c.doc, 2)} { tag: "${c.name}"; values: [${c.types.join(", ")}] }`; + } + return `${formatJSDocComment(c.doc, 2)} { tag: "${c.name}" }`; + }) + .join(" |\n"); + + return `${doc} export type ${name} = +${caseTypes};`; + } + + /** + * Generate TypeScript enum + */ + private generateEnum(enumEntry: xdr.ScSpecUdtEnumV0): string { + const name = sanitizeName(enumEntry.name().toString()); + const doc = formatJSDocComment( + enumEntry.doc().toString() || `Enum: ${name}`, + 0, + ); + + const members = enumEntry + .cases() + .map((enumCase) => { + const caseName = enumCase.name().toString(); + const caseValue = enumCase.value(); + const caseDoc = enumCase.doc().toString() || `Enum Case: ${caseName}`; + + return `${formatJSDocComment(caseDoc, 2)} ${caseName} = ${caseValue}`; + }) + .join(",\n"); + + return `${doc}export enum ${name} { +${members} +}`; + } + + /** + * Generate TypeScript error enum + */ + private generateErrorEnum(errorEnum: xdr.ScSpecUdtErrorEnumV0): string { + const name = sanitizeName(errorEnum.name().toString()); + const doc = formatJSDocComment( + errorEnum.doc().toString() || `Error Enum: ${name}`, + 0, + ); + const cases = errorEnum + .cases() + .map((enumCase) => this.generateEnumCase(enumCase)); + + const members = cases + .map((c) => { + return `${formatJSDocComment(c.doc, 2)} ${c.value} : {message: "${c.name}" }`; + }) + .join(",\n"); + + return `${doc}export const ${name} = { +${members} +}`; + } + + /** + * Generate union case + */ + private generateUnionCase(unionCase: xdr.ScSpecUdtUnionCaseV0): UnionCase { + switch (unionCase.switch()) { + case xdr.ScSpecUdtUnionCaseV0Kind.scSpecUdtUnionCaseVoidV0(): { + const voidCase = unionCase.voidCase(); + return { + doc: voidCase.doc().toString(), + name: voidCase.name().toString(), + types: [], + }; + } + case xdr.ScSpecUdtUnionCaseV0Kind.scSpecUdtUnionCaseTupleV0(): { + const tupleCase = unionCase.tupleCase(); + return { + doc: tupleCase.doc().toString(), + name: tupleCase.name().toString(), + types: tupleCase.type().map((t) => parseTypeFromTypeDef(t)), + }; + } + default: + throw new Error(`Unknown union case kind: ${unionCase.switch()}`); + } + } + + /** + * Generate enum case + */ + private generateEnumCase(enumCase: xdr.ScSpecUdtEnumCaseV0): EnumCase { + return { + doc: enumCase.doc().toString(), + name: enumCase.name().toString(), + value: enumCase.value(), + }; + } +} diff --git a/src/bindings/utils.ts b/src/bindings/utils.ts new file mode 100644 index 000000000..08c52d306 --- /dev/null +++ b/src/bindings/utils.ts @@ -0,0 +1,276 @@ +import { xdr } from "@stellar/stellar-base"; +export function isNameReserved(name: string): boolean { + const reservedNames = [ + // Keywords + "break", + "case", + "catch", + "class", + "const", + "continue", + "debugger", + "default", + "delete", + "do", + "else", + "export", + "extends", + "finally", + "for", + "function", + "if", + "import", + "in", + "instanceof", + "new", + "return", + "super", + "switch", + "this", + "throw", + "try", + "typeof", + "var", + "void", + "while", + "with", + "yield", + // Future reserved words + "enum", + // Strict mode reserved words + "implements", + "interface", + "let", + "package", + "private", + "protected", + "public", + "static", + // Contextual keywords + "async", + "await", + "constructor", + // Literals + "null", + "true", + "false", + ]; + return reservedNames.includes(name); +} +/** + * Sanitize a name to avoid reserved keywords + * @param name + * @returns + */ +export function sanitizeName(name: string): string { + if (isNameReserved(name)) { + // Append underscore to reserved + return name + "_"; + } + return name; +} + +/** + * Generate TypeScript type from XDR type definition + */ +export function parseTypeFromTypeDef(typeDef: xdr.ScSpecTypeDef): string { + switch (typeDef.switch()) { + case xdr.ScSpecType.scSpecTypeVal(): + return "xdr.ScVal"; + case xdr.ScSpecType.scSpecTypeBool(): + return "boolean"; + case xdr.ScSpecType.scSpecTypeVoid(): + return "null"; + case xdr.ScSpecType.scSpecTypeError(): + return "Error"; + case xdr.ScSpecType.scSpecTypeU32(): + case xdr.ScSpecType.scSpecTypeI32(): + return "number"; + case xdr.ScSpecType.scSpecTypeU64(): + case xdr.ScSpecType.scSpecTypeI64(): + case xdr.ScSpecType.scSpecTypeTimepoint(): + case xdr.ScSpecType.scSpecTypeDuration(): + case xdr.ScSpecType.scSpecTypeU128(): + case xdr.ScSpecType.scSpecTypeI128(): + case xdr.ScSpecType.scSpecTypeU256(): + case xdr.ScSpecType.scSpecTypeI256(): + return "bigint"; + case xdr.ScSpecType.scSpecTypeBytes(): + case xdr.ScSpecType.scSpecTypeBytesN(): + return "Buffer"; + case xdr.ScSpecType.scSpecTypeString(): + return "string"; + case xdr.ScSpecType.scSpecTypeSymbol(): + return "string"; + case xdr.ScSpecType.scSpecTypeAddress(): + case xdr.ScSpecType.scSpecTypeMuxedAddress(): + return "string | Address"; + case xdr.ScSpecType.scSpecTypeVec(): { + const vecType = parseTypeFromTypeDef(typeDef.vec().elementType()); + return `Array<${vecType}>`; + } + case xdr.ScSpecType.scSpecTypeMap(): { + const keyType = parseTypeFromTypeDef(typeDef.map().keyType()); + const valueType = parseTypeFromTypeDef(typeDef.map().valueType()); + return `Map<${keyType}, ${valueType}>`; + } + case xdr.ScSpecType.scSpecTypeTuple(): { + const tupleTypes = typeDef + .tuple() + .valueTypes() + .map((t: xdr.ScSpecTypeDef) => parseTypeFromTypeDef(t)); + return `[${tupleTypes.join(", ")}]`; + } + case xdr.ScSpecType.scSpecTypeOption(): { + // Handle nested options + while ( + typeDef.option().valueType().switch() === + xdr.ScSpecType.scSpecTypeOption() + ) { + typeDef = typeDef.option().valueType(); + } + const optionType = parseTypeFromTypeDef(typeDef.option().valueType()); + + return `${optionType} | null`; + } + case xdr.ScSpecType.scSpecTypeResult(): { + const okType = parseTypeFromTypeDef(typeDef.result().okType()); + const errorType = parseTypeFromTypeDef(typeDef.result().errorType()); + return `Result<${okType}, ${errorType}>`; + } + case xdr.ScSpecType.scSpecTypeUdt(): { + const udtName = sanitizeName(typeDef.udt().name().toString()); + return udtName; + } + default: + return "unknown"; + } +} + +/** + * Imports needed for generating bindings + */ +export interface BindingImports { + /** Imports needed from type definitions */ + typeFileImports: string[]; + /** Imports needed from the Stellar SDK in the contract namespace */ + stellarContractImports: string[]; + /** Imports needed from Stellar SDK in the global namespace */ + stellarImports: string[]; + /** Whether Buffer import is needed */ + needsBufferImport: boolean; +} +export function generateTypeImports( + typeDefs: xdr.ScSpecTypeDef[], +): BindingImports { + const typeFileImports = new Set(); + const stellarContractImports = new Set(); + const stellarImports = new Set(); + let needsBufferImport = false; + typeDefs.forEach((typeDef) => { + switch (typeDef.switch()) { + case xdr.ScSpecType.scSpecTypeUdt(): + // These are contract interfaces/structs/enums/errors that need to imported from types.ts + typeFileImports.add(sanitizeName(typeDef.udt().name().toString())); + break; + case xdr.ScSpecType.scSpecTypeAddress(): + case xdr.ScSpecType.scSpecTypeMuxedAddress(): + stellarImports.add("Address"); + break; + case xdr.ScSpecType.scSpecTypeBytes(): + case xdr.ScSpecType.scSpecTypeBytesN(): + needsBufferImport = true; + break; + case xdr.ScSpecType.scSpecTypeVal(): + stellarImports.add("xdr"); + break; + case xdr.ScSpecType.scSpecTypeVec(): + { + const vecImports = generateTypeImports([typeDef.vec().elementType()]); + vecImports.typeFileImports.forEach((imp) => typeFileImports.add(imp)); + vecImports.stellarContractImports.forEach((imp) => + stellarContractImports.add(imp), + ); + vecImports.stellarImports.forEach((imp) => stellarImports.add(imp)); + } + break; + case xdr.ScSpecType.scSpecTypeMap(): + { + const mapImports = generateTypeImports([ + typeDef.map().keyType(), + typeDef.map().valueType(), + ]); + mapImports.typeFileImports.forEach((imp) => typeFileImports.add(imp)); + mapImports.stellarContractImports.forEach((imp) => + stellarContractImports.add(imp), + ); + mapImports.stellarImports.forEach((imp) => stellarImports.add(imp)); + } + break; + case xdr.ScSpecType.scSpecTypeTuple(): + { + const tupleImports = generateTypeImports( + typeDef.tuple().valueTypes(), + ); + tupleImports.typeFileImports.forEach((imp) => + typeFileImports.add(imp), + ); + tupleImports.stellarContractImports.forEach((imp) => + stellarContractImports.add(imp), + ); + tupleImports.stellarImports.forEach((imp) => stellarImports.add(imp)); + } + break; + case xdr.ScSpecType.scSpecTypeOption(): + { + const optionImports = generateTypeImports([ + typeDef.option().valueType(), + ]); + optionImports.typeFileImports.forEach((imp) => + typeFileImports.add(imp), + ); + optionImports.stellarContractImports.forEach((imp) => + stellarContractImports.add(imp), + ); + optionImports.stellarImports.forEach((imp) => + stellarImports.add(imp), + ); + } + break; + case xdr.ScSpecType.scSpecTypeResult(): + { + stellarContractImports.add("Result"); + const resultImports = generateTypeImports([ + typeDef.result().okType(), + typeDef.result().errorType(), + ]); + resultImports.typeFileImports.forEach((imp) => + typeFileImports.add(imp), + ); + resultImports.stellarContractImports.forEach((imp) => + stellarContractImports.add(imp), + ); + resultImports.stellarImports.forEach((imp) => + stellarImports.add(imp), + ); + } + break; + } + }); + + return { + typeFileImports: Array.from(typeFileImports), + stellarContractImports: Array.from(stellarContractImports), + stellarImports: Array.from(stellarImports), + needsBufferImport, + }; +} + +export function formatJSDocComment(comment: string, indentLevel = 0): string { + if (comment.trim() === "") { + return ""; + } + const indent = " ".repeat(indentLevel); + const lines = comment.split("\n").map((line) => `${indent} * ${line}`); + return `${indent}/**\n${lines.join("\n")}\n${indent} */\n`; +} diff --git a/src/bindings/wasm_fetcher.ts b/src/bindings/wasm_fetcher.ts new file mode 100644 index 000000000..a833cfdcb --- /dev/null +++ b/src/bindings/wasm_fetcher.ts @@ -0,0 +1,256 @@ +import { Address, StrKey, xdr } from "@stellar/stellar-base"; + +import { RpcServer } from "../rpc/server"; + +/** + * Result of fetching contract WASM + */ +export interface FetchedContract { + contract: ContractData; + source: ContractSource; +} + +/** + * Types of contract data that can be fetched + */ +export type ContractData = + | { type: "wasm"; wasmBytes: Buffer } + | { type: "stellar-asset-contract" }; + +/** + * Source information about where the contract was fetched from + */ +export type ContractSource = + | { type: "file"; path: string } + | { + type: "wasm-hash"; + hash: string; + rpcUrl: string; + networkPassphrase: string; + } + | { + type: "contract-id"; + resolvedAddress: string; + rpcUrl: string; + networkPassphrase: string; + }; + +/** + * Errors that can occur during WASM fetching + */ +export class WasmFetchError extends Error { + constructor( + message: string, + public readonly cause?: Error, + ) { + super(message); + this.name = "WasmFetchError"; + } +} + +/** + * Verify that the server is on the expected network + */ +async function verifyNetwork( + server: RpcServer, + expectedPassphrase: string, +): Promise { + try { + const networkResponse = await server.getNetwork(); + if (networkResponse.passphrase !== expectedPassphrase) { + throw new WasmFetchError( + `Network mismatch: expected "${expectedPassphrase}", got "${networkResponse.passphrase}"`, + ); + } + } catch (error) { + if (error instanceof WasmFetchError) { + throw error; + } + throw new WasmFetchError("Failed to verify network", error as Error); + } +} + +/** + * Get WASM bytes from a WASM hash on the network + */ +async function getRemoteWasmFromHash( + server: RpcServer, + hashBuffer: Buffer, +): Promise { + try { + // Create the ledger key for the contract code + const contractCodeKey = xdr.LedgerKey.contractCode( + new xdr.LedgerKeyContractCode({ + hash: xdr.Hash.fromXDR(hashBuffer, "raw"), + }), + ); + + // Get the ledger entries + const response = await server.getLedgerEntries(contractCodeKey); + + if (!response.entries || response.entries.length === 0) { + throw new WasmFetchError("WASM not found for the given hash"); + } + + const entry = response.entries[0]; + if (entry.key.switch() !== xdr.LedgerEntryType.contractCode()) { + throw new WasmFetchError("Invalid ledger entry type returned"); + } + + const contractCode = entry.val.contractCode(); + return Buffer.from(contractCode.code()); + } catch (error) { + if (error instanceof WasmFetchError) { + throw error; + } + throw new WasmFetchError("Failed to fetch WASM from hash", error as Error); + } +} + +/** + * Check if a contract is a Stellar Asset Contract + */ +function isStellarAssetContract(instance: xdr.ScContractInstance): boolean { + // Check if it's a Stellar Asset Contract (has no WASM hash) + return ( + instance.executable().switch() === + xdr.ContractExecutableType.contractExecutableStellarAsset() + ); +} + +/** + * Fetch WASM bytes from a deployed contract + */ +async function fetchWasmFromContract( + server: RpcServer, + contractAddress: Address, +): Promise { + try { + // Get contract instance + const contractInstanceKey = xdr.LedgerKey.contractData( + new xdr.LedgerKeyContractData({ + contract: contractAddress.toScAddress(), + key: xdr.ScVal.scvLedgerKeyContractInstance(), + durability: xdr.ContractDataDurability.persistent(), + }), + ); + + const response = await server.getLedgerEntries(contractInstanceKey); + + if (!response.entries || response.entries.length === 0) { + throw new WasmFetchError("Contract instance not found"); + } + + const entry = response.entries[0]; + if (entry.key.switch() !== xdr.LedgerEntryType.contractData()) { + throw new WasmFetchError("Invalid ledger entry type returned"); + } + + const contractData = entry.val.contractData(); + const instance = contractData.val().instance(); + + if (isStellarAssetContract(instance)) { + return { + contract: { type: "stellar-asset-contract" }, + source: { + type: "contract-id", + resolvedAddress: contractAddress.toString(), + rpcUrl: server.serverURL.toString(), + networkPassphrase: "", // Unknown in this context + }, + }; + } + + const wasmHash = instance.executable().wasmHash(); + let wasmBytes = await getRemoteWasmFromHash(server, wasmHash); + return { + contract: { type: "wasm", wasmBytes }, + source: { + type: "contract-id", + resolvedAddress: contractAddress.toString(), + rpcUrl: server.serverURL.toString(), + networkPassphrase: "", // Unknown in this context + }, + }; + } catch (error) { + if (error instanceof WasmFetchError) { + throw error; + } + throw new WasmFetchError( + "Failed to fetch WASM from contract", + error as Error, + ); + } +} + +/** + * Fetch WASM from network using WASM hash + */ +export async function fetchFromWasmHash( + wasmHash: string, + rpcUrl: string, + networkPassphrase: string, +): Promise { + try { + // Validate and decode the hash + const hashBuffer = Buffer.from(wasmHash, "hex"); + if (hashBuffer.length !== 32) { + throw new WasmFetchError( + `Invalid WASM hash length: expected 32 bytes, got ${hashBuffer.length}`, + ); + } + + const server = new RpcServer(rpcUrl); + + // Verify network + await verifyNetwork(server, networkPassphrase); + + // Get WASM from hash + const wasmBytes = await getRemoteWasmFromHash(server, hashBuffer); + + return { + contract: { type: "wasm", wasmBytes }, + source: { + type: "wasm-hash", + hash: wasmHash, + rpcUrl, + networkPassphrase, + }, + }; + } catch (error) { + throw new WasmFetchError( + `Failed to fetch WASM from hash ${wasmHash}`, + error as Error, + ); + } +} + +/** + * Fetch WASM from network using contract ID + */ +export async function fetchFromContractId( + contractId: string, + rpcUrl: string, + networkPassphrase: string, +): Promise { + try { + const server = new RpcServer(rpcUrl); + + // Verify network + await verifyNetwork(server, networkPassphrase); + + if (!StrKey.isValidContract(contractId)) { + throw new WasmFetchError(`Invalid contract ID: ${contractId}`); + } + // Parse contract address + const contractAddress = Address.fromString(contractId); + + // Try to get WASM from contract + return await fetchWasmFromContract(server, contractAddress); + } catch (error) { + throw new WasmFetchError( + `Failed to fetch WASM from contract ${contractId}`, + error as Error, + ); + } +} diff --git a/src/cli/index.ts b/src/cli/index.ts new file mode 100644 index 000000000..1f3178871 --- /dev/null +++ b/src/cli/index.ts @@ -0,0 +1,128 @@ +import { Command } from "commander"; +import * as path from "path"; + +import { BindingGenerator } from "../bindings/generator"; +import { WasmFetchError } from "../bindings/wasm_fetcher"; +import { Spec } from "../contract/spec"; +import { + deriveContractName, + fetchWasm, + generateAndWrite, + logSourceInfo, +} from "./util"; +import { SAC_SPEC } from "../bindings"; +import { Networks } from "@stellar/stellar-base"; + +function runCli() { + const program = new Command(); + + program + .name("stellar-cli") + .description("CLI for generating TypeScript bindings for Stellar contracts") + .version("1.0.0"); + + program + .command("generate") + .description("Generate TypeScript bindings for a Stellar contract") + .helpOption("-h, --help", "Display help for command") + .option("--wasm ", "Path to local WASM file") + .option("--wasm-hash ", "Hash of WASM blob on network") + .option("--contract-id ", "Contract ID on network") + .option("--rpc-url ", "RPC server URL") + .option( + "--network ", + "Network options to use: testnet, mainnet, or futurenet", + ) + .option("--output-dir ", "Output directory for generated bindings") + .option( + "--contract-name ", + "Name for the generated contract client class", + ) + .option("--overwrite", "Overwrite existing files", false) + .action(async (options) => { + try { + // Map network to passphrase + let networkPassphrase: string | undefined; + if (options.network) { + const network = options.network.toLowerCase(); + switch (network) { + case "testnet": + networkPassphrase = Networks.TESTNET; + break; + case "mainnet": + networkPassphrase = Networks.PUBLIC; + break; + case "futurenet": + networkPassphrase = Networks.FUTURENET; + break; + default: + throw new Error( + `\n✗ Invalid network: ${options.network}. Must be testnet, mainnet, or futurenet`, + ); + } + } + if (options.outputDir === undefined) { + throw new Error("Output directory (--output-dir) is required"); + } + const { contract, source } = await fetchWasm({ + wasm: options.wasm, + wasmHash: options.wasmHash, + contractId: options.contractId, + rpcUrl: options.rpcUrl, + networkPassphrase, + }); + console.log("Fetching contract WASM..."); + + logSourceInfo(source); + + let generator: BindingGenerator; + let contractName: string; + + // Handle Stellar Asset Contract + if (contract.type === "stellar-asset-contract") { + console.log( + "\n✓ Detected Stellar Asset Contract, generating SAC bindings...", + ); + const spec = new Spec(SAC_SPEC); + generator = BindingGenerator.fromSpec(spec); + contractName = options.contractName || "StellarAssetContract"; + } else { + // Generate from WASM + console.log("\n✓ Generating TypeScript bindings..."); + generator = await BindingGenerator.fromWasm(contract.wasmBytes); + contractName = + options.contractName || deriveContractName(source) || "Contract"; + } + + // Generate and write bindings using helper + await generateAndWrite(generator, { + contractName, + outputDir: path.resolve(options.outputDir), + overwrite: options.overwrite, + contractAddress: options.contractId, + rpcUrl: options.rpcUrl, + networkPassphrase, + }); + + console.log( + `\n✓ Successfully generated bindings in ${options.outputDir}`, + ); + console.log(`\nUsage:`); + console.log( + ` import { Client } from './${path.basename(options.outputDir)}';`, + ); + } catch (error) { + if (error instanceof WasmFetchError) { + console.error(`\n✗ Error: ${error.message}`); + } else if (error instanceof Error) { + console.error(`\n✗ Error: ${error.message}`); + } else { + console.error(`\n✗ Unexpected error:`, error); + } + process.exit(1); + } + }); + program.parse(); +} + +export { runCli }; diff --git a/src/cli/util.ts b/src/cli/util.ts new file mode 100644 index 000000000..26de54db6 --- /dev/null +++ b/src/cli/util.ts @@ -0,0 +1,201 @@ +import * as fs from "fs/promises"; +import * as path from "path"; +import { + BindingGenerator, + GeneratedBindings, + GenerateOptions, +} from "../bindings/generator"; +import { + FetchedContract, + WasmFetchError, + fetchFromWasmHash, + fetchFromContractId, +} from "../bindings"; + +export type GenerateAndWriteOptions = GenerateOptions & { + outputDir: string; + overwrite?: boolean; +}; + +type WasmFetchArgs = { + wasm?: string; + wasmHash?: string; + contractId?: string; + rpcUrl?: string; + networkPassphrase?: string; +}; +/** + * Create a generator from a WASM file (Node.js only) + */ +export async function fromWasmFile(wasmPath: string): Promise { + // Read WASM file + const wasmBuffer = await fs.readFile(wasmPath); + + return wasmBuffer; +} + +/** + * Write generated bindings to disk (Node.js only) + */ +export async function writeBindings( + outputDir: string, + bindings: GeneratedBindings, + overwrite: boolean, +): Promise { + // Check if output directory exists + try { + const stat = await fs.stat(outputDir); + if (stat.isFile()) { + throw new Error(`Output directory path is a file: ${outputDir}`); + } + if (stat.isDirectory() && !overwrite) { + throw new Error( + `Output directory already exists and overwrite is false: ${outputDir}`, + ); + } + if (overwrite) { + await fs.rm(outputDir, { recursive: true, force: true }); + } + } catch (error: any) { + if (error.code !== "ENOENT") { + throw error; + } + } + + // Create output directory + await fs.mkdir(path.join(outputDir, "src"), { recursive: true }); + + // Write all files + const writePromises = [ + fs.writeFile(path.join(outputDir, "src/index.ts"), bindings.index), + fs.writeFile(path.join(outputDir, "src/client.ts"), bindings.client), + fs.writeFile(path.join(outputDir, ".gitignore"), bindings.gitignore), + fs.writeFile(path.join(outputDir, "README.md"), bindings.readme), + fs.writeFile(path.join(outputDir, "package.json"), bindings.packageJson), + fs.writeFile(path.join(outputDir, "tsconfig.json"), bindings.tsConfig), + ]; + + // Only write types file if it's not empty + if (bindings.types.trim() !== "") { + writePromises.push( + fs.writeFile(path.join(outputDir, "src/types.ts"), bindings.types), + ); + } + + await Promise.all(writePromises); +} +/** + * Generate TypeScript bindings and write to disk (Node.js only) + */ +export async function generateAndWrite( + generator: BindingGenerator, + options: GenerateAndWriteOptions, +): Promise { + const { outputDir, overwrite = false, ...genOptions } = options; + + // Generate bindings + const bindings = generator.generate(genOptions); + + // Write to disk + await writeBindings(outputDir, bindings, overwrite); +} + +/** + * Fetches contract WASM from local file, network hash, or contract ID + */ +export async function fetchWasm(args: WasmFetchArgs): Promise { + // Validate that exactly one source is provided + const sources = [args.wasm, args.wasmHash, args.contractId].filter(Boolean); + if (sources.length === 0) { + throw new WasmFetchError( + "Must provide one of the following: --wasm, --wasm-hash, or --contract-id", + ); + } + if (sources.length > 1) { + throw new WasmFetchError( + "Must provide only one of the following: --wasm, --wasm-hash, or --contract-id", + ); + } + + // Handle local WASM file + if (args.wasm) { + const buffer = await fromWasmFile(args.wasm); + return { + contract: { type: "wasm", wasmBytes: buffer }, + source: { type: "file", path: args.wasm }, + }; + } + + // For network sources, validate required network parameters + if (!args.rpcUrl) { + throw new WasmFetchError( + "--rpc-url is required when fetching from network", + ); + } + if (!args.networkPassphrase) { + throw new WasmFetchError( + "--network-passphrase is required when fetching from network", + ); + } + + // Handle WASM hash from network + if (args.wasmHash) { + return fetchFromWasmHash( + args.wasmHash, + args.rpcUrl, + args.networkPassphrase, + ); + } + + // Handle contract ID from network + if (args.contractId) { + return fetchFromContractId( + args.contractId, + args.rpcUrl, + args.networkPassphrase, + ); + } + + throw new WasmFetchError("Invalid arguments provided"); +} + +/** + * Log information about the contract source + */ +export function logSourceInfo(source: any): void { + console.log("\nSource:"); + switch (source.type) { + case "file": + console.log(` Type: Local file`); + console.log(` Path: ${source.path}`); + break; + case "wasm-hash": + console.log(` Type: WASM hash`); + console.log(` Hash: ${source.hash}`); + console.log(` RPC: ${source.rpcUrl}`); + console.log(` Network: ${source.networkPassphrase}`); + break; + case "contract-id": + console.log(` Type: Contract ID`); + console.log(` Address: ${source.resolvedAddress}`); + console.log(` RPC: ${source.rpcUrl}`); + console.log(` Network: ${source.networkPassphrase}`); + break; + } +} + +/** + * Derive a contract name from the source + */ +export function deriveContractName(source: any): string | null { + if (source.type === "file") { + const basename = path.basename(source.path, path.extname(source.path)); + // Convert kebab-case or snake_case to PascalCase + return basename + .split(/[-_]/) + .map((part) => part.charAt(0).toUpperCase() + part.slice(1)) + .join(""); + } + + return null; +} diff --git a/src/index.ts b/src/index.ts index 2bcd61396..8b6cfcc98 100644 --- a/src/index.ts +++ b/src/index.ts @@ -35,7 +35,7 @@ export * as rpc from "./rpc"; * import { Client } from '@stellar/stellar-sdk/contract'; */ export * as contract from "./contract"; - +export * as bindings from "./bindings"; // expose classes and functions from stellar-base export * from "@stellar/stellar-base"; diff --git a/src/rpc/server.ts b/src/rpc/server.ts index 1b601e155..8f391ea09 100644 --- a/src/rpc/server.ts +++ b/src/rpc/server.ts @@ -398,9 +398,9 @@ export class RpcServer { networkPassphrase?: string, ): Promise { let addr: string = address as string; - // Coalesce to a strkey if (typeof address === "string") { + addr = address; } else if (address instanceof Address) { addr = address.toString(); } else if (address instanceof Contract) { diff --git a/test/unit/bindings/wasm_fetcher_test.js b/test/unit/bindings/wasm_fetcher_test.js new file mode 100644 index 000000000..2a048f2d3 --- /dev/null +++ b/test/unit/bindings/wasm_fetcher_test.js @@ -0,0 +1,89 @@ +const { + fetchWasm, + WasmFetchError, +} = require("../../../lib/bindings/wasm_fetcher"); +const fs = require("fs").promises; +const path = require("path"); + +describe("WASM Fetcher", function () { + describe("fetchWasm", function () { + it("throws error when no source is provided", function () { + expect(() => { + fetchWasm({}); + }).to.throw( + WasmFetchError, + "Must provide one of: wasm, wasmHash, or contractId", + ); + }); + + it("throws error when multiple sources are provided", function () { + expect(() => { + fetchWasm({ + wasm: "test.wasm", + wasmHash: "abc123", + contractId: "contract123", + }); + }).to.throw( + WasmFetchError, + "Must provide only one of: wasm, wasmHash, or contractId", + ); + }); + + it("throws error when rpcUrl is missing for network sources", function () { + expect(() => { + fetchWasm({ + wasmHash: "abc123", + }); + }).to.throw( + WasmFetchError, + "rpcUrl is required when fetching from network", + ); + }); + + it("throws error when networkPassphrase is missing for network sources", function () { + expect(() => { + fetchWasm({ + wasmHash: "abc123", + rpcUrl: "https://rpc.example.com", + }); + }).to.throw( + WasmFetchError, + "networkPassphrase is required when fetching from network", + ); + }); + + it("throws error when file does not exist", async function () { + try { + await fetchWasm({ + wasm: "/nonexistent/file.wasm", + }); + expect.fail("Expected fetchWasm to throw an error"); + } catch (error) { + expect(error).to.be.instanceOf(WasmFetchError); + expect(error.message).to.include("Failed to read WASM file"); + } + }); + }); + + describe("WasmFetchError", function () { + it("creates proper error with message", function () { + const error = new WasmFetchError("Test error message"); + expect(error).to.be.instanceOf(Error); + expect(error).to.be.instanceOf(WasmFetchError); + expect(error.message).to.equal("Test error message"); + expect(error.name).to.equal("WasmFetchError"); + }); + + it("creates proper error with cause", function () { + const cause = new Error("Root cause"); + const error = new WasmFetchError("Test error message", cause); + expect(error.cause).to.equal(cause); + }); + + it("maintains proper prototype chain", function () { + const error = new WasmFetchError("Test error"); + expect(error instanceof WasmFetchError).to.be.true; + expect(error instanceof Error).to.be.true; + }); + }); +}); diff --git a/yarn.lock b/yarn.lock index 2af8b2028..0e348e59e 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3582,6 +3582,11 @@ commander@^13.1.0: resolved "https://registry.yarnpkg.com/commander/-/commander-13.1.0.tgz#776167db68c78f38dcce1f9b8d7b8b9a488abf46" integrity sha512-/rFeCpNJQbhSZjGVwO9RFV3xPqbnERS8MmIQzCtD/zl6gpJuV/bMLuN92oG3F7d8oDEHHRrujSXNUr8fpjntKw== +commander@^14.0.2: + version "14.0.2" + resolved "https://registry.yarnpkg.com/commander/-/commander-14.0.2.tgz#b71fd37fe4069e4c3c7c13925252ada4eba14e8e" + integrity sha512-TywoWNNRbhoD0BXs1P3ZEScW8W5iKrnbithIl0YH+uCmBd0QpPOA8yc82DS3BIE5Ma6FnBVUsJ7wVUDz4dvOWQ== + commander@^2.19.0, commander@^2.20.0: version "2.20.3" resolved "https://registry.yarnpkg.com/commander/-/commander-2.20.3.tgz#fd485e84c03eb4881c20722ba48035e8531aeb33"