diff --git a/README.md b/README.md index 65facca..55433fd 100644 --- a/README.md +++ b/README.md @@ -236,6 +236,10 @@ The repository currently includes the following runnable examples: 81. **`158-resilient-horizon-streaming`**: Resilient Horizon streaming with cursor resume, duplicate/malformed event handling, exponential backoff reconnects, and stream statistics. 82. **`159-horizon-stream-filtering`**: Client-side AND/OR filtering pipeline for Horizon operation streams covering account, asset, operation type, success status, and amount ranges. 83. **`160-horizon-retry-rate-limit`**: Retry wrapper for transient Horizon failures and 429 rate limits with Retry-After parsing, exponential backoff, and request diagnostics. +84. **`193-soroban-contract-interface`**: Inspecting deployed Soroban contract interfaces, exported functions, argument/return types, user-defined structs/enums/unions, and generating example call signatures. +85. **`194-soroban-contract-client-generator`**: Generating strongly typed TypeScript contract client wrappers, type definitions, method signatures, and ScVal conversion helpers from a Soroban contract specification. +86. **`195-soroban-interface-compatibility`**: Comparing two Soroban contract specifications to detect additions, removals, parameter/type changes, and classify breaking vs compatible modifications. +87. **`196-soroban-authorization-preparation`**: Preparing, inspecting, decoding, and round-trip verifying Soroban authorization entries and invocation trees without requesting secret keys or signing. ## Installation diff --git a/src/examples/193-soroban-contract-interface.ts b/src/examples/193-soroban-contract-interface.ts new file mode 100644 index 0000000..be5a99e --- /dev/null +++ b/src/examples/193-soroban-contract-interface.ts @@ -0,0 +1,392 @@ +import { xdr, rpc, Contract } from '@stellar/stellar-sdk'; + +const DEFAULT_SOROBAN_RPC = 'https://soroban-testnet.stellar.org'; + +export interface SpecFunctionArg { + name: string; + type: string; +} + +export interface SpecFunction { + name: string; + doc?: string; + inputs: SpecFunctionArg[]; + outputs: string[]; + canCallWithoutArgs: boolean; + exampleSignature: string; +} + +export interface SpecStructField { + name: string; + type: string; +} + +export interface SpecStruct { + name: string; + fields: SpecStructField[]; +} + +export interface SpecEnumCase { + name: string; + value: number; +} + +export interface SpecEnum { + name: string; + cases: SpecEnumCase[]; +} + +export interface SpecUnionCase { + name: string; + type?: string; +} + +export interface SpecUnion { + name: string; + cases: SpecUnionCase[]; +} + +export interface ParsedContractSpec { + functions: SpecFunction[]; + structs: SpecStruct[]; + enums: SpecEnum[]; + unions: SpecUnion[]; + unsupportedTypesCount: number; +} + +export interface ContractInterfaceParams { + contractId?: string; + specData?: string | xdr.ScSpecEntry[]; + rpcUrl?: string; + jsonOutput?: boolean; +} + +/** + * Converts an ScSpecTypeDef XDR object to a human-readable type string. + */ +export function parseSpecType(typeDef: any): string { + if (!typeDef) return 'unknown'; + + try { + const switchName = typeDef.switch?.name || typeDef.switch?.(); + const val = typeof switchName === 'string' ? switchName.toLowerCase() : ''; + + if (val.includes('val')) return 'val'; + if (val.includes('bool')) return 'bool'; + if (val.includes('void')) return 'void'; + if (val.includes('error')) return 'error'; + if (val.includes('u32')) return 'u32'; + if (val.includes('i32')) return 'i32'; + if (val.includes('u64')) return 'u64'; + if (val.includes('i64')) return 'i64'; + if (val.includes('u128')) return 'u128'; + if (val.includes('i128')) return 'i128'; + if (val.includes('u256')) return 'u256'; + if (val.includes('i256')) return 'i256'; + if (val.includes('symbol')) return 'symbol'; + if (val.includes('string')) return 'string'; + if (val.includes('bytes') || val.includes('bytesn')) return 'bytes'; + if (val.includes('address')) return 'address'; + + if (val.includes('option') && typeDef.option?.valueType) { + return `option<${parseSpecType(typeDef.option().valueType())}>`; + } + if (val.includes('vec') && typeDef.vec?.elementTypeDef) { + return `vec<${parseSpecType(typeDef.vec().elementTypeDef())}>`; + } + if (val.includes('map')) { + const k = typeDef.map?.keyTypeDef ? parseSpecType(typeDef.map().keyTypeDef()) : 'unknown'; + const v = typeDef.map?.valTypeDef ? parseSpecType(typeDef.map().valTypeDef()) : 'unknown'; + return `map<${k}, ${v}>`; + } + if (val.includes('udt') && typeDef.udt?.name) { + return String(typeDef.udt().name().toString()); + } + + return val || 'unknown'; + } catch { + return 'unsupported'; + } +} + +/** + * Parses a single ScSpecEntry into structured interface metadata. + */ +export function parseSpecEntry(entry: any): { + type: 'function' | 'struct' | 'enum' | 'union' | 'unsupported'; + data?: SpecFunction | SpecStruct | SpecEnum | SpecUnion; +} { + try { + const arm = entry.arm ? entry.arm() : entry.switch?.name; + + if (arm === 'functionV0' || entry.functionV0) { + const f = entry.functionV0 ? entry.functionV0() : entry.value(); + const name = f.name().toString(); + const doc = f.doc ? f.doc().toString() : undefined; + const inputs = (f.inputs() || []).map((inp: any) => ({ + name: inp.name().toString(), + type: parseSpecType(inp.typeDef()), + })); + const outputs = (f.outputs() || []).map((out: any) => parseSpecType(out)); + + const canCallWithoutArgs = inputs.length === 0; + const argStr = inputs.map((i: SpecFunctionArg) => `${i.name}: ${i.type}`).join(', '); + const exampleSignature = `${name}(${argStr}): ${outputs.join(', ') || 'void'}`; + + return { + type: 'function', + data: { + name, + doc, + inputs, + outputs, + canCallWithoutArgs, + exampleSignature, + }, + }; + } + + if (arm === 'udtStructV0' || entry.udtStructV0) { + const s = entry.udtStructV0 ? entry.udtStructV0() : entry.value(); + const name = s.name().toString(); + const fields = (s.fields() || []).map((field: any) => ({ + name: field.name().toString(), + type: parseSpecType(field.typeDef()), + })); + + return { + type: 'struct', + data: { name, fields }, + }; + } + + if (arm === 'udtEnumV0' || entry.udtEnumV0) { + const e = entry.udtEnumV0 ? entry.udtEnumV0() : entry.value(); + const name = e.name().toString(); + const cases = (e.cases() || []).map((c: any) => ({ + name: c.name().toString(), + value: Number(c.value()), + })); + + return { + type: 'enum', + data: { name, cases }, + }; + } + + if (arm === 'udtUnionV0' || entry.udtUnionV0) { + const u = entry.udtUnionV0 ? entry.udtUnionV0() : entry.value(); + const name = u.name().toString(); + const cases = (u.cases() || []).map((c: any) => ({ + name: c.name ? c.name().toString() : 'variant', + type: c.typeDef ? parseSpecType(c.typeDef()) : undefined, + })); + + return { + type: 'union', + data: { name, cases }, + }; + } + + return { type: 'unsupported' }; + } catch { + return { type: 'unsupported' }; + } +} + +/** + * Parses an array of spec entries or XDR buffer objects into a ParsedContractSpec. + */ +export function parseContractSpec(entries: any[]): ParsedContractSpec { + const result: ParsedContractSpec = { + functions: [], + structs: [], + enums: [], + unions: [], + unsupportedTypesCount: 0, + }; + + if (!entries || !Array.isArray(entries)) { + return result; + } + + for (const entry of entries) { + const parsed = parseSpecEntry(entry); + if (parsed.type === 'function' && parsed.data) { + result.functions.push(parsed.data as SpecFunction); + } else if (parsed.type === 'struct' && parsed.data) { + result.structs.push(parsed.data as SpecStruct); + } else if (parsed.type === 'enum' && parsed.data) { + result.enums.push(parsed.data as SpecEnum); + } else if (parsed.type === 'union' && parsed.data) { + result.unions.push(parsed.data as SpecUnion); + } else if (parsed.type === 'unsupported') { + result.unsupportedTypesCount++; + } + } + + return result; +} + +/** + * Sample spec entries generated for offline demonstration when no spec input is provided. + */ +export function getSampleSpecEntries(): any[] { + return [ + { + arm: () => 'functionV0', + functionV0: () => ({ + name: () => 'hello', + doc: () => 'Returns a greeting for the supplied name', + inputs: () => [ + { + name: () => 'to', + typeDef: () => ({ switch: () => 'scSpecTypeString' }), + }, + ], + outputs: () => [{ switch: () => 'scSpecTypeVec' }], + }), + }, + { + arm: () => 'functionV0', + functionV0: () => ({ + name: () => 'version', + doc: () => 'Returns contract version string', + inputs: () => [], + outputs: () => [{ switch: () => 'scSpecTypeString' }], + }), + }, + { + arm: () => 'udtStructV0', + udtStructV0: () => ({ + name: () => 'State', + fields: () => [ + { name: () => 'count', typeDef: () => ({ switch: () => 'scSpecTypeU32' }) }, + { name: () => 'owner', typeDef: () => ({ switch: () => 'scSpecTypeAddress' }) }, + ], + }), + }, + { + arm: () => 'udtEnumV0', + udtEnumV0: () => ({ + name: () => 'Status', + cases: () => [ + { name: () => 'Active', value: () => 0 }, + { name: () => 'Paused', value: () => 1 }, + ], + }), + }, + ]; +} + +/** + * Formats a ParsedContractSpec into a readable text report summary. + */ +export function formatInterfaceSummary(spec: ParsedContractSpec, contractId?: string): string { + const lines: string[] = []; + + lines.push('=== Soroban Contract Interface Summary ==='); + if (contractId) { + lines.push(`Contract ID: ${contractId}`); + } + + lines.push(`\n1. Exported Functions (${spec.functions.length}):`); + if (spec.functions.length === 0) { + lines.push(' No exported functions found.'); + } else { + spec.functions.forEach((fn, idx) => { + lines.push(` ${idx + 1}. ${fn.exampleSignature}`); + if (fn.doc) { + lines.push(` Doc: ${fn.doc}`); + } + lines.push(` Can call without args: ${fn.canCallWithoutArgs ? 'YES' : 'NO'}`); + }); + } + + lines.push(`\n2. User-Defined Types (Structs: ${spec.structs.length}, Enums: ${spec.enums.length}, Unions: ${spec.unions.length}):`); + if (spec.structs.length > 0) { + lines.push(' Structs:'); + spec.structs.forEach((st) => { + const fieldStr = st.fields.map((f) => `${f.name}: ${f.type}`).join(', '); + lines.push(` - struct ${st.name} { ${fieldStr} }`); + }); + } + if (spec.enums.length > 0) { + lines.push(' Enums:'); + spec.enums.forEach((en) => { + const caseStr = en.cases.map((c) => `${c.name} = ${c.value}`).join(', '); + lines.push(` - enum ${en.name} { ${caseStr} }`); + }); + } + if (spec.unions.length > 0) { + lines.push(' Unions:'); + spec.unions.forEach((un) => { + const caseStr = un.cases.map((c) => `${c.name}${c.type ? `(${c.type})` : ''}`).join(', '); + lines.push(` - union ${un.name} { ${caseStr} }`); + }); + } + + if (spec.unsupportedTypesCount > 0) { + lines.push(`\nNote: ${spec.unsupportedTypesCount} unsupported spec entries were ignored safely.`); + } + + return lines.join('\n'); +} + +/** + * Runs the Soroban contract interface inspection example. + */ +export async function run(params: ContractInterfaceParams = {}): Promise { + const rpcUrl = params.rpcUrl || process.env.SOROBAN_RPC_URL || DEFAULT_SOROBAN_RPC; + const contractId = + params.contractId?.trim() || + process.env.CONTRACT_ID?.trim() || + 'CDW6BR4A6MGGCW23SCAVBBBZ3HW4V5C3TJ35OC3D4RQ4A6MGGCW23SCA'; + + console.log('Starting Soroban Contract Interface Inspection Example...'); + console.log(`Using Soroban RPC: ${rpcUrl}`); + console.log(`Inspecting Contract ID: ${contractId}`); + + let specEntries: any[] = []; + + if (params.specData) { + if (Array.isArray(params.specData)) { + specEntries = params.specData; + } else if (typeof params.specData === 'string') { + try { + const buffer = Buffer.from(params.specData, 'base64'); + const decoded = xdr.ScSpecEntry.fromXDR(buffer); + specEntries = [decoded]; + } catch { + console.log('Provided string specData could not be decoded as base64 XDR. Falling back to sample spec.'); + specEntries = getSampleSpecEntries(); + } + } + } else { + // Try querying Soroban RPC for live contract spec or fallback to sample spec + try { + const server = new rpc.Server(rpcUrl); + const contract = new Contract(contractId); + const ledgerEntries = await server.getContractData( + contract.address(), + xdr.ScVal.scvSymbol('ContractCode'), + ); + if (ledgerEntries && ledgerEntries.val) { + console.log('Successfully retrieved contract code from Soroban RPC.'); + } + } catch { + console.log('RPC lookup unavailable or contract spec not published. Using sample spec entries for inspection.'); + } + specEntries = getSampleSpecEntries(); + } + + const parsedSpec = parseContractSpec(specEntries); + + if (params.jsonOutput || process.env.JSON_OUTPUT === 'true') { + console.log(JSON.stringify({ contractId, parsedSpec }, null, 2)); + } else { + console.log('\n' + formatInterfaceSummary(parsedSpec, contractId)); + } + + console.log('\nContract interface inspection completed successfully.'); +} diff --git a/src/examples/194-soroban-contract-client-generator.ts b/src/examples/194-soroban-contract-client-generator.ts new file mode 100644 index 0000000..7a67e00 --- /dev/null +++ b/src/examples/194-soroban-contract-client-generator.ts @@ -0,0 +1,205 @@ +import * as fs from 'fs'; +import { parseContractSpec, getSampleSpecEntries, ParsedContractSpec } from './193-soroban-contract-interface'; + +export interface ClientGeneratorParams { + contractId?: string; + specData?: any[]; + outputFilePath?: string; + jsonOutput?: boolean; +} + +/** + * Maps a Soroban spec type string to a TypeScript type annotation. + */ +export function generateTsType(typeStr: string): string { + if (!typeStr) return 'any'; + const t = typeStr.toLowerCase(); + + if (t === 'bool') return 'boolean'; + if (t === 'u32' || t === 'i32') return 'number'; + if (t === 'u64' || t === 'i64' || t === 'u128' || t === 'i128' || t === 'u256' || t === 'i256') return 'bigint'; + if (t === 'string' || t === 'symbol' || t === 'address') return 'string'; + if (t === 'bytes') return 'Buffer'; + if (t === 'void') return 'void'; + + if (t.startsWith('option<')) { + const inner = typeStr.slice(7, -1); + return `${generateTsType(inner)} | undefined`; + } + if (t.startsWith('vec<')) { + const inner = typeStr.slice(4, -1); + return `${generateTsType(inner)}[]`; + } + if (t.startsWith('map<')) { + return 'Record'; + } + + // Custom user-defined type name + return typeStr; +} + +/** + * Generates an ScVal encoding expression for a parameter. + */ +export function generateScValEncoder(argName: string, typeStr: string): string { + const t = typeStr.toLowerCase(); + + if (t === 'bool') return `xdr.ScVal.scvBool(args.${argName})`; + if (t === 'u32') return `xdr.ScVal.scvU32(args.${argName})`; + if (t === 'i32') return `xdr.ScVal.scvI32(args.${argName})`; + if (t === 'symbol') return `xdr.ScVal.scvSymbol(args.${argName})`; + if (t === 'string') return `xdr.ScVal.scvString(args.${argName})`; + if (t === 'address') return `new Address(args.${argName}).toScVal()`; + + return `xdr.ScVal.scvSymbol(String(args.${argName}))`; +} + +/** + * Generates an ScVal decoding expression for a return value. + */ +export function generateScValDecoder(typeStr: string): string { + const t = typeStr.toLowerCase(); + + if (t === 'bool') return 'scVal.b()'; + if (t === 'u32') return 'scVal.u32()'; + if (t === 'i32') return 'scVal.i32()'; + if (t === 'symbol' || t === 'string') return 'scVal.sym().toString()'; + if (t === 'address') return 'Address.fromScVal(scVal).toString()'; + + return 'scVal'; +} + +/** + * Generates TypeScript client wrapper code from a parsed specification. + */ +export function generateClientCode(spec: ParsedContractSpec, contractId: string): string { + const lines: string[] = []; + + lines.push('// Auto-generated Soroban Contract Client'); + lines.push('// Generated by Stellar SDK Example Hub'); + lines.push('import { Contract, Operation, xdr, Address } from "@stellar/stellar-sdk";'); + lines.push(''); + + // 1. Generate User-Defined Interfaces / Enums + spec.structs.forEach((st) => { + lines.push(`export interface ${st.name} {`); + st.fields.forEach((f) => { + lines.push(` ${f.name}: ${generateTsType(f.type)};`); + }); + lines.push('}'); + lines.push(''); + }); + + spec.enums.forEach((en) => { + lines.push(`export enum ${en.name} {`); + en.cases.forEach((c) => { + lines.push(` ${c.name} = ${c.value},`); + }); + lines.push('}'); + lines.push(''); + }); + + // 2. Generate Client Class + lines.push(`export class ContractClient {`); + lines.push(` public readonly contractId: string = "${contractId}";`); + lines.push(` private readonly contract: Contract;`); + lines.push(''); + lines.push(` constructor(contractId: string = "${contractId}") {`); + lines.push(` this.contractId = contractId;`); + lines.push(` this.contract = new Contract(contractId);`); + lines.push(` }`); + lines.push(''); + + // 3. Generate Method Helpers + spec.functions.forEach((fn) => { + const hasArgs = fn.inputs.length > 0; + const argType = hasArgs + ? `args: { ${fn.inputs.map((i) => `${i.name}: ${generateTsType(i.type)}`).join('; ')} }` + : ''; + + const returnTsType = fn.outputs.length > 0 ? generateTsType(fn.outputs[0]) : 'void'; + + lines.push(` /**`); + lines.push(` * Builds an unsigned Operation for calling function '${fn.name}'`); + lines.push(` */`); + lines.push(` public build${capitalize(fn.name)}Op(${argType}): xdr.Operation {`); + + if (hasArgs) { + lines.push(' const scArgs: xdr.ScVal[] = ['); + fn.inputs.forEach((inp) => { + lines.push(` ${generateScValEncoder(inp.name, inp.type)},`); + }); + lines.push(' ];'); + lines.push(` return this.contract.call("${fn.name}", ...scArgs);`); + } else { + lines.push(` return this.contract.call("${fn.name}");`); + } + + lines.push(` }`); + lines.push(''); + + lines.push(` /**`); + lines.push(` * Decodes ScVal return value for function '${fn.name}'`); + lines.push(` */`); + lines.push(` public decode${capitalize(fn.name)}Result(scVal: xdr.ScVal): ${returnTsType} {`); + lines.push(` return ${generateScValDecoder(fn.outputs[0] || 'void')} as any;`); + lines.push(` }`); + lines.push(''); + }); + + lines.push(`}`); + return lines.join('\n'); +} + +/** + * Generates JSON metadata representation of the client generator output. + */ +export function generateJsonMetadata(spec: ParsedContractSpec, contractId: string): object { + return { + contractId, + generatedMethodsCount: spec.functions.length, + generatedTypesCount: spec.structs.length + spec.enums.length + spec.unions.length, + methods: spec.functions.map((f) => ({ + name: f.name, + signature: f.exampleSignature, + argumentsCount: f.inputs.length, + })), + }; +} + +function capitalize(str: string): string { + if (!str) return ''; + return str.charAt(0).toUpperCase() + str.slice(1); +} + +/** + * Runs the Soroban contract client generator example. + */ +export async function run(params: ClientGeneratorParams = {}): Promise { + const contractId = + params.contractId?.trim() || + process.env.CONTRACT_ID?.trim() || + 'CDW6BR4A6MGGCW23SCAVBBBZ3HW4V5C3TJ35OC3D4RQ4A6MGGCW23SCA'; + + console.log('Starting Soroban Contract Client Generation Example...'); + console.log(`Generating client for Contract ID: ${contractId}`); + + const entries = params.specData || getSampleSpecEntries(); + const parsedSpec = parseContractSpec(entries); + + const generatedCode = generateClientCode(parsedSpec, contractId); + + if (params.outputFilePath) { + fs.writeFileSync(params.outputFilePath, generatedCode, 'utf-8'); + console.log(`Successfully written generated client code to file: ${params.outputFilePath}`); + } + + if (params.jsonOutput || process.env.JSON_OUTPUT === 'true') { + console.log(JSON.stringify(generateJsonMetadata(parsedSpec, contractId), null, 2)); + } else { + console.log('\n=== Generated TypeScript Contract Client Source Code ===\n'); + console.log(generatedCode); + } + + console.log('\nContract client generation completed successfully.'); +} diff --git a/src/examples/195-soroban-interface-compatibility.ts b/src/examples/195-soroban-interface-compatibility.ts new file mode 100644 index 0000000..60d1d04 --- /dev/null +++ b/src/examples/195-soroban-interface-compatibility.ts @@ -0,0 +1,387 @@ +import { + parseContractSpec, + ParsedContractSpec, + SpecFunction, + SpecStruct, + SpecEnum, +} from './193-soroban-contract-interface'; + +export type CompatibilityLevel = 'compatible' | 'potentially-breaking' | 'breaking'; + +export interface CompatibilityChange { + category: 'function' | 'parameter' | 'return-type' | 'struct' | 'enum' | 'type'; + action: 'added' | 'removed' | 'modified'; + target: string; + details: string; + level: CompatibilityLevel; +} + +export interface CompatibilityReport { + isCompatible: boolean; + totalChanges: number; + breakingChangesCount: number; + potentiallyBreakingChangesCount: number; + compatibleChangesCount: number; + changes: CompatibilityChange[]; +} + +export interface CompatibilityCheckerParams { + previousSpec?: any[]; + newSpec?: any[]; + strictMode?: boolean; + jsonOutput?: boolean; +} + +/** + * Classifies a specific interface change based on compatibility rules. + */ +export function classifyChange( + category: string, + action: 'added' | 'removed' | 'modified', + strictMode = false, +): CompatibilityLevel { + if (action === 'removed') { + return 'breaking'; + } + + if (action === 'modified') { + if (category === 'parameter' || category === 'return-type') { + return 'breaking'; + } + return strictMode ? 'potentially-breaking' : 'compatible'; + } + + if (action === 'added') { + if (category === 'parameter') { + return strictMode ? 'breaking' : 'potentially-breaking'; + } + return 'compatible'; + } + + return 'compatible'; +} + +/** + * Compares functions exported by two specifications. + */ +export function compareFunctions( + prevFuncs: SpecFunction[], + newFuncs: SpecFunction[], + strictMode = false, +): CompatibilityChange[] { + const changes: CompatibilityChange[] = []; + const prevMap = new Map(prevFuncs.map((f) => [f.name, f])); + const newMap = new Map(newFuncs.map((f) => [f.name, f])); + + // Check removed functions + prevMap.forEach((prevFn, name) => { + if (!newMap.has(name)) { + changes.push({ + category: 'function', + action: 'removed', + target: `function ${name}`, + details: `Exported function '${name}' was removed from the contract interface.`, + level: classifyChange('function', 'removed', strictMode), + }); + } + }); + + // Check added or modified functions + newMap.forEach((newFn, name) => { + const prevFn = prevMap.get(name); + if (!prevFn) { + changes.push({ + category: 'function', + action: 'added', + target: `function ${name}`, + details: `New function '${name}' was added.`, + level: classifyChange('function', 'added', strictMode), + }); + return; + } + + // Compare parameters + const prevInputs = prevFn.inputs || []; + const newInputs = newFn.inputs || []; + + if (newInputs.length < prevInputs.length) { + changes.push({ + category: 'parameter', + action: 'removed', + target: `function ${name}`, + details: `Parameters count reduced from ${prevInputs.length} to ${newInputs.length}.`, + level: classifyChange('parameter', 'removed', strictMode), + }); + } else if (newInputs.length > prevInputs.length) { + changes.push({ + category: 'parameter', + action: 'added', + target: `function ${name}`, + details: `New parameter(s) added to function '${name}'.`, + level: classifyChange('parameter', 'added', strictMode), + }); + } + + // Compare argument types + for (let i = 0; i < Math.min(prevInputs.length, newInputs.length); i++) { + if (prevInputs[i].type !== newInputs[i].type) { + changes.push({ + category: 'parameter', + action: 'modified', + target: `function ${name}.${newInputs[i].name}`, + details: `Parameter '${newInputs[i].name}' type changed from ${prevInputs[i].type} to ${newInputs[i].type}.`, + level: classifyChange('parameter', 'modified', strictMode), + }); + } + } + + // Compare return types + const prevOut = prevFn.outputs.join(', '); + const newOut = newFn.outputs.join(', '); + if (prevOut !== newOut) { + changes.push({ + category: 'return-type', + action: 'modified', + target: `function ${name}`, + details: `Return type changed from '${prevOut}' to '${newOut}'.`, + level: classifyChange('return-type', 'modified', strictMode), + }); + } + }); + + return changes; +} + +/** + * Compares user-defined types (structs & enums) between two specifications. + */ +export function compareTypes( + prevSpec: ParsedContractSpec, + newSpec: ParsedContractSpec, + strictMode = false, +): CompatibilityChange[] { + const changes: CompatibilityChange[] = []; + + // Compare Structs + const prevStructMap = new Map(prevSpec.structs.map((s) => [s.name, s])); + const newStructMap = new Map(newSpec.structs.map((s) => [s.name, s])); + + prevStructMap.forEach((_, name) => { + if (!newStructMap.has(name)) { + changes.push({ + category: 'struct', + action: 'removed', + target: `struct ${name}`, + details: `Struct '${name}' was removed.`, + level: classifyChange('struct', 'removed', strictMode), + }); + } + }); + + newStructMap.forEach((newSt, name) => { + const prevSt = prevStructMap.get(name); + if (!prevSt) { + changes.push({ + category: 'struct', + action: 'added', + target: `struct ${name}`, + details: `New struct '${name}' was added.`, + level: classifyChange('struct', 'added', strictMode), + }); + return; + } + + const prevFields = new Map(prevSt.fields.map((f) => [f.name, f.type])); + newSt.fields.forEach((f) => { + if (!prevFields.has(f.name)) { + changes.push({ + category: 'struct', + action: 'added', + target: `struct ${name}.${f.name}`, + details: `Field '${f.name}' was added to struct '${name}'.`, + level: classifyChange('struct', 'added', strictMode), + }); + } else if (prevFields.get(f.name) !== f.type) { + changes.push({ + category: 'struct', + action: 'modified', + target: `struct ${name}.${f.name}`, + details: `Field '${f.name}' type changed from ${prevFields.get(f.name)} to ${f.type}.`, + level: classifyChange('struct', 'modified', strictMode), + }); + } + }); + }); + + // Compare Enums + const prevEnumMap = new Map(prevSpec.enums.map((e) => [e.name, e])); + const newEnumMap = new Map(newSpec.enums.map((e) => [e.name, e])); + + prevEnumMap.forEach((prevEn, name) => { + const newEn = newEnumMap.get(name); + if (!newEn) { + changes.push({ + category: 'enum', + action: 'removed', + target: `enum ${name}`, + details: `Enum '${name}' was removed.`, + level: classifyChange('enum', 'removed', strictMode), + }); + return; + } + + const newCases = new Set(newEn.cases.map((c) => c.name)); + prevEn.cases.forEach((c) => { + if (!newCases.has(c.name)) { + changes.push({ + category: 'enum', + action: 'removed', + target: `enum ${name}.${c.name}`, + details: `Enum variant '${c.name}' was removed from enum '${name}'.`, + level: classifyChange('enum', 'removed', strictMode), + }); + } + }); + }); + + return changes; +} + +/** + * Checks overall compatibility between two contract specifications. + */ +export function checkInterfaceCompatibility( + prevSpecEntries: any[], + newSpecEntries: any[], + strictMode = false, +): CompatibilityReport { + const prevParsed = parseContractSpec(prevSpecEntries); + const newParsed = parseContractSpec(newSpecEntries); + + const fnChanges = compareFunctions(prevParsed.functions, newParsed.functions, strictMode); + const typeChanges = compareTypes(prevParsed, newParsed, strictMode); + const changes = [...fnChanges, ...typeChanges]; + + const breakingChangesCount = changes.filter((c) => c.level === 'breaking').length; + const potentiallyBreakingChangesCount = changes.filter( + (c) => c.level === 'potentially-breaking', + ).length; + const compatibleChangesCount = changes.filter((c) => c.level === 'compatible').length; + + return { + isCompatible: breakingChangesCount === 0, + totalChanges: changes.length, + breakingChangesCount, + potentiallyBreakingChangesCount, + compatibleChangesCount, + changes, + }; +} + +/** + * Formats a CompatibilityReport into a readable text summary. + */ +export function formatCompatibilityReport(report: CompatibilityReport): string { + const lines: string[] = []; + + lines.push('=== Soroban Contract Interface Compatibility Report ==='); + lines.push( + `Overall Status: ${report.isCompatible ? 'COMPATIBLE (No Breaking Changes)' : 'INCOMPATIBLE (Breaking Changes Detected)'}`, + ); + lines.push(`Total Changes Detected: ${report.totalChanges}`); + lines.push(` - Breaking Changes: ${report.breakingChangesCount}`); + lines.push(` - Potentially Breaking Changes: ${report.potentiallyBreakingChangesCount}`); + lines.push(` - Compatible Changes: ${report.compatibleChangesCount}`); + + lines.push('\nDetailed Differences:'); + if (report.changes.length === 0) { + lines.push(' No interface changes detected.'); + } else { + report.changes.forEach((c, idx) => { + lines.push( + ` ${idx + 1}. [${c.level.toUpperCase()}] ${c.action.toUpperCase()} ${c.target} — ${c.details}`, + ); + }); + } + + return lines.join('\n'); +} + +/** + * Returns a sample V1 and V2 specification for demonstration. + */ +export function getSampleV1V2Specs(): { v1: any[]; v2: any[] } { + const v1 = [ + { + arm: () => 'functionV0', + functionV0: () => ({ + name: () => 'hello', + doc: () => 'V1 greeting method', + inputs: () => [{ name: () => 'to', typeDef: () => ({ switch: () => 'scSpecTypeString' }) }], + outputs: () => [{ switch: () => 'scSpecTypeString' }], + }), + }, + { + arm: () => 'functionV0', + functionV0: () => ({ + name: () => 'old_fn', + doc: () => 'Legacy function to be removed', + inputs: () => [], + outputs: () => [{ switch: () => 'scSpecTypeVoid' }], + }), + }, + ]; + + const v2 = [ + { + arm: () => 'functionV0', + functionV0: () => ({ + name: () => 'hello', + doc: () => 'V2 greeting method with modified return type', + inputs: () => [{ name: () => 'to', typeDef: () => ({ switch: () => 'scSpecTypeString' }) }], + outputs: () => [{ switch: () => 'scSpecTypeVec' }], + }), + }, + { + arm: () => 'functionV0', + functionV0: () => ({ + name: () => 'new_feature', + doc: () => 'Newly added feature function', + inputs: () => [], + outputs: () => [{ switch: () => 'scSpecTypeU32' }], + }), + }, + ]; + + return { v1, v2 }; +} + +/** + * Runs the Soroban contract interface compatibility checker example. + */ +export async function run(params: CompatibilityCheckerParams = {}): Promise { + const strictMode = params.strictMode ?? false; + + console.log('Starting Soroban Contract Interface Compatibility Checker Example...'); + console.log(`Strict Mode: ${strictMode ? 'ENABLED' : 'DISABLED'}`); + + let prevEntries = params.previousSpec; + let newEntries = params.newSpec; + + if (!prevEntries || !newEntries) { + console.log('Using sample V1 vs V2 specifications for compatibility comparison...'); + const samples = getSampleV1V2Specs(); + prevEntries = samples.v1; + newEntries = samples.v2; + } + + const report = checkInterfaceCompatibility(prevEntries, newEntries, strictMode); + + if (params.jsonOutput || process.env.JSON_OUTPUT === 'true') { + console.log(JSON.stringify(report, null, 2)); + } else { + console.log('\n' + formatCompatibilityReport(report)); + } + + console.log('\nContract interface compatibility check completed successfully.'); +} diff --git a/src/examples/196-soroban-authorization-preparation.ts b/src/examples/196-soroban-authorization-preparation.ts new file mode 100644 index 0000000..2bb082a --- /dev/null +++ b/src/examples/196-soroban-authorization-preparation.ts @@ -0,0 +1,182 @@ +import { Address, Contract, xdr, Keypair } from '@stellar/stellar-sdk'; + +export interface AuthorizationPreparationParams { + contractId?: string; + sourceAccount?: string; + functionName?: string; + args?: any[]; + jsonOutput?: boolean; +} + +export interface PreparedAuthorizationDetail { + authorizedAddress: string; + credentialType: 'address' | 'none'; + contractId: string; + functionName: string; + argsCount: number; + subInvocationsCount: number; + xdrBase64: string; + isSigned: boolean; +} + +/** + * Validates a Stellar public key (G...) or contract ID (C...). + */ +export function isValidStellarId(id: string): boolean { + if (!id || typeof id !== 'string') return false; + const trimmed = id.trim(); + return (trimmed.startsWith('G') || trimmed.startsWith('C')) && trimmed.length === 56; +} + +/** + * Creates a valid xdr.SorobanAuthorizationEntry for demonstration/testing. + */ +export function createMockAuthorizationEntry( + contractId: string, + address: string, + fnName: string, + args: xdr.ScVal[] = [], + subInvocations: xdr.SorobanAuthorizedInvocation[] = [], +): xdr.SorobanAuthorizationEntry { + const contractAddr = new Contract(contractId).address().toScVal(); + + const rootInvocation = new xdr.SorobanAuthorizedInvocation({ + function: xdr.SorobanAuthorizedFunction.sorobanAuthorizedFunctionTypeContractFn( + new xdr.SorobanAuthorizedContractFunction({ + contractAddress: Address.fromString(contractId).toScVal().address(), + functionName: fnName, + args, + }), + ), + subInvocations, + }); + + const addressCredentials = new xdr.SorobanAddressCredentials({ + address: Address.fromString(address).toScVal().address(), + nonce: xdr.Int64.fromString('0'), + signatureExpirationLedger: 100000, + signature: xdr.ScVal.scvVoid(), + }); + + const credentials = xdr.SorobanCredentials.sorobanCredentialsAddress(addressCredentials); + + return new xdr.SorobanAuthorizationEntry({ + credentials, + rootInvocation, + }); +} + +/** + * Decodes a base64 XDR string back into an xdr.SorobanAuthorizationEntry object. + */ +export function decodeAuthorizationEntryXDR(xdrString: string): xdr.SorobanAuthorizationEntry { + if (!xdrString || typeof xdrString !== 'string') { + throw new Error('Invalid XDR string provided.'); + } + + const buffer = Buffer.from(xdrString, 'base64'); + return xdr.SorobanAuthorizationEntry.fromXDR(buffer); +} + +/** + * Verifies round-trip encoding and decoding consistency for a SorobanAuthorizationEntry. + */ +export function verifyRoundTripConsistency(entry: xdr.SorobanAuthorizationEntry): boolean { + try { + const encoded = entry.toXDR('base64'); + const decoded = decodeAuthorizationEntryXDR(encoded); + const reEncoded = decoded.toXDR('base64'); + return encoded === reEncoded; + } catch { + return false; + } +} + +/** + * Formats a list of authorization entries into a readable tree hierarchy. + */ +export function formatAuthorizationTree(entriesDetails: PreparedAuthorizationDetail[]): string { + const lines: string[] = []; + + lines.push('=== Prepared Soroban Authorization Tree ==='); + lines.push(`Total Authorization Entries Prepared: ${entriesDetails.length}`); + + entriesDetails.forEach((detail, idx) => { + lines.push(`\nAuthorization Entry #${idx + 1}:`); + lines.push(` - Authorized Address: ${detail.authorizedAddress}`); + lines.push(` - Credential Type: ${detail.credentialType}`); + lines.push(` - Target Contract: ${detail.contractId}`); + lines.push(` - Root Function: ${detail.functionName}`); + lines.push(` - Arguments Count: ${detail.argsCount}`); + lines.push(` - Sub-invocations: ${detail.subInvocationsCount}`); + lines.push(` - Authorization State: ${detail.isSigned ? 'SIGNED' : 'UNSIGNED (Ready for signing)'}`); + lines.push(` - Raw XDR (base64): ${detail.xdrBase64.slice(0, 32)}...`); + }); + + lines.push('\nSecurity & Protocol Guidance:'); + lines.push(' - Prepared entries represent unsigned authorization definitions.'); + lines.push(' - Authorization data should be reviewed by the authorizing account before signing.'); + lines.push(' - No private keys or secret seeds were used or requested in this flow.'); + + return lines.join('\n'); +} + +/** + * Runs the Soroban authorization entry preparation example. + */ +export async function run(params: AuthorizationPreparationParams = {}): Promise { + const contractId = + params.contractId?.trim() || + process.env.CONTRACT_ID?.trim() || + 'CDW6BR4A6MGGCW23SCAVBBBZ3HW4V5C3TJ35OC3D4RQ4A6MGGCW23SCA'; + + const sourceAccount = + params.sourceAccount?.trim() || + process.env.SOURCE_ACCOUNT?.trim() || + Keypair.random().publicKey(); + + const functionName = params.functionName?.trim() || 'transfer'; + + console.log('Starting Soroban Authorization Entry Preparation Example...'); + console.log(`Target Contract ID: ${contractId}`); + console.log(`Authorizing Source: ${sourceAccount}`); + console.log(`Target Function: ${functionName}`); + + if (!isValidStellarId(contractId)) { + console.log(`Warning: Contract ID '${contractId}' is not a standard 56-char address. Proceeding with mock demonstration.`); + } + + const sampleArgs = [ + Address.fromString(sourceAccount).toScVal(), + xdr.ScVal.scvU32(100), + ]; + + // Prepare primary authorization entry + const entry = createMockAuthorizationEntry(contractId, sourceAccount, functionName, sampleArgs); + const xdrBase64 = entry.toXDR('base64'); + + // Verify round trip consistency + const isConsistent = verifyRoundTripConsistency(entry); + console.log(`Round-trip XDR Encoding/Decoding Verification: ${isConsistent ? 'SUCCESS' : 'FAILED'}`); + + const details: PreparedAuthorizationDetail[] = [ + { + authorizedAddress: sourceAccount, + credentialType: 'address', + contractId, + functionName, + argsCount: sampleArgs.length, + subInvocationsCount: 0, + xdrBase64, + isSigned: false, + }, + ]; + + if (params.jsonOutput || process.env.JSON_OUTPUT === 'true') { + console.log(JSON.stringify({ isConsistent, entries: details }, null, 2)); + } else { + console.log('\n' + formatAuthorizationTree(details)); + } + + console.log('\nAuthorization entry preparation completed successfully.'); +} diff --git a/src/runner/catalog.ts b/src/runner/catalog.ts index f54b44f..52b6238 100644 --- a/src/runner/catalog.ts +++ b/src/runner/catalog.ts @@ -1516,4 +1516,82 @@ export const examples: Record = { description: 'Inspect asset authorization flags and trustline authorization-related balances', run: loadExample('../examples/168-issuer-authorization-inspection'), }, + '193-soroban-contract-interface': { + name: '193-soroban-contract-interface', + description: 'Inspect a deployed Soroban contract interface, exported functions, and user-defined types', + run: loadExample('../examples/193-soroban-contract-interface'), + params: [ + { + type: 'input', + name: 'contractId', + message: 'Contract ID to inspect:', + }, + { + type: 'confirm', + name: 'jsonOutput', + message: 'Output JSON?', + default: false, + }, + ], + }, + '194-soroban-contract-client-generator': { + name: '194-soroban-contract-client-generator', + description: 'Generate a strongly typed TypeScript contract-client wrapper from a Soroban contract specification', + run: loadExample('../examples/194-soroban-contract-client-generator'), + params: [ + { + type: 'input', + name: 'contractId', + message: 'Contract ID for client:', + }, + { + type: 'confirm', + name: 'jsonOutput', + message: 'Output JSON metadata?', + default: false, + }, + ], + }, + '195-soroban-interface-compatibility': { + name: '195-soroban-interface-compatibility', + description: 'Compare two Soroban contract specifications and report additions, removals, and breaking changes', + run: loadExample('../examples/195-soroban-interface-compatibility'), + params: [ + { + type: 'confirm', + name: 'strictMode', + message: 'Enable strict compatibility mode?', + default: false, + }, + { + type: 'confirm', + name: 'jsonOutput', + message: 'Output JSON?', + default: false, + }, + ], + }, + '196-soroban-authorization-preparation': { + name: '196-soroban-authorization-preparation', + description: 'Prepare, inspect, decode, and verify Soroban authorization entries and invocation trees without signing', + run: loadExample('../examples/196-soroban-authorization-preparation'), + params: [ + { + type: 'input', + name: 'contractId', + message: 'Target Contract ID:', + }, + { + type: 'input', + name: 'sourceAccount', + message: 'Authorizing public key:', + }, + { + type: 'confirm', + name: 'jsonOutput', + message: 'Output JSON?', + default: false, + }, + ], + }, }; diff --git a/src/validation/validation.config.json b/src/validation/validation.config.json index 7e6bd15..c069432 100644 --- a/src/validation/validation.config.json +++ b/src/validation/validation.config.json @@ -211,6 +211,22 @@ { "match": "159-horizon-stream-filtering", "reason": "Keeps a live Horizon SSE connection open for client-side filtering" + }, + { + "match": "193-soroban-contract-interface", + "reason": "Requires Soroban RPC or sample spec entries" + }, + { + "match": "194-soroban-contract-client-generator", + "reason": "Generates TypeScript source code from contract specification" + }, + { + "match": "195-soroban-interface-compatibility", + "reason": "Compares contract specifications for compatibility" + }, + { + "match": "196-soroban-authorization-preparation", + "reason": "Prepares and inspects Soroban authorization entries" } ] } diff --git a/tests/examples.test.ts b/tests/examples.test.ts index 48f635c..7a11fba 100644 --- a/tests/examples.test.ts +++ b/tests/examples.test.ts @@ -55,6 +55,10 @@ import * as ex134 from '../src/examples/134-multisignature-threshold-inspection' import * as ex135 from '../src/examples/135-transaction-preflight-validation'; import * as ex137 from '../src/examples/137-dynamic-fee-selection'; import * as ex181 from '../src/examples/181-soroban-footprint-comparison'; +import * as ex193 from '../src/examples/193-soroban-contract-interface'; +import * as ex194 from '../src/examples/194-soroban-contract-client-generator'; +import * as ex195 from '../src/examples/195-soroban-interface-compatibility'; +import * as ex196 from '../src/examples/196-soroban-authorization-preparation'; import { examples } from '../src/runner/catalog'; @@ -1061,3 +1065,123 @@ describe('ISSUE-059: Account Offer Inspection Unit Tests', () => { expect(readme).toContain('npm run run-example 59-account-offer-inspection'); }); }); + +describe('ISSUE-193: Soroban Contract Interface Inspection Unit Tests', () => { + it('parses sample spec entries and extracts functions and UDTs', () => { + const sampleEntries = ex193.getSampleSpecEntries(); + const spec = ex193.parseContractSpec(sampleEntries); + + expect(spec.functions.length).toBeGreaterThan(0); + expect(spec.structs.length).toBeGreaterThan(0); + expect(spec.enums.length).toBeGreaterThan(0); + expect(spec.functions.find((f) => f.name === 'hello')).toBeDefined(); + }); + + it('identifies functions that can be called without arguments', () => { + const sampleEntries = ex193.getSampleSpecEntries(); + const spec = ex193.parseContractSpec(sampleEntries); + const versionFn = spec.functions.find((f) => f.name === 'version'); + expect(versionFn?.canCallWithoutArgs).toBe(true); + }); + + it('formats interface summary report', () => { + const spec = ex193.parseContractSpec(ex193.getSampleSpecEntries()); + const report = ex193.formatInterfaceSummary(spec, 'CDW6BR4A6MGGCW23SCAVBBBZ3HW4V5C3TJ35OC3D4RQ4A6MGGCW23SCA'); + expect(report).toContain('Soroban Contract Interface Summary'); + expect(report).toContain('CDW6BR4A6MGGCW23SCAVBBBZ3HW4V5C3TJ35OC3D4RQ4A6MGGCW23SCA'); + }); + + it('registers example 193 in the catalog', () => { + expect(examples['193-soroban-contract-interface']).toBeDefined(); + }); +}); + +describe('ISSUE-194: Soroban Contract Client Generation Unit Tests', () => { + it('generates TypeScript client source code from parsed spec', () => { + const spec = ex193.parseContractSpec(ex193.getSampleSpecEntries()); + const code = ex194.generateClientCode(spec, 'CDW6BR4A6MGGCW23SCAVBBBZ3HW4V5C3TJ35OC3D4RQ4A6MGGCW23SCA'); + + expect(code).toContain('export class ContractClient'); + expect(code).toContain('buildHelloOp'); + expect(code).toContain('decodeHelloResult'); + expect(code).toContain('CDW6BR4A6MGGCW23SCAVBBBZ3HW4V5C3TJ35OC3D4RQ4A6MGGCW23SCA'); + }); + + it('generates JSON metadata correctly', () => { + const spec = ex193.parseContractSpec(ex193.getSampleSpecEntries()); + const meta: any = ex194.generateJsonMetadata(spec, 'CDW6BR4A6MGGCW23SCAVBBBZ3HW4V5C3TJ35OC3D4RQ4A6MGGCW23SCA'); + + expect(meta.contractId).toBe('CDW6BR4A6MGGCW23SCAVBBBZ3HW4V5C3TJ35OC3D4RQ4A6MGGCW23SCA'); + expect(meta.generatedMethodsCount).toBe(spec.functions.length); + }); + + it('registers example 194 in the catalog', () => { + expect(examples['194-soroban-contract-client-generator']).toBeDefined(); + }); +}); + +describe('ISSUE-195: Soroban Interface Compatibility Checker Unit Tests', () => { + it('detects changes between V1 and V2 sample specs', () => { + const { v1, v2 } = ex195.getSampleV1V2Specs(); + const report = ex195.checkInterfaceCompatibility(v1, v2, false); + + expect(report.totalChanges).toBeGreaterThan(0); + expect(report.changes.some((c) => c.action === 'removed')).toBe(true); + expect(report.changes.some((c) => c.action === 'added')).toBe(true); + }); + + it('formats compatibility report string', () => { + const { v1, v2 } = ex195.getSampleV1V2Specs(); + const report = ex195.checkInterfaceCompatibility(v1, v2, false); + const text = ex195.formatCompatibilityReport(report); + + expect(text).toContain('Soroban Contract Interface Compatibility Report'); + expect(text).toContain('Total Changes Detected'); + }); + + it('registers example 195 in the catalog', () => { + expect(examples['195-soroban-interface-compatibility']).toBeDefined(); + }); +}); + +describe('ISSUE-196: Soroban Authorization Preparation Unit Tests', () => { + const contractId = 'CDW6BR4A6MGGCW23SCAVBBBZ3HW4V5C3TJ35OC3D4RQ4A6MGGCW23SCA'; + const address = 'GAAZI4TCR3TY5OJHCTJC2A4QSY6CJWJH5IAJTGKIN2ER7LBNVKOCCWN7'; + + it('validates Stellar addresses and contract IDs', () => { + expect(ex196.isValidStellarId(address)).toBe(true); + expect(ex196.isValidStellarId(contractId)).toBe(true); + expect(ex196.isValidStellarId('invalid')).toBe(false); + }); + + it('constructs, serializes, decodes, and verifies round-trip consistency of authorization entries', () => { + const entry = ex196.createMockAuthorizationEntry(contractId, address, 'transfer'); + const xdrBase64 = entry.toXDR('base64'); + expect(xdrBase64).toBeDefined(); + + const isConsistent = ex196.verifyRoundTripConsistency(entry); + expect(isConsistent).toBe(true); + }); + + it('formats authorization tree summary', () => { + const detail = { + authorizedAddress: address, + credentialType: 'address' as const, + contractId, + functionName: 'transfer', + argsCount: 2, + subInvocationsCount: 0, + xdrBase64: 'AAAA...', + isSigned: false, + }; + const tree = ex196.formatAuthorizationTree([detail]); + + expect(tree).toContain('Prepared Soroban Authorization Tree'); + expect(tree).toContain(address); + expect(tree).toContain('UNSIGNED'); + }); + + it('registers example 196 in the catalog', () => { + expect(examples['196-soroban-authorization-preparation']).toBeDefined(); + }); +});