diff --git a/README.md b/README.md index a6b5781..3918fe2 100644 --- a/README.md +++ b/README.md @@ -250,6 +250,11 @@ The repository currently includes the following runnable examples: 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. +84. **`116-soroban-token-contract`**: Inspect Soroban token metadata, balances, allowances, and optional total supply; construct and simulate a token transfer; and decode returned `ScVal` values. +85. **`117-soroban-auth-tree`**: Simulate Soroban authorization requirements and display readable root and nested invocation trees with signer, contract, function, argument, and signature information. +86. **`118-ledger-footprint-analysis`**: Simulate and compare Soroban ledger footprints, distinguish read-only and read-write entries, decode ledger keys, identify storage types, and display raw XDR. +87. **`119-soroban-resource-fee-analysis`**: Simulate and compare Soroban CPU, memory, ledger I/O, transaction resource limits, resource fees, inclusion fees, and total estimated transaction cost. + ## Installation Ensure you have [Node.js](https://nodejs.org/) version 18.0.0 or later installed. diff --git a/src/examples/116-soroban-token-contract.ts b/src/examples/116-soroban-token-contract.ts new file mode 100644 index 0000000..6b63b11 --- /dev/null +++ b/src/examples/116-soroban-token-contract.ts @@ -0,0 +1,1025 @@ +import { + Account, + Address, + Asset, + Contract, + Keypair, + Networks, + StrKey, + Transaction, + TransactionBuilder, + contract, + nativeToScVal, + rpc, + scValToNative, + xdr, +} from 'stellar-sdk-v16'; +import chalk from 'chalk'; + +/** + * ISSUE-116: Soroban Token Contract Interaction + * + * Soroban token contracts expose a standard interface for fungible assets. + * This example demonstrates how applications can inspect token metadata, + * balances and allowances, construct a transfer, and simulate that transfer + * before any transaction is signed or submitted. + * + * The example intentionally performs simulation only. It never broadcasts a + * transfer and therefore never moves real assets. + * + * It demonstrates: + * + * 1. Connecting to Soroban RPC. + * 2. Accepting and validating a token contract ID. + * 3. Inspecting available contract methods where runtime specification + * metadata is available. + * 4. Reading token name, symbol and decimals. + * 5. Reading an address balance. + * 6. Reading an allowance where supported. + * 7. Reading total_supply where supported. + * 8. Building a token transfer with correctly encoded ScVal arguments. + * 9. Simulating the transfer before submission. + * 10. Decoding returned ScVal values. + * 11. Recognising insufficient-balance failures. + * 12. Explaining the relationship between Soroban token contracts and + * classic Stellar assets represented by the Stellar Asset Contract. + */ + +const DEFAULT_RPC_URL = 'https://soroban-testnet.stellar.org'; +const BASE_FEE = '100'; + +const STANDARD_TOKEN_METHODS = [ + 'name', + 'symbol', + 'decimals', + 'balance', + 'allowance', + 'approve', + 'transfer', + 'transfer_from', + 'burn', + 'burn_from', +]; + +export interface SorobanTokenContractParams { + tokenContractId?: string; + accountId?: string; + spenderId?: string; + recipientId?: string; + transferAmount?: string; + rpcUrl?: string; + networkPassphrase?: string; +} + +export interface SimulationValueResult { + ok: boolean; + restoreRequired: boolean; + rawValue?: xdr.ScVal; + decodedValue?: unknown; + error?: string; +} + +export interface TokenMetadata { + name?: string; + symbol?: string; + decimals?: number; +} + +export interface TokenTransferSummary { + from: string; + to: string; + amount: bigint; + transaction: Transaction; +} + +/** + * Return a readable error message without assuming that caught values are + * always Error objects. + */ +export function getErrorMessage(error: unknown): string { + if (error instanceof Error) { + return error.message; + } + + return String(error); +} + +/** + * Validate a Soroban contract address. + */ +export function isValidTokenContractId(contractId: string): boolean { + return StrKey.isValidContract(contractId.trim()); +} + +/** + * Token balance and authorization arguments may use either account addresses + * or contract addresses. + */ +export function isValidSorobanAddress(address: string): boolean { + const value = address.trim(); + + return StrKey.isValidEd25519PublicKey(value) || StrKey.isValidContract(value); +} + +/** + * Safely convert a decoded Soroban integer to bigint. + */ +export function toBigIntValue(value: unknown): bigint | null { + if (typeof value === 'bigint') { + return value; + } + + if (typeof value === 'number' && Number.isFinite(value) && Number.isInteger(value)) { + return BigInt(value); + } + + if (typeof value === 'string' && /^-?\d+$/.test(value)) { + return BigInt(value); + } + + return null; +} + +/** + * Render a raw token amount using its declared decimal precision. + * + * Example: + * + * formatTokenAmount(12345678n, 7) -> "1.2345678" + */ +export function formatTokenAmount(amount: bigint, decimals: number): string { + if (decimals <= 0) { + return amount.toString(); + } + + const negative = amount < 0n; + const absolute = negative ? -amount : amount; + + const scale = 10n ** BigInt(decimals); + const whole = absolute / scale; + const fraction = (absolute % scale).toString().padStart(decimals, '0').replace(/0+$/, ''); + + const rendered = fraction.length > 0 ? `${whole}.${fraction}` : whole.toString(); + + return negative ? `-${rendered}` : rendered; +} + +/** + * Decode an ScVal while retaining a predictable fallback for values the SDK + * cannot convert to a native JavaScript representation. + */ +export function decodeScVal(value: xdr.ScVal): unknown { + try { + return scValToNative(value); + } catch { + return { + type: value.switch().name, + xdr: value.toXDR('base64'), + }; + } +} + +/** + * Determine whether a simulation error appears to represent a balance-related + * failure. + * + * Stellar Asset Contract failures may expose human-readable text, contract + * error names, or numeric error information depending on the RPC version. + */ +export function isInsufficientBalanceError(message: string): boolean { + const normalized = message.toLowerCase(); + + return ( + normalized.includes('insufficient') || + normalized.includes('balanceerror') || + normalized.includes('balance error') || + normalized.includes('balance is not sufficient') || + normalized.includes('accountmissing') || + normalized.includes('account missing') + ); +} + +/** + * Build a single-operation Soroban contract invocation. + * + * The source account only needs an address and sequence for simulation. No + * signing occurs in this example. + */ +export function buildContractInvocation( + sourceAccountId: string, + networkPassphrase: string, + contractId: string, + method: string, + args: xdr.ScVal[] = [], +): Transaction { + const sourceAccount = new Account(sourceAccountId, '0'); + const tokenContract = new Contract(contractId); + + return new TransactionBuilder(sourceAccount, { + fee: BASE_FEE, + networkPassphrase, + }) + .addOperation(tokenContract.call(method, ...args)) + .setTimeout(30) + .build(); +} + +/** + * Build the transfer invocation separately so it can be tested independently + * from RPC communication. + */ +export function buildTokenTransfer( + sourceAccountId: string, + networkPassphrase: string, + contractId: string, + from: string, + to: string, + amount: bigint, +): TokenTransferSummary { + if (amount < 0n) { + throw new Error('Transfer amount cannot be negative.'); + } + + if (!isValidSorobanAddress(from)) { + throw new Error(`Invalid transfer source address "${from}".`); + } + + if (!isValidSorobanAddress(to)) { + throw new Error(`Invalid transfer destination address "${to}".`); + } + + const args = [ + Address.fromString(from).toScVal(), + Address.fromString(to).toScVal(), + nativeToScVal(amount, { type: 'i128' }), + ]; + + return { + from, + to, + amount, + transaction: buildContractInvocation( + sourceAccountId, + networkPassphrase, + contractId, + 'transfer', + args, + ), + }; +} + +/** + * Simulate a token method and decode its return value. + */ +async function simulateMethod( + server: rpc.Server, + sourceAccountId: string, + networkPassphrase: string, + contractId: string, + method: string, + args: xdr.ScVal[] = [], +): Promise { + let simulation: rpc.Api.SimulateTransactionResponse; + + try { + const transaction = buildContractInvocation( + sourceAccountId, + networkPassphrase, + contractId, + method, + args, + ); + + simulation = await server.simulateTransaction(transaction); + } catch (error: unknown) { + return { + ok: false, + restoreRequired: false, + error: `RPC request failed: ${getErrorMessage(error)}`, + }; + } + + if (rpc.Api.isSimulationError(simulation)) { + return { + ok: false, + restoreRequired: false, + error: simulation.error, + }; + } + + const restoreRequired = rpc.Api.isSimulationRestore(simulation); + + if (!simulation.result) { + return { + ok: true, + restoreRequired, + }; + } + + return { + ok: true, + restoreRequired, + rawValue: simulation.result.retval, + decodedValue: decodeScVal(simulation.result.retval), + }; +} + +/** + * Try to inspect the deployed WASM contract specification. + * + * Stellar Asset Contracts are native/built-in contracts rather than ordinary + * user-deployed WASM contracts, so an RPC node may not expose WASM + * specification metadata for them. In that case the example falls back to the + * standard token interface and probes those methods directly. + */ +async function inspectTokenMethods( + server: rpc.Server, + rpcUrl: string, + networkPassphrase: string, + contractId: string, +): Promise { + try { + const wasm = await server.getContractWasmByContractId(contractId); + + const client = await contract.Client.fromWasm(wasm, { + contractId, + networkPassphrase, + rpcUrl, + }); + + const methods = client.spec + .funcs() + .map((fn) => fn.name().toString()) + .filter((name) => !name.startsWith('__')) + .sort(); + + return methods; + } catch { + return null; + } +} + +/** + * Print a result returned by a token getter. + */ +function printGetterResult(label: string, result: SimulationValueResult): void { + if (!result.ok) { + console.log(chalk.yellow(` ${label.padEnd(12)}: unavailable`)); + console.log(chalk.gray(` ${result.error ?? 'No value returned.'}`)); + return; + } + + if (result.rawValue) { + console.log(` ${label.padEnd(12)}: ${formatNativeValue(result.decodedValue)}`); + console.log(chalk.gray(` ScVal: ${result.rawValue.switch().name}`)); + } else { + console.log(chalk.yellow(` ${label.padEnd(12)}: no return value`)); + } + + if (result.restoreRequired) { + console.log( + chalk.yellow(' Archived state must be restored before submission.'), + ); + } +} + +/** + * Render common native values without losing bigint precision. + */ +export function formatNativeValue(value: unknown): string { + if (typeof value === 'bigint') { + return value.toString(); + } + + if (typeof value === 'string') { + return value; + } + + if (value === undefined) { + return '(undefined)'; + } + + if (value === null) { + return '(null)'; + } + + try { + return JSON.stringify( + value, + (_key, nestedValue) => + typeof nestedValue === 'bigint' ? nestedValue.toString() : nestedValue, + 2, + ); + } catch { + return String(value); + } +} + +/** + * Display a compact subset of diagnostic events when simulation fails. + */ +function printDiagnosticEvents(events: xdr.DiagnosticEvent[]): void { + if (events.length === 0) { + console.log(chalk.gray(' No diagnostic events were returned by the RPC node.')); + return; + } + + console.log(chalk.gray(` Diagnostic events returned: ${events.length}`)); + + events.slice(0, 5).forEach((event, index) => { + try { + const contractEvent = event.event(); + const body = contractEvent.body().v0(); + const topics = body.topics().map((topic) => formatNativeValue(decodeScVal(topic))); + + console.log(chalk.gray(` [${index + 1}] successful=${event.inSuccessfulContractCall()}`)); + + if (topics.length > 0) { + console.log(chalk.gray(` topics: ${topics.join(', ')}`)); + } + + console.log(chalk.gray(` data : ${formatNativeValue(decodeScVal(body.data()))}`)); + } catch (error: unknown) { + console.log( + chalk.gray( + ` [${index + 1}] Could not decode diagnostic event: ${getErrorMessage(error)}`, + ), + ); + } + }); + + if (events.length > 5) { + console.log(chalk.gray(` ... ${events.length - 5} additional event(s) omitted.`)); + } +} + +/** + * Run ISSUE-116. + */ +export async function run(params: SorobanTokenContractParams = {}): Promise { + const rpcUrl = params.rpcUrl?.trim() || process.env.SOROBAN_RPC_URL?.trim() || DEFAULT_RPC_URL; + + const networkPassphrase = + params.networkPassphrase?.trim() || process.env.NETWORK_PASSPHRASE?.trim() || Networks.TESTNET; + + /* + * The native asset's Stellar Asset Contract ID is deterministic for a + * network, making it a reliable default token contract. + */ + const defaultTokenContractId = Asset.native().contractId(networkPassphrase); + + const contractId = + params.tokenContractId?.trim() || + process.env.TOKEN_CONTRACT_ID?.trim() || + process.env.CONTRACT_ID?.trim() || + defaultTokenContractId; + + /* + * No secret key is needed because this example only simulates. A random + * account address therefore makes a safe default address for balance, + * allowance and transfer demonstrations. + */ + const generatedAccount = Keypair.random().publicKey(); + const generatedSpender = Keypair.random().publicKey(); + const generatedRecipient = Keypair.random().publicKey(); + + const accountId = + params.accountId?.trim() || process.env.TOKEN_ACCOUNT_ID?.trim() || generatedAccount; + + const spenderId = + params.spenderId?.trim() || process.env.TOKEN_SPENDER_ID?.trim() || generatedSpender; + + const recipientId = + params.recipientId?.trim() || process.env.TOKEN_RECIPIENT_ID?.trim() || generatedRecipient; + + const transferAmountInput = + params.transferAmount?.trim() || process.env.TOKEN_TRANSFER_AMOUNT?.trim() || '1'; + + console.log(chalk.bold('\nSoroban Token Contract Interaction Example')); + + console.log( + chalk.gray( + 'Inspect token metadata and balances, then construct and simulate a token transfer without broadcasting it.', + ), + ); + + console.log(chalk.yellow('\nConfiguration')); + console.log(` RPC endpoint : ${rpcUrl}`); + console.log(` Network : ${networkPassphrase}`); + console.log(` Token contract : ${contractId}`); + console.log(` Balance address : ${accountId}`); + console.log(` Allowance spender : ${spenderId}`); + console.log(` Transfer recipient: ${recipientId}`); + console.log(` Transfer amount : ${transferAmountInput} base unit(s)`); + + // ----------------------------------------------------------------------- + // Step 1: Validate input + // ----------------------------------------------------------------------- + + console.log(chalk.yellow('\nStep 1: Validating input...')); + + if (!isValidTokenContractId(contractId)) { + console.error( + chalk.red( + ` Invalid token contract ID "${contractId}". Expected a valid Stellar contract address beginning with "C".`, + ), + ); + + return; + } + + if (!isValidSorobanAddress(accountId)) { + console.error(chalk.red(` Invalid balance/source address "${accountId}".`)); + console.log( + chalk.gray(' Expected a valid Stellar account (G...) or contract (C...) address.'), + ); + return; + } + + if (!isValidSorobanAddress(spenderId)) { + console.error(chalk.red(` Invalid spender address "${spenderId}".`)); + return; + } + + if (!isValidSorobanAddress(recipientId)) { + console.error(chalk.red(` Invalid recipient address "${recipientId}".`)); + return; + } + + let transferAmount: bigint; + + try { + transferAmount = BigInt(transferAmountInput); + + if (transferAmount < 0n) { + throw new Error('amount cannot be negative'); + } + } catch { + console.error( + chalk.red( + ` Invalid transfer amount "${transferAmountInput}". Use a non-negative integer in token base units.`, + ), + ); + + return; + } + + console.log(chalk.green(' Input validation passed.')); + + const server = new rpc.Server(rpcUrl); + + // ----------------------------------------------------------------------- + // Step 2: Connect to Soroban RPC + // ----------------------------------------------------------------------- + + console.log(chalk.yellow('\nStep 2: Connecting to Soroban RPC...')); + + try { + const latestLedger = await server.getLatestLedger(); + + console.log(chalk.green(` Connected. Latest ledger sequence: ${latestLedger.sequence}`)); + } catch (error: unknown) { + console.error(chalk.red(` Unable to reach Soroban RPC: ${getErrorMessage(error)}`)); + console.log(chalk.gray(' Check SOROBAN_RPC_URL and your network connection.')); + return; + } + + // ----------------------------------------------------------------------- + // Step 3: Inspect available token methods + // ----------------------------------------------------------------------- + + console.log(chalk.yellow('\nStep 3: Inspecting token contract methods...')); + + const discoveredMethods = await inspectTokenMethods( + server, + rpcUrl, + networkPassphrase, + contractId, + ); + + if (discoveredMethods && discoveredMethods.length > 0) { + console.log( + chalk.green(` Runtime specification exposes ${discoveredMethods.length} public method(s):`), + ); + + discoveredMethods.forEach((method) => { + const standard = STANDARD_TOKEN_METHODS.includes(method) ? ' [token interface]' : ''; + console.log(` - ${method}${standard}`); + }); + } else { + console.log( + chalk.gray( + ' Runtime WASM specification is unavailable. This is normal for native Stellar Asset Contracts.', + ), + ); + + console.log(chalk.cyan(' Standard token methods that this example can probe:')); + + STANDARD_TOKEN_METHODS.forEach((method) => { + console.log(` - ${method}`); + }); + } + + // ----------------------------------------------------------------------- + // Step 4: Read token metadata + // ----------------------------------------------------------------------- + + console.log(chalk.yellow('\nStep 4: Reading token metadata...')); + + /* + * Read-only simulations do not need an existing funded transaction source. + * If accountId itself is a G-address, use it as the simulated transaction + * source. Otherwise use a throwaway G-address because TransactionBuilder + * requires an account source rather than a contract address. + */ + const simulationSource = StrKey.isValidEd25519PublicKey(accountId) + ? accountId + : Keypair.random().publicKey(); + + const nameResult = await simulateMethod( + server, + simulationSource, + networkPassphrase, + contractId, + 'name', + ); + + const symbolResult = await simulateMethod( + server, + simulationSource, + networkPassphrase, + contractId, + 'symbol', + ); + + const decimalsResult = await simulateMethod( + server, + simulationSource, + networkPassphrase, + contractId, + 'decimals', + ); + + printGetterResult('Name', nameResult); + printGetterResult('Symbol', symbolResult); + printGetterResult('Decimals', decimalsResult); + + const metadata: TokenMetadata = {}; + + if (nameResult.ok && typeof nameResult.decodedValue === 'string') { + metadata.name = nameResult.decodedValue; + } + + if (symbolResult.ok && typeof symbolResult.decodedValue === 'string') { + metadata.symbol = symbolResult.decodedValue; + } + + const decodedDecimals = toBigIntValue(decimalsResult.decodedValue); + + if ( + decodedDecimals !== null && + decodedDecimals >= 0n && + decodedDecimals <= BigInt(Number.MAX_SAFE_INTEGER) + ) { + metadata.decimals = Number(decodedDecimals); + } + + // ----------------------------------------------------------------------- + // Step 5: Read token balance + // ----------------------------------------------------------------------- + + console.log(chalk.yellow('\nStep 5: Reading account balance...')); + + const balanceResult = await simulateMethod( + server, + simulationSource, + networkPassphrase, + contractId, + 'balance', + [Address.fromString(accountId).toScVal()], + ); + + let balance: bigint | null = null; + + if (!balanceResult.ok) { + console.log(chalk.yellow(' Balance is unavailable.')); + console.log(chalk.gray(` ${balanceResult.error ?? 'No balance value returned.'}`)); + } else { + balance = toBigIntValue(balanceResult.decodedValue); + + if (balance === null) { + console.log( + chalk.yellow( + ` Balance returned an unexpected value: ${formatNativeValue(balanceResult.decodedValue)}`, + ), + ); + } else { + console.log(` Address : ${accountId}`); + console.log(` Raw balance : ${balance.toString()} base unit(s)`); + + if (metadata.decimals !== undefined) { + console.log( + ` Display amount: ${formatTokenAmount(balance, metadata.decimals)} ${ + metadata.symbol ?? '' + }`.trimEnd(), + ); + } + } + + if (balanceResult.rawValue) { + console.log(chalk.gray(` Return ScVal : ${balanceResult.rawValue.switch().name}`)); + console.log(chalk.gray(` Return XDR : ${balanceResult.rawValue.toXDR('base64')}`)); + } + } + + // ----------------------------------------------------------------------- + // Step 6: Inspect allowance + // ----------------------------------------------------------------------- + + console.log(chalk.yellow('\nStep 6: Inspecting allowance...')); + + const allowanceResult = await simulateMethod( + server, + simulationSource, + networkPassphrase, + contractId, + 'allowance', + [Address.fromString(accountId).toScVal(), Address.fromString(spenderId).toScVal()], + ); + + if (!allowanceResult.ok) { + console.log(chalk.yellow(' Allowance could not be read.')); + console.log( + chalk.gray( + ` ${allowanceResult.error ?? 'The contract may not implement the standard allowance method.'}`, + ), + ); + } else { + const allowance = toBigIntValue(allowanceResult.decodedValue); + + console.log(` Owner : ${accountId}`); + console.log(` Spender : ${spenderId}`); + + if (allowance !== null) { + console.log(` Allowance: ${allowance.toString()} base unit(s)`); + + if (metadata.decimals !== undefined) { + console.log( + ` Display : ${formatTokenAmount(allowance, metadata.decimals)} ${ + metadata.symbol ?? '' + }`.trimEnd(), + ); + } + } else { + console.log(` Allowance: ${formatNativeValue(allowanceResult.decodedValue)}`); + } + } + + // ----------------------------------------------------------------------- + // Step 7: Probe total supply + // ----------------------------------------------------------------------- + + console.log(chalk.yellow('\nStep 7: Checking total supply where supported...')); + + /* + * total_supply is useful but is not guaranteed by the common token + * interface. We deliberately probe it instead of assuming it exists. + */ + const totalSupplyResult = await simulateMethod( + server, + simulationSource, + networkPassphrase, + contractId, + 'total_supply', + ); + + if (!totalSupplyResult.ok) { + console.log( + chalk.gray( + ' total_supply is not available on this contract, or the contract rejected the call.', + ), + ); + + console.log( + chalk.gray( + ' This is expected for token implementations that expose only the standard token interface.', + ), + ); + } else { + const totalSupply = toBigIntValue(totalSupplyResult.decodedValue); + + if (totalSupply !== null) { + console.log(` Raw total supply: ${totalSupply.toString()} base unit(s)`); + + if (metadata.decimals !== undefined) { + console.log( + ` Display amount : ${formatTokenAmount(totalSupply, metadata.decimals)} ${ + metadata.symbol ?? '' + }`.trimEnd(), + ); + } + } else { + console.log(` Total supply: ${formatNativeValue(totalSupplyResult.decodedValue)}`); + } + } + + // ----------------------------------------------------------------------- + // Step 8: Construct token transfer + // ----------------------------------------------------------------------- + + console.log(chalk.yellow('\nStep 8: Constructing token transfer...')); + + let transfer: TokenTransferSummary; + + try { + transfer = buildTokenTransfer( + simulationSource, + networkPassphrase, + contractId, + accountId, + recipientId, + transferAmount, + ); + } catch (error: unknown) { + console.error(chalk.red(` Could not construct transfer: ${getErrorMessage(error)}`)); + return; + } + + console.log(chalk.green(' Transfer invocation constructed successfully.')); + console.log(` From : ${transfer.from}`); + console.log(` To : ${transfer.to}`); + console.log(` Raw amount : ${transfer.amount.toString()} base unit(s)`); + + if (metadata.decimals !== undefined) { + console.log( + ` Display : ${formatTokenAmount(transfer.amount, metadata.decimals)} ${ + metadata.symbol ?? '' + }`.trimEnd(), + ); + } + + const transferOperation = transfer.transaction.operations[0]; + + if (transferOperation?.type === 'invokeHostFunction') { + console.log(chalk.gray(` Operation : ${transferOperation.type}`)); + console.log( + chalk.gray(` Auth entries before simulation: ${transferOperation.auth?.length ?? 0}`), + ); + } + + // ----------------------------------------------------------------------- + // Step 9: Pre-check for insufficient balance + // ----------------------------------------------------------------------- + + console.log(chalk.yellow('\nStep 9: Checking balance before simulation...')); + + if (balance !== null && transferAmount > balance) { + console.log( + chalk.yellow( + ` Requested amount ${transferAmount.toString()} exceeds the available balance ${balance.toString()}.`, + ), + ); + + console.log( + chalk.gray( + ' An application can stop here before asking a wallet to sign. This example continues to simulation so the RPC diagnostic can also be inspected.', + ), + ); + } else if (balance !== null) { + console.log(chalk.green(' The inspected balance is sufficient for the requested amount.')); + } else { + console.log( + chalk.gray( + ' Balance could not be determined, so the transfer will rely on simulation for validation.', + ), + ); + } + + // ----------------------------------------------------------------------- + // Step 10: Simulate the transfer + // ----------------------------------------------------------------------- + + console.log(chalk.yellow('\nStep 10: Simulating token transfer...')); + + console.log( + chalk.gray( + ' Simulation executes the invocation without committing ledger changes or broadcasting the transaction.', + ), + ); + + let transferSimulation: rpc.Api.SimulateTransactionResponse; + + try { + transferSimulation = await server.simulateTransaction(transfer.transaction); + } catch (error: unknown) { + console.error(chalk.red(` Simulation request failed: ${getErrorMessage(error)}`)); + console.log( + chalk.gray(' Verify the RPC endpoint, network, token contract ID and supplied addresses.'), + ); + return; + } + + console.log(chalk.gray(` Simulation ledger: ${transferSimulation.latestLedger}`)); + + if (rpc.Api.isSimulationError(transferSimulation)) { + console.log(chalk.yellow(' Transfer simulation: FAILED')); + + const message = transferSimulation.error; + + console.log(chalk.gray(` RPC diagnostic: ${message}`)); + + if ((balance !== null && transferAmount > balance) || isInsufficientBalanceError(message)) { + console.log( + chalk.yellow( + ' Diagnosis: insufficient balance or an unavailable source balance prevented the transfer.', + ), + ); + + console.log( + chalk.gray( + ' This is an expected application-level condition. Reduce the amount, fund the source address, or select another holder.', + ), + ); + } else { + console.log( + chalk.gray( + ' The contract rejected the transfer for another reason. Check authorization, address validity, token state and diagnostic events.', + ), + ); + } + + printDiagnosticEvents(transferSimulation.events); + } else { + if (rpc.Api.isSimulationRestore(transferSimulation)) { + console.log(chalk.yellow(' Transfer simulation: RESTORE REQUIRED')); + + console.log( + chalk.gray( + ' Archived ledger state must be restored before this transfer can be submitted.', + ), + ); + + console.log( + chalk.gray( + ` Restore minimum resource fee: ${transferSimulation.restorePreamble.minResourceFee} stroops`, + ), + ); + } else { + console.log(chalk.green(' Transfer simulation: SUCCESS')); + } + + console.log(` Estimated Soroban resource fee: ${transferSimulation.minResourceFee} stroops`); + + if (transferSimulation.result) { + console.log(chalk.gray(` Return ScVal: ${transferSimulation.result.retval.switch().name}`)); + + console.log( + chalk.gray( + ` Decoded return value: ${formatNativeValue( + decodeScVal(transferSimulation.result.retval), + )}`, + ), + ); + } + + const authorizationEntries = transferSimulation.result?.auth ?? []; + + console.log(` Authorization entries required: ${authorizationEntries.length}`); + + console.log( + chalk.gray( + ' A real application would now prepare/assemble the transaction from this simulation result, collect any required authorization, sign it, and only then submit it.', + ), + ); + } + + // ----------------------------------------------------------------------- + // Step 11: Explain Stellar asset/token relationship + // ----------------------------------------------------------------------- + + console.log(chalk.yellow('\nStep 11: Stellar assets and Soroban token contracts')); + + console.log( + chalk.cyan( + [ + ' • Soroban token contracts expose fungible-token operations such as metadata, balances,', + ' allowances and transfers.', + ' • Classic Stellar assets can be used by smart contracts through their deterministic', + ' Stellar Asset Contract (SAC).', + ' • The SAC acts as the smart-contract representation of a classic Stellar asset, keeping', + ' contract-side balances and operations consistent with the underlying Stellar asset.', + ' • A user-deployed Soroban token contract can also implement the standard token interface', + ' without representing a classic Stellar-issued asset.', + ' • Optional methods must be detected rather than assumed; total_supply is an example.', + ].join('\n'), + ), + ); + + console.log(chalk.bold.green('\nSoroban token contract inspection complete.')); + + console.log( + chalk.gray( + 'No transaction was signed or submitted. The transfer demonstration was simulation-only.', + ), + ); +} diff --git a/src/examples/117-soroban-auth-tree.ts b/src/examples/117-soroban-auth-tree.ts new file mode 100644 index 0000000..2a15c01 --- /dev/null +++ b/src/examples/117-soroban-auth-tree.ts @@ -0,0 +1,744 @@ +import { + Account, + Address, + Asset, + Contract, + Keypair, + Networks, + Transaction, + TransactionBuilder, + nativeToScVal, + rpc, + scValToNative, + xdr, +} from 'stellar-sdk-v16'; +import chalk from 'chalk'; + +/** + * ISSUE-117: Soroban Authorization Tree Visualization + * + * Soroban authorization entries contain an invocation tree describing exactly + * which contract calls an address has authorized. + * + * A single authorization entry contains: + * + * credentials + * -> who is authorizing + * + * rootInvocation + * -> the top-level authorized call + * -> subInvocation + * -> subInvocation + * -> ... + * + * Nested authorization becomes especially important when one contract calls + * another contract. A user may appear to authorize one high-level action while + * the authorization tree also covers token transfers or other nested calls. + * + * This example: + * + * 1. Builds an invocation that requires Soroban authorization. + * 2. Simulates the transaction through Soroban RPC. + * 3. Extracts all returned authorization entries. + * 4. Associates each entry with its required signer. + * 5. Visualizes root and nested invocations. + * 6. Displays contract IDs, function names, arguments and child counts. + * 7. Reports signature status. + * 8. Decodes common ScVal argument types. + * 9. Uses iterative tree traversal so deeply nested authorization trees do not + * depend on JavaScript recursion depth. + * 10. Handles empty authorization results and simulation failures gracefully. + * + * The example performs simulation only. No transaction is signed or submitted. + */ + +const DEFAULT_RPC_URL = 'https://soroban-testnet.stellar.org'; +const BASE_FEE = '100'; +const DEFAULT_ALLOWANCE_AMOUNT = 0n; +const DEFAULT_ALLOWANCE_LIFETIME = 100; + +export interface SorobanAuthTreeParams { + rpcUrl?: string; + contractId?: string; + sourceAccountId?: string; + spenderId?: string; + networkPassphrase?: string; + allowanceAmount?: string; +} + +export interface AuthorizationTreeNode { + invocation: xdr.SorobanAuthorizedInvocation; + depth: number; + path: string; + kind: 'root' | 'nested'; +} + +export interface DecodedInvocation { + functionType: string; + contractId?: string; + functionName?: string; + arguments: string[]; + subInvocationCount: number; +} + +export interface AuthorizationSignerInfo { + credentialType: string; + address: string; + signatureStatus: string; + nonce?: string; + signatureExpirationLedger?: number; +} + +/** + * Convert an unknown thrown value into a readable message. + */ +export function getErrorMessage(error: unknown): string { + if (error instanceof Error) { + return error.message; + } + + return String(error); +} + +/** + * Render JSON without failing when a decoded Soroban value contains bigint. + */ +function bigintReplacer(_key: string, value: unknown): unknown { + return typeof value === 'bigint' ? value.toString() : value; +} + +/** + * Decode a Soroban address into its normal G... or C... representation. + */ +export function describeScAddress(address: xdr.ScAddress): string { + try { + return String(scValToNative(xdr.ScVal.scvAddress(address))); + } catch { + return `(undecodable address: ${address.switch().name})`; + } +} + +/** + * Convert common ScVal argument types into readable text. + * + * scValToNative already handles the standard Soroban primitive and collection + * types. Additional formatting is applied here for byte arrays, bigint values + * and structured values. + */ +export function formatScVal(value: xdr.ScVal): string { + const type = value.switch().name; + + try { + const native = scValToNative(value); + + if (typeof native === 'bigint') { + return `${type}(${native.toString()})`; + } + + if (typeof native === 'string') { + return `${type}("${native}")`; + } + + if (typeof native === 'boolean' || typeof native === 'number') { + return `${type}(${String(native)})`; + } + + if (Buffer.isBuffer(native)) { + return `${type}(0x${native.toString('hex')})`; + } + + if (native instanceof Uint8Array) { + return `${type}(0x${Buffer.from(native).toString('hex')})`; + } + + if (native === null) { + return `${type}(null)`; + } + + if (native === undefined) { + return `${type}(undefined)`; + } + + return `${type}(${JSON.stringify(native, bigintReplacer)})`; + } catch { + return `${type}(raw-xdr=${value.toXDR('base64')})`; + } +} + +/** + * Iteratively flatten an authorization invocation tree. + * + * Using an explicit stack instead of recursive function calls means deeply + * nested trees are not limited by JavaScript's call-stack depth. + */ +export function flattenAuthorizationTree( + rootInvocation: xdr.SorobanAuthorizedInvocation, +): AuthorizationTreeNode[] { + interface PendingNode { + invocation: xdr.SorobanAuthorizedInvocation; + depth: number; + path: string; + } + + const flattened: AuthorizationTreeNode[] = []; + + const stack: PendingNode[] = [ + { + invocation: rootInvocation, + depth: 0, + path: '0', + }, + ]; + + while (stack.length > 0) { + const current = stack.pop(); + + if (!current) { + continue; + } + + flattened.push({ + invocation: current.invocation, + depth: current.depth, + path: current.path, + kind: current.depth === 0 ? 'root' : 'nested', + }); + + const children = current.invocation.subInvocations(); + + /* + * Push children in reverse order because a stack is LIFO. This preserves + * their natural left-to-right order when we later pop them. + */ + for (let index = children.length - 1; index >= 0; index -= 1) { + stack.push({ + invocation: children[index], + depth: current.depth + 1, + path: `${current.path}.${index}`, + }); + } + } + + return flattened; +} + +/** + * Decode the contract/function part of one authorization-tree node. + */ +export function decodeInvocation(invocation: xdr.SorobanAuthorizedInvocation): DecodedInvocation { + const authorizedFunction = invocation.function(); + const functionType = authorizedFunction.switch().name; + const subInvocationCount = invocation.subInvocations().length; + + /* + * Contract calls are the most common authorization nodes and contain the + * contract address, function name and arguments directly. + */ + if (functionType === 'sorobanAuthorizedFunctionTypeContractFn') { + const contractFunction = authorizedFunction.contractFn(); + + return { + functionType, + contractId: describeScAddress(contractFunction.contractAddress()), + functionName: contractFunction.functionName().toString(), + arguments: contractFunction.args().map((argument) => formatScVal(argument)), + subInvocationCount, + }; + } + + /* + * Contract-creation authorization variants do not have the same + * contract/function/argument shape as normal function calls. Keep the node + * visible instead of failing to visualize the rest of the tree. + */ + return { + functionType, + arguments: [], + subInvocationCount, + }; +} + +/** + * Inspect the credential section of one authorization entry and determine the + * signer associated with it. + * + * Source-account credentials contain no explicit address in the auth entry; + * they mean "the transaction source account authorizes this invocation". + */ +export function getAuthorizationSigner( + credentials: xdr.SorobanCredentials, + sourceAccountId: string, +): AuthorizationSignerInfo { + const credentialType = credentials.switch().name; + + if (credentialType === 'sorobanCredentialsSourceAccount') { + return { + credentialType, + address: sourceAccountId, + signatureStatus: 'transaction signature required; no separate auth-entry signature is stored', + }; + } + + if (credentialType === 'sorobanCredentialsAddress') { + const addressCredentials = credentials.address(); + const signature = addressCredentials.signature(); + + const signaturePresent = signature.switch().name !== 'scvVoid'; + + return { + credentialType, + address: describeScAddress(addressCredentials.address()), + nonce: addressCredentials.nonce().toString(), + signatureExpirationLedger: addressCredentials.signatureExpirationLedger(), + signatureStatus: signaturePresent + ? 'signature present' + : 'unsigned authorization entry returned by simulation', + }; + } + + /* + * Newer protocol versions may introduce additional credential variants. + * Keeping the entry visible is safer than throwing during visualization. + */ + return { + credentialType, + address: '(unable to decode signer for this credential variant)', + signatureStatus: 'unknown', + }; +} + +/** + * Produce a readable summary for one invocation node. + */ +function printInvocationNode(node: AuthorizationTreeNode): void { + const decoded = decodeInvocation(node.invocation); + + const indentation = ' '.repeat(node.depth + 2); + + const role = + node.kind === 'root' + ? chalk.bold.cyan(`ROOT [${node.path}]`) + : chalk.bold.magenta(`NESTED [${node.path}]`); + + console.log(`${indentation}${role}`); + + console.log(`${indentation} Function type : ${decoded.functionType}`); + + if (decoded.contractId) { + console.log(`${indentation} Contract ID : ${decoded.contractId}`); + } else { + console.log( + chalk.gray(`${indentation} Contract ID : not applicable to this authorization variant`), + ); + } + + if (decoded.functionName) { + console.log(`${indentation} Function : ${decoded.functionName}`); + } else { + console.log( + chalk.gray(`${indentation} Function : host-level contract creation operation`), + ); + } + + if (decoded.arguments.length === 0) { + console.log(`${indentation} Arguments : (none)`); + } else { + console.log(`${indentation} Arguments :`); + + decoded.arguments.forEach((argument, index) => { + console.log(`${indentation} [${index}] ${argument}`); + }); + } + + console.log(`${indentation} Sub-invocations : ${decoded.subInvocationCount}`); +} + +/** + * Visualize an entire authorization entry. + */ +export function printAuthorizationEntry( + entry: xdr.SorobanAuthorizationEntry, + index: number, + sourceAccountId: string, +): void { + console.log(chalk.bold(`\n Authorization entry #${index + 1}`)); + + const signer = getAuthorizationSigner(entry.credentials(), sourceAccountId); + + console.log(chalk.yellow(' Required signer')); + console.log(` Credential type : ${signer.credentialType}`); + console.log(` Authorized addr : ${signer.address}`); + + if (signer.nonce !== undefined) { + console.log(` Nonce : ${signer.nonce}`); + } + + if (signer.signatureExpirationLedger !== undefined) { + console.log(` Signature expiry: ledger ${signer.signatureExpirationLedger}`); + } + + console.log(` Signature status: ${signer.signatureStatus}`); + + const flattened = flattenAuthorizationTree(entry.rootInvocation()); + + const nestedCount = flattened.filter((node) => node.kind === 'nested').length; + const maximumDepth = flattened.reduce((highest, node) => Math.max(highest, node.depth), 0); + + console.log(chalk.yellow('\n Authorization hierarchy')); + console.log(` Total nodes : ${flattened.length}`); + console.log(` Nested nodes : ${nestedCount}`); + console.log(` Maximum depth : ${maximumDepth}`); + + flattened.forEach((node) => { + printInvocationNode(node); + }); +} + +/** + * Build a token allowance invocation that requires authorization. + * + * `approve` is useful for this example because it calls require_auth() for the + * owner/from address. Using amount 0 means the example does not need the source + * address to hold XLM in order to demonstrate the authorization structure. + */ +export function buildAuthorizedInvocation( + sourceAccountId: string, + spenderId: string, + contractId: string, + networkPassphrase: string, + allowanceAmount: bigint, + expirationLedger: number, +): Transaction { + const sourceAccount = new Account(sourceAccountId, '0'); + const tokenContract = new Contract(contractId); + + const operation = tokenContract.call( + 'approve', + Address.fromString(sourceAccountId).toScVal(), + Address.fromString(spenderId).toScVal(), + nativeToScVal(allowanceAmount, { type: 'i128' }), + nativeToScVal(expirationLedger, { type: 'u32' }), + ); + + return new TransactionBuilder(sourceAccount, { + fee: BASE_FEE, + networkPassphrase, + }) + .addOperation(operation) + .setTimeout(30) + .build(); +} + +/** + * Print useful diagnostic events when simulation fails. + */ +function printSimulationDiagnostics(events: xdr.DiagnosticEvent[]): void { + if (events.length === 0) { + console.log(chalk.gray(' No diagnostic events were returned.')); + return; + } + + console.log(chalk.gray(` Diagnostic events: ${events.length}`)); + + events.slice(0, 5).forEach((event, index) => { + try { + const contractEvent = event.event(); + const body = contractEvent.body().v0(); + + const topics = body + .topics() + .map((topic) => formatScVal(topic)) + .join(', '); + + console.log( + chalk.gray(` [${index + 1}] successfulContractCall=${event.inSuccessfulContractCall()}`), + ); + + if (topics.length > 0) { + console.log(chalk.gray(` topics: ${topics}`)); + } + + console.log(chalk.gray(` data : ${formatScVal(body.data())}`)); + } catch (error: unknown) { + console.log( + chalk.gray( + ` [${index + 1}] Could not decode diagnostic event: ${getErrorMessage(error)}`, + ), + ); + } + }); + + if (events.length > 5) { + console.log(chalk.gray(` ... ${events.length - 5} additional diagnostic event(s) omitted.`)); + } +} + +/** + * Run ISSUE-117. + */ +export async function run(params: SorobanAuthTreeParams = {}): Promise { + const rpcUrl = params.rpcUrl?.trim() || process.env.SOROBAN_RPC_URL?.trim() || DEFAULT_RPC_URL; + + const networkPassphrase = + params.networkPassphrase?.trim() || process.env.NETWORK_PASSPHRASE?.trim() || Networks.TESTNET; + + /* + * The native XLM Stellar Asset Contract is always available for the network + * and provides the standard token interface, including approve(). + */ + const defaultContractId = Asset.native().contractId(networkPassphrase); + + const contractId = + params.contractId?.trim() || + process.env.AUTH_CONTRACT_ID?.trim() || + process.env.CONTRACT_ID?.trim() || + defaultContractId; + + /* + * No secret keys are needed. Simulation records authorization requirements + * without signing or submitting the transaction. + */ + const sourceAccountId = + params.sourceAccountId?.trim() || + process.env.AUTH_SOURCE_ACCOUNT?.trim() || + Keypair.random().publicKey(); + + const spenderId = + params.spenderId?.trim() || + process.env.AUTH_SPENDER_ACCOUNT?.trim() || + Keypair.random().publicKey(); + + const allowanceInput = + params.allowanceAmount?.trim() || + process.env.AUTH_ALLOWANCE_AMOUNT?.trim() || + DEFAULT_ALLOWANCE_AMOUNT.toString(); + + let allowanceAmount: bigint; + + try { + allowanceAmount = BigInt(allowanceInput); + + if (allowanceAmount < 0n) { + throw new Error('allowance cannot be negative'); + } + } catch { + console.error( + chalk.red(`Invalid AUTH_ALLOWANCE_AMOUNT "${allowanceInput}". Use a non-negative integer.`), + ); + + return; + } + + console.log(chalk.bold('\nSoroban Authorization Tree Visualization Example')); + + console.log( + chalk.gray( + 'Simulate an authorized contract call and turn its Soroban authorization entries into a readable invocation tree.', + ), + ); + + console.log(chalk.yellow('\nConfiguration')); + console.log(` RPC endpoint : ${rpcUrl}`); + console.log(` Contract : ${contractId}`); + console.log(` Authorized address : ${sourceAccountId}`); + console.log(` Spender address : ${spenderId}`); + console.log(` Allowance amount : ${allowanceAmount.toString()}`); + + const server = new rpc.Server(rpcUrl); + + // ----------------------------------------------------------------------- + // Step 1: Connect to Soroban RPC + // ----------------------------------------------------------------------- + + console.log(chalk.yellow('\nStep 1: Connecting to Soroban RPC...')); + + let latestLedger: number; + + try { + const ledger = await server.getLatestLedger(); + + latestLedger = ledger.sequence; + + console.log(chalk.green(` Connected. Latest ledger sequence: ${latestLedger}`)); + } catch (error: unknown) { + console.error(chalk.red(` Unable to reach Soroban RPC: ${getErrorMessage(error)}`)); + + console.log( + chalk.gray( + ' Check SOROBAN_RPC_URL and confirm that the endpoint matches the selected network.', + ), + ); + + return; + } + + // ----------------------------------------------------------------------- + // Step 2: Build invocation that requires authorization + // ----------------------------------------------------------------------- + + console.log( + chalk.yellow('\nStep 2: Building a Soroban invocation that requires authorization...'), + ); + + /* + * Keep the allowance alive for a short demonstration window. + */ + const expirationLedger = latestLedger + DEFAULT_ALLOWANCE_LIFETIME; + + let transaction: Transaction; + + try { + transaction = buildAuthorizedInvocation( + sourceAccountId, + spenderId, + contractId, + networkPassphrase, + allowanceAmount, + expirationLedger, + ); + } catch (error: unknown) { + console.error(chalk.red(` Could not build authorization example: ${getErrorMessage(error)}`)); + + return; + } + + console.log(chalk.green(' Transaction constructed.')); + console.log(` Method : approve`); + console.log(` Source/authorizer : ${sourceAccountId}`); + console.log(` Spender : ${spenderId}`); + console.log(` Amount : ${allowanceAmount.toString()}`); + console.log(` Allowance expires : ledger ${expirationLedger}`); + console.log(` Transaction signed : no`); + console.log(` Transaction sent : no`); + + // ----------------------------------------------------------------------- + // Step 3: Simulate and obtain authorization entries + // ----------------------------------------------------------------------- + + console.log( + chalk.yellow('\nStep 3: Simulating transaction and recording authorization requirements...'), + ); + + let simulation: rpc.Api.SimulateTransactionResponse; + + try { + simulation = await server.simulateTransaction(transaction); + } catch (error: unknown) { + console.error(chalk.red(` Simulation request failed: ${getErrorMessage(error)}`)); + + console.log( + chalk.gray(' Confirm the RPC endpoint, contract ID, network and argument addresses.'), + ); + + return; + } + + console.log(` Simulation ledger: ${simulation.latestLedger}`); + + if (rpc.Api.isSimulationError(simulation)) { + console.error(chalk.red(' Simulation failed.')); + + console.log(chalk.gray(` RPC diagnostic: ${simulation.error}`)); + + printSimulationDiagnostics(simulation.events); + + console.log( + chalk.gray( + ' No authorization tree can be produced because contract execution did not simulate successfully.', + ), + ); + + return; + } + + if (rpc.Api.isSimulationRestore(simulation)) { + console.log(chalk.yellow(' Simulation requires archived state restoration.')); + + console.log( + chalk.gray( + ' Restore the required state and simulate again before relying on the authorization tree.', + ), + ); + + return; + } + + console.log(chalk.green(' Simulation succeeded.')); + + const authorizationEntries = simulation.result?.auth ?? []; + + // ----------------------------------------------------------------------- + // Step 4: Handle transactions with no authorization entries + // ----------------------------------------------------------------------- + + console.log(chalk.yellow('\nStep 4: Extracting authorization entries...')); + + if (authorizationEntries.length === 0) { + console.log(chalk.yellow(' Simulation returned no authorization entries.')); + + console.log( + chalk.gray( + [ + ' This is valid for contract calls that do not invoke require_auth() or', + ' require_auth_for_args(). Read-only calls commonly produce an empty result.', + '', + ' The default example uses the token approve() method because that method', + ' normally requires authorization from its owner/from address.', + ].join('\n'), + ), + ); + + return; + } + + console.log( + chalk.green( + ` Found ${authorizationEntries.length} authorization ${ + authorizationEntries.length === 1 ? 'entry' : 'entries' + }.`, + ), + ); + + // ----------------------------------------------------------------------- + // Step 5: Visualize every authorization tree + // ----------------------------------------------------------------------- + + console.log(chalk.yellow('\nStep 5: Authorization tree visualization')); + + authorizationEntries.forEach((entry, index) => { + printAuthorizationEntry(entry, index, sourceAccountId); + }); + + // ----------------------------------------------------------------------- + // Step 6: Explain what the tree means + // ----------------------------------------------------------------------- + + console.log(chalk.yellow('\nStep 6: Understanding the authorization tree')); + + console.log( + chalk.cyan( + [ + ' • Each authorization entry belongs to one required authorizer.', + ' • ROOT is the first invocation covered by that authorization.', + ' • NESTED nodes are additional calls covered by the same authorization.', + ' • Contract ID + function + arguments define exactly what is being authorized.', + ' • A signer should inspect the entire tree, not only its root operation.', + ' • Simulation records the required authorization structure before submission.', + ' • Entries returned by simulation are normally unsigned and can later be', + ' signed or satisfied by the transaction source account as appropriate.', + ].join('\n'), + ), + ); + + console.log( + chalk.gray( + '\nNested trees commonly appear when a high-level contract call invokes another contract that also requires authorization.', + ), + ); + + console.log(chalk.bold.green('\nSoroban authorization tree visualization complete.')); + + console.log( + chalk.gray('No transaction or authorization entry was signed or submitted by this example.'), + ); +} diff --git a/src/examples/118-ledger-footprint-analysis.ts b/src/examples/118-ledger-footprint-analysis.ts new file mode 100644 index 0000000..cc9d03e --- /dev/null +++ b/src/examples/118-ledger-footprint-analysis.ts @@ -0,0 +1,985 @@ +import { + Account, + Address, + Asset, + Contract, + Keypair, + Networks, + StrKey, + Transaction, + TransactionBuilder, + rpc, + scValToNative, + xdr, +} from 'stellar-sdk-v16'; +import chalk from 'chalk'; + +/** + * ISSUE-118: Soroban Ledger Footprint Analysis + * + * Soroban transactions declare the ledger entries they need to read and write + * through a ledger footprint. + * + * Simulation is normally used to discover that footprint before a transaction + * is prepared, signed, and submitted. + * + * This example demonstrates how to: + * + * 1. Connect to Soroban RPC. + * 2. Build two Soroban contract invocations. + * 3. Simulate both invocations. + * 4. Extract their ledger footprints. + * 5. Separate read-only and read-write entries. + * 6. Decode common ledger-key types. + * 7. Identify contract-data durability. + * 8. Identify contract-instance entries. + * 9. Display raw XDR for detailed inspection. + * 10. Compare the footprints from two invocations. + * 11. Explain why footprints are required by Soroban. + * 12. Handle empty footprints and simulation failures gracefully. + * + * The example performs simulation only. Nothing is signed or submitted. + */ + +const DEFAULT_RPC_URL = 'https://soroban-testnet.stellar.org'; +const BASE_FEE = '100'; +const DEFAULT_METHOD_A = 'decimals'; +const DEFAULT_METHOD_B = 'name'; + +export interface LedgerFootprintParams { + rpcUrl?: string; + networkPassphrase?: string; + contractId?: string; + methodA?: string; + methodB?: string; + balanceAddress?: string; +} + +export type FootprintAccess = 'read-only' | 'read-write'; + +export type ContractStorageType = + | 'persistent' + | 'temporary' + | 'instance' + | 'contract-code' + | 'not-contract-storage'; + +export interface FootprintEntryInfo { + access: FootprintAccess; + ledgerType: string; + description: string; + rawXdr: string; + isContractEntry: boolean; + storageType: ContractStorageType; +} + +export interface FootprintSummary { + readOnlyCount: number; + readWriteCount: number; + totalCount: number; + contractEntryCount: number; + persistentEntryCount: number; + temporaryEntryCount: number; + instanceEntryCount: number; + contractCodeCount: number; + entries: FootprintEntryInfo[]; +} + +export interface FootprintComparison { + firstTotal: number; + secondTotal: number; + totalDelta: number; + firstReadOnly: number; + secondReadOnly: number; + readOnlyDelta: number; + firstReadWrite: number; + secondReadWrite: number; + readWriteDelta: number; + commonEntries: number; + onlyInFirst: number; + onlyInSecond: number; +} + +export interface SimulatedFootprint { + label: string; + method: string; + success: boolean; + restoreRequired: boolean; + error?: string; + latestLedger?: number; + summary?: FootprintSummary; +} + +/** + * Return a readable error message without assuming that the thrown value is an + * Error instance. + */ +export function getErrorMessage(error: unknown): string { + if (error instanceof Error) { + return error.message; + } + + return String(error); +} + +/** + * Decode an ScVal for display while preserving a raw fallback for unusual + * values. + */ +export function formatScVal(value: xdr.ScVal): string { + if (value.switch() === xdr.ScValType.scvLedgerKeyContractInstance()) { + return ''; + } + + if (value.switch() === xdr.ScValType.scvLedgerKeyNonce()) { + try { + return ``; + } catch { + return ''; + } + } + + try { + const native = scValToNative(value); + + if (typeof native === 'bigint') { + return native.toString(); + } + + if (native instanceof Uint8Array) { + return `0x${Buffer.from(native).toString('hex')}`; + } + + if (native === undefined) { + return '(undefined)'; + } + + if (native === null) { + return '(null)'; + } + + if (typeof native === 'object') { + return JSON.stringify(native, bigintReplacer); + } + + return String(native); + } catch { + return `${value.switch().name}(raw-xdr=${value.toXDR('base64')})`; + } +} + +/** + * JSON.stringify cannot serialize bigint directly. + */ +function bigintReplacer(_key: string, value: unknown): unknown { + if (typeof value === 'bigint') { + return value.toString(); + } + + if (value instanceof Uint8Array) { + return `0x${Buffer.from(value).toString('hex')}`; + } + + return value; +} + +/** + * Convert a contract ScAddress into its normal C... form when possible. + */ +export function describeScAddress(address: xdr.ScAddress): string { + try { + return String(scValToNative(xdr.ScVal.scvAddress(address))); + } catch { + return `(undecodable ${address.switch().name})`; + } +} + +/** + * Determine the Soroban storage category represented by a ledger key. + * + * Contract instance entries are contract-data entries with persistent + * durability and the special ledger-key-contract-instance ScVal. They are + * reported separately because they have a distinct role even though their + * durability is persistent. + */ +export function identifyStorageType(key: xdr.LedgerKey): ContractStorageType { + try { + if (key.switch() === xdr.LedgerEntryType.contractCode()) { + return 'contract-code'; + } + + if (key.switch() !== xdr.LedgerEntryType.contractData()) { + return 'not-contract-storage'; + } + + const contractData = key.contractData(); + + if (contractData.key().switch() === xdr.ScValType.scvLedgerKeyContractInstance()) { + return 'instance'; + } + + const durability = contractData.durability().name.toLowerCase(); + + if (durability.includes('temporary')) { + return 'temporary'; + } + + return 'persistent'; + } catch { + return 'not-contract-storage'; + } +} + +/** + * Decode a ledger key into readable information. + * + * Complex/uncommon keys still retain their raw XDR, so the example never loses + * the exact ledger-key representation returned by simulation. + */ +export function describeLedgerKey(key: xdr.LedgerKey): string { + try { + switch (key.switch()) { + case xdr.LedgerEntryType.contractData(): { + const data = key.contractData(); + + const contractId = describeScAddress(data.contract()); + + const storageType = identifyStorageType(key); + + const durability = data.durability().name; + + return [ + 'contractData', + `contract=${contractId}`, + `storage=${storageType}`, + `durability=${durability}`, + `key=${formatScVal(data.key())}`, + ].join(' '); + } + + case xdr.LedgerEntryType.contractCode(): { + const hash = key.contractCode().hash().toString('hex'); + + return `contractCode wasmHash=${hash}`; + } + + case xdr.LedgerEntryType.account(): { + try { + const publicKey = StrKey.encodeEd25519PublicKey(key.account().accountId().ed25519()); + + return `account address=${publicKey}`; + } catch { + return 'account'; + } + } + + case xdr.LedgerEntryType.trustline(): + return 'trustline'; + + case xdr.LedgerEntryType.offer(): + return 'offer'; + + case xdr.LedgerEntryType.data(): + return 'classic-account-data'; + + case xdr.LedgerEntryType.claimableBalance(): + return 'claimableBalance'; + + case xdr.LedgerEntryType.liquidityPool(): + return 'liquidityPool'; + + case xdr.LedgerEntryType.configSetting(): + return 'configSetting'; + + case xdr.LedgerEntryType.ttl(): { + const keyHash = key.ttl().keyHash().toString('hex'); + + return `ttl keyHash=${keyHash}`; + } + + default: + return key.switch().name; + } + } catch (error: unknown) { + return `(could not decode ledger key: ${getErrorMessage(error)})`; + } +} + +/** + * Turn one raw ledger key into the normalized representation used by the + * report and comparison helpers. + */ +export function inspectFootprintEntry( + key: xdr.LedgerKey, + access: FootprintAccess, +): FootprintEntryInfo { + const storageType = identifyStorageType(key); + + const isContractEntry = + key.switch() === xdr.LedgerEntryType.contractData() || + key.switch() === xdr.LedgerEntryType.contractCode(); + + return { + access, + ledgerType: key.switch().name, + description: describeLedgerKey(key), + rawXdr: key.toXDR('base64'), + isContractEntry, + storageType, + }; +} + +/** + * Extract and classify all entries in a Soroban footprint. + */ +export function analyzeFootprint(footprint: xdr.LedgerFootprint): FootprintSummary { + const readOnly = footprint.readOnly(); + const readWrite = footprint.readWrite(); + + const entries: FootprintEntryInfo[] = [ + ...readOnly.map((key) => inspectFootprintEntry(key, 'read-only')), + ...readWrite.map((key) => inspectFootprintEntry(key, 'read-write')), + ]; + + /* + * Instance entries have persistent durability underneath, but the report + * exposes them as their own category. For the persistent total, count both + * ordinary persistent contract data and contract instances. + */ + const persistentEntryCount = entries.filter( + (entry) => entry.storageType === 'persistent' || entry.storageType === 'instance', + ).length; + + return { + readOnlyCount: readOnly.length, + readWriteCount: readWrite.length, + totalCount: entries.length, + + contractEntryCount: entries.filter((entry) => entry.isContractEntry).length, + + persistentEntryCount, + + temporaryEntryCount: entries.filter((entry) => entry.storageType === 'temporary').length, + + instanceEntryCount: entries.filter((entry) => entry.storageType === 'instance').length, + + contractCodeCount: entries.filter((entry) => entry.storageType === 'contract-code').length, + + entries, + }; +} + +/** + * Compare two analyzed footprints by raw ledger-key identity. + * + * Access mode is deliberately not included in the identity. The same ledger + * key appearing read-only in one invocation and read-write in another should + * still be recognized as the same underlying ledger entry. + */ +export function compareFootprints( + first: FootprintSummary, + second: FootprintSummary, +): FootprintComparison { + const firstKeys = new Set(first.entries.map((entry) => entry.rawXdr)); + const secondKeys = new Set(second.entries.map((entry) => entry.rawXdr)); + + let commonEntries = 0; + + firstKeys.forEach((key) => { + if (secondKeys.has(key)) { + commonEntries += 1; + } + }); + + return { + firstTotal: first.totalCount, + secondTotal: second.totalCount, + totalDelta: second.totalCount - first.totalCount, + + firstReadOnly: first.readOnlyCount, + secondReadOnly: second.readOnlyCount, + readOnlyDelta: second.readOnlyCount - first.readOnlyCount, + + firstReadWrite: first.readWriteCount, + secondReadWrite: second.readWriteCount, + readWriteDelta: second.readWriteCount - first.readWriteCount, + + commonEntries, + onlyInFirst: firstKeys.size - commonEntries, + onlyInSecond: secondKeys.size - commonEntries, + }; +} + +/** + * Build one Soroban invocation. + */ +export function buildInvocation( + sourceAccountId: string, + networkPassphrase: string, + contractId: string, + method: string, + args: xdr.ScVal[] = [], +): Transaction { + const sourceAccount = new Account(sourceAccountId, '0'); + + const contract = new Contract(contractId); + + return new TransactionBuilder(sourceAccount, { + fee: BASE_FEE, + networkPassphrase, + }) + .addOperation(contract.call(method, ...args)) + .setTimeout(30) + .build(); +} + +/** + * Determine arguments for the built-in demonstration methods. + * + * `decimals` takes no arguments. + * `balance` takes one address. + * + * Custom methods can still be selected through environment variables. They + * are invoked with no arguments so any mismatch is reported cleanly by + * simulation rather than crashing the example. + */ +export function buildMethodArguments(method: string, balanceAddress: string): xdr.ScVal[] { + if (method === 'balance') { + return [Address.fromString(balanceAddress).toScVal()]; + } + + return []; +} + +/** + * Simulate an invocation and extract its footprint. + */ +async function simulateFootprint( + server: rpc.Server, + label: string, + method: string, + sourceAccountId: string, + networkPassphrase: string, + contractId: string, + args: xdr.ScVal[], +): Promise { + let transaction: Transaction; + + try { + transaction = buildInvocation(sourceAccountId, networkPassphrase, contractId, method, args); + } catch (error: unknown) { + return { + label, + method, + success: false, + restoreRequired: false, + error: `Could not build invocation: ${getErrorMessage(error)}`, + }; + } + + let simulation: rpc.Api.SimulateTransactionResponse; + + try { + simulation = await server.simulateTransaction(transaction); + } catch (error: unknown) { + return { + label, + method, + success: false, + restoreRequired: false, + error: `RPC simulation request failed: ${getErrorMessage(error)}`, + }; + } + + if (rpc.Api.isSimulationError(simulation)) { + return { + label, + method, + success: false, + restoreRequired: false, + latestLedger: simulation.latestLedger, + error: simulation.error, + }; + } + + if (rpc.Api.isSimulationRestore(simulation)) { + return { + label, + method, + success: false, + restoreRequired: true, + latestLedger: simulation.latestLedger, + error: + 'Simulation detected archived ledger state that must be restored before this invocation can be analyzed safely.', + }; + } + + try { + const transactionData = simulation.transactionData.build(); + + const footprint = transactionData.resources().footprint(); + + return { + label, + method, + success: true, + restoreRequired: false, + latestLedger: simulation.latestLedger, + summary: analyzeFootprint(footprint), + }; + } catch (error: unknown) { + return { + label, + method, + success: false, + restoreRequired: false, + latestLedger: simulation.latestLedger, + error: `Simulation succeeded but its footprint could not be decoded: ${getErrorMessage( + error, + )}`, + }; + } +} + +/** + * Print one footprint category. + */ +function printEntryGroup(title: string, entries: FootprintEntryInfo[]): void { + console.log(chalk.cyan(`\n ${title} (${entries.length})`)); + + if (entries.length === 0) { + console.log(chalk.gray(' (none)')); + return; + } + + entries.forEach((entry, index) => { + console.log(` [${index + 1}] ${entry.description}`); + + /* + * Raw XDR makes every complex ledger key inspectable even when its + * high-level representation is unfamiliar to this example. + */ + console.log(chalk.gray(` Type : ${entry.ledgerType}`)); + + console.log(chalk.gray(` Storage : ${entry.storageType}`)); + + console.log(chalk.gray(` Raw XDR : ${entry.rawXdr}`)); + }); +} + +/** + * Display one complete footprint report. + */ +function printFootprintReport(result: SimulatedFootprint): void { + console.log(chalk.bold(`\n${result.label}: ${result.method}()`)); + + if (!result.success || !result.summary) { + if (result.restoreRequired) { + console.log(chalk.yellow(' Result : RESTORE REQUIRED')); + } else { + console.log(chalk.red(' Result : SIMULATION FAILED')); + } + + if (result.latestLedger !== undefined) { + console.log(` Ledger : ${result.latestLedger}`); + } + + console.log(chalk.gray(` Detail : ${result.error ?? 'No diagnostic information returned.'}`)); + + return; + } + + const summary = result.summary; + + console.log(chalk.green(' Result : SUCCESS')); + + if (result.latestLedger !== undefined) { + console.log(` Ledger : ${result.latestLedger}`); + } + + console.log(chalk.yellow('\n Footprint summary')); + + console.log(` Total entries : ${summary.totalCount}`); + console.log(` Read-only entries : ${summary.readOnlyCount}`); + console.log(` Read-write entries : ${summary.readWriteCount}`); + console.log(` Contract entries : ${summary.contractEntryCount}`); + console.log(` Persistent entries : ${summary.persistentEntryCount}`); + console.log(` Temporary entries : ${summary.temporaryEntryCount}`); + console.log(` Instance entries : ${summary.instanceEntryCount}`); + console.log(` Contract-code keys : ${summary.contractCodeCount}`); + + if (summary.totalCount === 0) { + console.log(chalk.yellow('\n The simulation returned an empty ledger footprint.')); + + console.log( + chalk.gray(' This can be valid when the invocation does not access ledger-backed state.'), + ); + + return; + } + + const readOnly = summary.entries.filter((entry) => entry.access === 'read-only'); + + const readWrite = summary.entries.filter((entry) => entry.access === 'read-write'); + + printEntryGroup('READ-ONLY', readOnly); + + printEntryGroup('READ-WRITE', readWrite); +} + +/** + * Display comparison information for two successful footprint simulations. + */ +function printComparison(first: SimulatedFootprint, second: SimulatedFootprint): void { + console.log(chalk.yellow('\nFootprint comparison')); + + if (!first.success || !first.summary) { + console.log( + chalk.gray( + ` Cannot compare because ${first.label} (${first.method}) did not produce a usable footprint.`, + ), + ); + + return; + } + + if (!second.success || !second.summary) { + console.log( + chalk.gray( + ` Cannot compare because ${second.label} (${second.method}) did not produce a usable footprint.`, + ), + ); + + return; + } + + const comparison = compareFootprints(first.summary, second.summary); + + console.log(` ${first.label.padEnd(15)}: ${first.method}()`); + + console.log(` ${second.label.padEnd(15)}: ${second.method}()`); + + console.log(''); + + console.log( + ` Total entries : ${comparison.firstTotal} -> ${comparison.secondTotal} (${formatDelta( + comparison.totalDelta, + )})`, + ); + + console.log( + ` Read-only entries : ${comparison.firstReadOnly} -> ${ + comparison.secondReadOnly + } (${formatDelta(comparison.readOnlyDelta)})`, + ); + + console.log( + ` Read-write entries : ${comparison.firstReadWrite} -> ${ + comparison.secondReadWrite + } (${formatDelta(comparison.readWriteDelta)})`, + ); + + console.log(` Common ledger keys : ${comparison.commonEntries}`); + console.log(` Only in first call : ${comparison.onlyInFirst}`); + console.log(` Only in second call : ${comparison.onlyInSecond}`); + + if (comparison.totalDelta > 0) { + console.log( + chalk.cyan( + `\n ${second.method}() touches ${comparison.totalDelta} more ledger ${ + comparison.totalDelta === 1 ? 'entry' : 'entries' + } than ${first.method}().`, + ), + ); + } else if (comparison.totalDelta < 0) { + console.log( + chalk.cyan( + `\n ${second.method}() touches ${Math.abs(comparison.totalDelta)} fewer ledger ${ + Math.abs(comparison.totalDelta) === 1 ? 'entry' : 'entries' + } than ${first.method}().`, + ), + ); + } else { + console.log( + chalk.cyan( + '\n Both invocations touch the same number of ledger entries, though the actual keys may differ.', + ), + ); + } + + if (comparison.firstReadWrite === 0 && comparison.secondReadWrite === 0) { + console.log( + chalk.gray( + ' Both default invocations are read-only and therefore produce no read-write footprint entries.', + ), + ); + } +} + +/** + * Render signed deltas consistently. + */ +export function formatDelta(value: number): string { + if (value > 0) { + return `+${value}`; + } + + return value.toString(); +} + +/** + * Run ISSUE-118. + */ +export async function run(params: LedgerFootprintParams = {}): Promise { + const rpcUrl = params.rpcUrl?.trim() || process.env.SOROBAN_RPC_URL?.trim() || DEFAULT_RPC_URL; + + const networkPassphrase = + params.networkPassphrase?.trim() || process.env.NETWORK_PASSPHRASE?.trim() || Networks.TESTNET; + + const defaultContractId = Asset.native().contractId(networkPassphrase); + + const contractId = + params.contractId?.trim() || + process.env.FOOTPRINT_CONTRACT_ID?.trim() || + process.env.CONTRACT_ID?.trim() || + defaultContractId; + + const methodA = + params.methodA?.trim() || process.env.FOOTPRINT_METHOD_A?.trim() || DEFAULT_METHOD_A; + + const methodB = + params.methodB?.trim() || process.env.FOOTPRINT_METHOD_B?.trim() || DEFAULT_METHOD_B; + + const balanceAddress = + params.balanceAddress?.trim() || + process.env.FOOTPRINT_BALANCE_ADDRESS?.trim() || + Keypair.random().publicKey(); + + console.log(chalk.bold('\nSoroban Ledger Footprint Analysis Example')); + + console.log( + chalk.gray( + 'Simulate two contract invocations, decode the ledger entries they access, and compare their footprints.', + ), + ); + + console.log(chalk.yellow('\nConfiguration')); + + console.log(` RPC endpoint : ${rpcUrl}`); + console.log(` Contract : ${contractId}`); + console.log(` Invocation A : ${methodA}()`); + console.log(` Invocation B : ${methodB}()`); + console.log(` Balance address : ${balanceAddress}`); + + // ----------------------------------------------------------------------- + // Step 1: Validate inputs + // ----------------------------------------------------------------------- + + console.log(chalk.yellow('\nStep 1: Validating inputs...')); + + if (!StrKey.isValidContract(contractId)) { + console.error( + chalk.red(` Invalid contract ID "${contractId}". Expected a valid C... contract address.`), + ); + + return; + } + + if (!StrKey.isValidEd25519PublicKey(balanceAddress) && !StrKey.isValidContract(balanceAddress)) { + console.error( + chalk.red(` Invalid balance address "${balanceAddress}". Expected a G... or C... address.`), + ); + + return; + } + + console.log(chalk.green(' Input validation passed.')); + + // ----------------------------------------------------------------------- + // Step 2: Connect to Soroban RPC + // ----------------------------------------------------------------------- + + console.log(chalk.yellow('\nStep 2: Connecting to Soroban RPC...')); + + const server = new rpc.Server(rpcUrl); + + try { + const latestLedger = await server.getLatestLedger(); + + console.log(chalk.green(` Connected. Latest ledger sequence: ${latestLedger.sequence}`)); + } catch (error: unknown) { + console.error(chalk.red(` Unable to reach Soroban RPC: ${getErrorMessage(error)}`)); + + console.log( + chalk.gray(' Check SOROBAN_RPC_URL and ensure the endpoint matches the selected network.'), + ); + + return; + } + + // ----------------------------------------------------------------------- + // Step 3: Build invocation arguments + // ----------------------------------------------------------------------- + + console.log(chalk.yellow('\nStep 3: Building two contract invocations...')); + + let argsA: xdr.ScVal[]; + let argsB: xdr.ScVal[]; + + try { + argsA = buildMethodArguments(methodA, balanceAddress); + + argsB = buildMethodArguments(methodB, balanceAddress); + } catch (error: unknown) { + console.error(chalk.red(` Could not encode invocation arguments: ${getErrorMessage(error)}`)); + + return; + } + + console.log(` Invocation A : ${methodA}(${describeArguments(argsA)})`); + + console.log(` Invocation B : ${methodB}(${describeArguments(argsB)})`); + + /* + * No funded account or secret key is necessary because this example only + * simulates transactions. + */ + const simulationSource = Keypair.random().publicKey(); + + // ----------------------------------------------------------------------- + // Step 4: Simulate first invocation + // ----------------------------------------------------------------------- + + console.log(chalk.yellow(`\nStep 4: Simulating ${methodA}()...`)); + + const first = await simulateFootprint( + server, + 'Invocation A', + methodA, + simulationSource, + networkPassphrase, + contractId, + argsA, + ); + + if (first.success) { + console.log(chalk.green(' First simulation succeeded.')); + } else { + console.log( + chalk.yellow( + ` First simulation did not produce a usable footprint: ${first.error ?? 'unknown reason'}`, + ), + ); + } + + // ----------------------------------------------------------------------- + // Step 5: Simulate second invocation + // ----------------------------------------------------------------------- + + console.log(chalk.yellow(`\nStep 5: Simulating ${methodB}()...`)); + + const second = await simulateFootprint( + server, + 'Invocation B', + methodB, + simulationSource, + networkPassphrase, + contractId, + argsB, + ); + + if (second.success) { + console.log(chalk.green(' Second simulation succeeded.')); + } else { + console.log( + chalk.yellow( + ` Second simulation did not produce a usable footprint: ${ + second.error ?? 'unknown reason' + }`, + ), + ); + } + + // ----------------------------------------------------------------------- + // Step 6: Print detailed footprint reports + // ----------------------------------------------------------------------- + + console.log(chalk.yellow('\nStep 6: Detailed ledger footprint reports')); + + printFootprintReport(first); + + printFootprintReport(second); + + // ----------------------------------------------------------------------- + // Step 7: Compare footprints + // ----------------------------------------------------------------------- + + console.log(chalk.yellow('\nStep 7: Comparing the two invocations...')); + + printComparison(first, second); + + // ----------------------------------------------------------------------- + // Step 8: Explain Soroban footprints + // ----------------------------------------------------------------------- + + console.log(chalk.yellow('\nStep 8: Why Soroban transactions need footprints')); + + console.log( + chalk.cyan( + [ + ' • A Soroban footprint declares the exact ledger keys a transaction may access.', + ' • Read-only entries can be read but are not expected to be modified.', + ' • Read-write entries may be read, created, changed, or deleted.', + ' • Contract-data entries can use persistent or temporary durability.', + ' • A contract instance is represented by a special persistent contract-data key.', + ' • Contract code is stored under a separate contract-code ledger key.', + ' • Simulation discovers the transitive footprint, including state touched by', + ' contracts called indirectly by the original contract.', + ].join('\n'), + ), + ); + + console.log( + chalk.gray( + '\n Without an accurate footprint, the network cannot safely execute the Soroban transaction against the declared ledger state.', + ), + ); + + // ----------------------------------------------------------------------- + // Step 9: Explain simulation and transaction preparation + // ----------------------------------------------------------------------- + + console.log(chalk.yellow('\nStep 9: Simulation and transaction preparation')); + + console.log( + chalk.cyan( + [ + ' 1. Build the initial contract invocation.', + ' 2. Simulate it through Soroban RPC.', + ' 3. RPC executes against a temporary ledger snapshot and records the entries', + ' that the invocation reads and writes.', + ' 4. The returned Soroban transaction data contains the recommended footprint.', + ' 5. Transaction preparation applies that data to the transaction before signing.', + ' 6. If ledger state changes enough before submission, the footprint may become', + ' stale and the transaction should be simulated again.', + ].join('\n'), + ), + ); + + console.log(chalk.bold.green('\nSoroban ledger footprint analysis complete.')); + + console.log( + chalk.gray('No transaction was signed or submitted. Both invocations were simulation-only.'), + ); +} + +/** + * Display invocation arguments compactly. + */ +function describeArguments(args: xdr.ScVal[]): string { + if (args.length === 0) { + return ''; + } + + return args.map((argument) => formatScVal(argument)).join(', '); +} diff --git a/src/examples/119-soroban-resource-fee-analysis.ts b/src/examples/119-soroban-resource-fee-analysis.ts new file mode 100644 index 0000000..f307727 --- /dev/null +++ b/src/examples/119-soroban-resource-fee-analysis.ts @@ -0,0 +1,1211 @@ +import { + Account, + Address, + Asset, + Contract, + Keypair, + Networks, + StrKey, + Transaction, + TransactionBuilder, + rpc, + xdr, +} from 'stellar-sdk-v16'; +import chalk from 'chalk'; + +/** + * ISSUE-119: Soroban Resource and Fee Analysis + * + * Soroban transactions use a multidimensional resource model. Contract + * execution consumes resources such as CPU instructions, memory, ledger entry + * access, and ledger I/O bytes. + * + * Simulation is the normal way to discover the resource limits and resource + * fee required to prepare a Soroban transaction before it is signed and + * submitted. + * + * This example demonstrates how to: + * + * 1. Build two Soroban contract invocations. + * 2. Simulate both invocations with Soroban RPC. + * 3. Extract CPU instruction consumption where exposed by RPC. + * 4. Extract memory consumption where exposed by RPC. + * 5. Inspect ledger read/write counts from the footprint. + * 6. Inspect recommended ledger read/write byte limits. + * 7. Inspect transaction instruction limits. + * 8. Separate Soroban resource fees from the inclusion/base fee. + * 9. Calculate the total estimated transaction fee. + * 10. Show meaningful relative contribution/utilization percentages. + * 11. Compare resource consumption between two invocations. + * 12. Identify unusually expensive resource differences. + * 13. Explain how simulation affects transaction preparation. + * 14. Handle unavailable resource information gracefully. + * 15. Handle simulation failures gracefully. + * + * The SDK's parsed simulation response provides transactionData and + * minResourceFee. Some RPC versions additionally expose a raw `cost` object + * containing cpuInsns and memBytes. Because stellar-sdk-v16@16.2.0 does not + * expose that `cost` object on its parsed response type, this example performs + * one additional dependency-free JSON-RPC simulation request solely to obtain + * CPU/memory information where the RPC node supports it. + * + * Nothing is signed or submitted. + */ + +const DEFAULT_RPC_URL = 'https://soroban-testnet.stellar.org'; +const BASE_FEE_STROOPS = 100n; +const BASE_FEE_STRING = BASE_FEE_STROOPS.toString(); + +const DEFAULT_METHOD_A = 'decimals'; +const DEFAULT_METHOD_B = 'name'; + +export interface ResourceFeeAnalysisParams { + rpcUrl?: string; + networkPassphrase?: string; + contractId?: string; + methodA?: string; + methodB?: string; + balanceAddress?: string; +} + +export interface RawSimulationCost { + cpuInstructions?: bigint; + memoryBytes?: bigint; +} + +interface RawRpcSimulationResponse { + jsonrpc?: string; + id?: string | number; + result?: { + cost?: { + cpuInsns?: string; + memBytes?: string; + }; + error?: string; + }; + error?: { + code?: number; + message?: string; + data?: unknown; + }; +} + +export interface ResourceReport { + label: string; + method: string; + latestLedger: number; + + cpuInstructions?: bigint; + memoryBytes?: bigint; + + instructionLimit: number; + ledgerReadCount: number; + ledgerWriteCount: number; + ledgerReadBytes: number; + ledgerWriteBytes: number; + + sorobanResourceFee: bigint; + inclusionFee: bigint; + totalEstimatedFee: bigint; + + rawCostAvailable: boolean; +} + +export interface SimulationAnalysis { + label: string; + method: string; + success: boolean; + restoreRequired: boolean; + latestLedger?: number; + error?: string; + report?: ResourceReport; +} + +export interface PercentageBreakdown { + first: number; + second: number; +} + +export interface ResourceComparisonRow { + name: string; + first?: bigint; + second?: bigint; + unit: string; +} + +export interface ExpensiveResourceFinding { + resource: string; + message: string; +} + +/** + * Convert unknown thrown values into useful diagnostics. + */ +export function getErrorMessage(error: unknown): string { + if (error instanceof Error) { + return error.message; + } + + return String(error); +} + +/** + * Parse a non-negative integer returned by JSON-RPC. + */ +export function parseOptionalBigInt( + value: string | number | bigint | undefined, +): bigint | undefined { + if (value === undefined) { + return undefined; + } + + try { + const parsed = BigInt(value); + + return parsed >= 0n ? parsed : undefined; + } catch { + return undefined; + } +} + +/** + * Format bytes using binary units. + */ +export function formatBytes(value: bigint | number): string { + const bytes = typeof value === 'bigint' ? Number(value) : value; + + if (!Number.isFinite(bytes) || bytes < 0) { + return String(value); + } + + const units = ['B', 'KiB', 'MiB', 'GiB']; + + let amount = bytes; + let unitIndex = 0; + + while (amount >= 1024 && unitIndex < units.length - 1) { + amount /= 1024; + unitIndex += 1; + } + + const decimals = amount >= 100 || unitIndex === 0 ? 0 : 2; + + return `${amount.toFixed(decimals)} ${units[unitIndex]}`; +} + +/** + * Render stroops and their approximate XLM equivalent. + * + * 1 XLM = 10,000,000 stroops. + */ +export function formatFee(stroops: bigint): string { + const stroopsPerXlm = 10_000_000n; + + const whole = stroops / stroopsPerXlm; + const fraction = (stroops % stroopsPerXlm).toString().padStart(7, '0').replace(/0+$/, ''); + + const xlm = fraction.length > 0 ? `${whole}.${fraction}` : whole.toString(); + + return `${stroops.toString()} stroops (${xlm} XLM)`; +} + +/** + * Calculate a percentage while handling a zero denominator. + */ +export function calculatePercentage(value: bigint | number, total: bigint | number): number { + const numericValue = Number(value); + const numericTotal = Number(total); + + if (!Number.isFinite(numericValue) || !Number.isFinite(numericTotal) || numericTotal <= 0) { + return 0; + } + + return (numericValue / numericTotal) * 100; +} + +/** + * Split two comparable values into percentage shares. + * + * This is used only for values with matching units, such as ledger read bytes + * versus ledger write bytes, or resource fee versus inclusion fee. + */ +export function calculateShare( + first: bigint | number, + second: bigint | number, +): PercentageBreakdown { + const firstNumber = Number(first); + const secondNumber = Number(second); + const total = firstNumber + secondNumber; + + if (!Number.isFinite(total) || total <= 0) { + return { + first: 0, + second: 0, + }; + } + + return { + first: (firstNumber / total) * 100, + second: (secondNumber / total) * 100, + }; +} + +/** + * Build arguments for the default demonstration methods. + * + * decimals() has no arguments. + * balance(address) accepts one Soroban address. + * + * Custom methods supplied through environment variables are invoked without + * arguments. If that is incompatible with the selected contract, simulation + * returns a useful contract/RPC error instead of the example crashing. + */ +export function buildMethodArguments(method: string, balanceAddress: string): xdr.ScVal[] { + if (method === 'balance') { + return [Address.fromString(balanceAddress).toScVal()]; + } + + return []; +} + +/** + * Build a single-operation Soroban invocation. + * + * No secret key is required because this example only simulates. + */ +export function buildInvocation( + sourceAccountId: string, + networkPassphrase: string, + contractId: string, + method: string, + args: xdr.ScVal[] = [], +): Transaction { + const sourceAccount = new Account(sourceAccountId, '0'); + const contract = new Contract(contractId); + + return new TransactionBuilder(sourceAccount, { + fee: BASE_FEE_STRING, + networkPassphrase, + }) + .addOperation(contract.call(method, ...args)) + .setTimeout(30) + .build(); +} + +/** + * Fetch the raw RPC simulation cost. + * + * The parsed SDK response in stellar-sdk-v16@16.2.0 exposes transactionData, + * minResourceFee and results, but not the optional RPC `cost` object. The + * current Soroban RPC API may return: + * + * cost.cpuInsns + * cost.memBytes + * + * This helper obtains those values without installing another dependency. + * + * If the RPC does not expose them, both properties simply remain undefined. + */ +export async function fetchRawSimulationCost( + rpcUrl: string, + transaction: Transaction, +): Promise { + const transactionXdr = transaction.toEnvelope().toXDR('base64'); + + const response = await fetch(rpcUrl, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + jsonrpc: '2.0', + id: `resource-analysis-${Date.now()}`, + method: 'simulateTransaction', + params: { + transaction: transactionXdr, + resourceConfig: { + instructionLeeway: 0, + }, + }, + }), + }); + + if (!response.ok) { + throw new Error(`Raw RPC request returned HTTP ${response.status} ${response.statusText}.`); + } + + const payload = (await response.json()) as RawRpcSimulationResponse; + + if (payload.error) { + throw new Error( + payload.error.message ?? + `Raw RPC returned JSON-RPC error ${payload.error.code ?? 'unknown'}.`, + ); + } + + if (payload.result?.error) { + throw new Error(payload.result.error); + } + + return { + cpuInstructions: parseOptionalBigInt(payload.result?.cost?.cpuInsns), + memoryBytes: parseOptionalBigInt(payload.result?.cost?.memBytes), + }; +} + +/** + * Extract a complete resource and fee report from successful SDK simulation. + */ +export function extractResourceReport( + label: string, + method: string, + simulation: rpc.Api.SimulateTransactionSuccessResponse, + rawCost: RawSimulationCost, + inclusionFee: bigint = BASE_FEE_STROOPS, +): ResourceReport { + const sorobanData = simulation.transactionData.build(); + const resources = sorobanData.resources(); + const footprint = resources.footprint(); + + const sorobanResourceFee = BigInt(simulation.minResourceFee); + + return { + label, + method, + latestLedger: simulation.latestLedger, + + cpuInstructions: rawCost.cpuInstructions, + memoryBytes: rawCost.memoryBytes, + + instructionLimit: resources.instructions(), + ledgerReadCount: footprint.readOnly().length, + ledgerWriteCount: footprint.readWrite().length, + ledgerReadBytes: resources.diskReadBytes(), + ledgerWriteBytes: resources.writeBytes(), + + sorobanResourceFee, + inclusionFee, + totalEstimatedFee: sorobanResourceFee + inclusionFee, + + rawCostAvailable: rawCost.cpuInstructions !== undefined || rawCost.memoryBytes !== undefined, + }; +} + +/** + * Simulate one transaction and produce a normalized resource report. + */ +async function simulateAndAnalyze( + server: rpc.Server, + rpcUrl: string, + label: string, + method: string, + transaction: Transaction, +): Promise { + /* + * CPU/memory collection is deliberately best-effort. Failure of the extra raw + * request must not prevent normal SDK resource analysis. + */ + const rawCostPromise = fetchRawSimulationCost(rpcUrl, transaction).catch( + () => + ({ + cpuInstructions: undefined, + memoryBytes: undefined, + }) satisfies RawSimulationCost, + ); + + let simulation: rpc.Api.SimulateTransactionResponse; + + try { + simulation = await server.simulateTransaction(transaction); + } catch (error: unknown) { + return { + label, + method, + success: false, + restoreRequired: false, + error: `RPC simulation request failed: ${getErrorMessage(error)}`, + }; + } + + const rawCost = await rawCostPromise; + + if (rpc.Api.isSimulationError(simulation)) { + return { + label, + method, + success: false, + restoreRequired: false, + latestLedger: simulation.latestLedger, + error: simulation.error, + }; + } + + if (rpc.Api.isSimulationRestore(simulation)) { + return { + label, + method, + success: false, + restoreRequired: true, + latestLedger: simulation.latestLedger, + error: + 'Archived ledger state must be restored before this invocation can be prepared normally.', + }; + } + + try { + return { + label, + method, + success: true, + restoreRequired: false, + latestLedger: simulation.latestLedger, + report: extractResourceReport(label, method, simulation, rawCost, BASE_FEE_STROOPS), + }; + } catch (error: unknown) { + return { + label, + method, + success: false, + restoreRequired: false, + latestLedger: simulation.latestLedger, + error: `Simulation succeeded but resource data could not be decoded: ${getErrorMessage( + error, + )}`, + }; + } +} + +/** + * Produce a signed numeric delta. + */ +export function formatSignedBigInt(value: bigint): string { + if (value > 0n) { + return `+${value.toString()}`; + } + + return value.toString(); +} + +/** + * Produce percentage change between two values. + */ +export function percentageChange(previous: bigint, current: bigint): number | undefined { + if (previous === 0n) { + return current === 0n ? 0 : undefined; + } + + return (Number(current - previous) / Number(previous)) * 100; +} + +/** + * Compare two values and produce a readable delta. + */ +function formatComparisonDelta(first: bigint, second: bigint): string { + const delta = second - first; + const percentage = percentageChange(first, second); + + if (percentage === undefined) { + return `${formatSignedBigInt(delta)} (new non-zero usage)`; + } + + const sign = percentage > 0 ? '+' : ''; + + return `${formatSignedBigInt(delta)} (${sign}${percentage.toFixed(1)}%)`; +} + +/** + * Print a resource field that may be unavailable. + */ +function printOptionalResource( + label: string, + value: bigint | undefined, + formatter: (value: bigint) => string = (resource) => resource.toString(), +): void { + if (value === undefined) { + console.log(chalk.yellow(` ${label.padEnd(27)}: unavailable`)); + return; + } + + console.log(` ${label.padEnd(27)}: ${formatter(value)}`); +} + +/** + * Print relative contribution information using comparable resource groups. + * + * We intentionally do NOT add CPU instructions, bytes and entry counts into a + * single percentage because those values use different units. Instead: + * + * - CPU consumption is shown against its instruction budget. + * - Ledger entry contribution splits read versus write entry counts. + * - Ledger I/O contribution splits read versus write byte budgets. + * - Fee contribution splits resource fee versus inclusion fee. + * - Memory is displayed independently because memory is limited but is not + * directly charged as a Soroban resource fee. + */ +function printRelativeContributions(report: ResourceReport): void { + console.log(chalk.cyan('\n Relative resource contribution')); + + if (report.cpuInstructions !== undefined && report.instructionLimit > 0) { + const cpuUtilization = calculatePercentage(report.cpuInstructions, report.instructionLimit); + + console.log(` CPU budget utilization : ${cpuUtilization.toFixed(2)}%`); + } else { + console.log( + chalk.yellow(' CPU budget utilization : unavailable (actual CPU cost not exposed)'), + ); + } + + const entryShare = calculateShare(report.ledgerReadCount, report.ledgerWriteCount); + + console.log( + ` Ledger entry access : reads ${entryShare.first.toFixed( + 1, + )}% / writes ${entryShare.second.toFixed(1)}%`, + ); + + const ioShare = calculateShare(report.ledgerReadBytes, report.ledgerWriteBytes); + + console.log( + ` Ledger I/O byte budget : reads ${ioShare.first.toFixed( + 1, + )}% / writes ${ioShare.second.toFixed(1)}%`, + ); + + const feeShare = calculateShare(report.sorobanResourceFee, report.inclusionFee); + + console.log( + ` Estimated fee composition : resources ${feeShare.first.toFixed( + 2, + )}% / inclusion ${feeShare.second.toFixed(2)}%`, + ); + + if (report.memoryBytes !== undefined) { + console.log( + ` Memory : ${formatBytes( + report.memoryBytes, + )} observed; memory is limited but not directly fee-charged`, + ); + } else { + console.log( + chalk.yellow(' Memory : unavailable from this RPC response'), + ); + } +} + +/** + * Print one complete resource and fee report. + */ +function printResourceReport(analysis: SimulationAnalysis): void { + console.log(chalk.bold(`\n ${analysis.label}: ${analysis.method}()`)); + + if (!analysis.success || !analysis.report) { + if (analysis.restoreRequired) { + console.log(chalk.yellow(' Result : RESTORE REQUIRED')); + } else { + console.log(chalk.red(' Result : SIMULATION FAILED')); + } + + if (analysis.latestLedger !== undefined) { + console.log(` Latest ledger : ${analysis.latestLedger}`); + } + + console.log( + chalk.gray( + ` Diagnostic : ${ + analysis.error ?? 'No diagnostic information returned.' + }`, + ), + ); + + return; + } + + const report = analysis.report; + + console.log(chalk.green(' Result : SUCCESS')); + console.log(` Latest ledger : ${report.latestLedger}`); + + console.log(chalk.cyan('\n Execution consumption')); + + printOptionalResource('CPU instructions', report.cpuInstructions, (value) => + value.toLocaleString('en-US'), + ); + + printOptionalResource( + 'Memory usage', + report.memoryBytes, + (value) => `${value.toLocaleString('en-US')} bytes (${formatBytes(value)})`, + ); + + console.log(chalk.cyan('\n Ledger access')); + + console.log(` ${'Read-only ledger entries'.padEnd(27)}: ${report.ledgerReadCount}`); + + console.log(` ${'Read-write ledger entries'.padEnd(27)}: ${report.ledgerWriteCount}`); + + console.log(chalk.cyan('\n Transaction resource limits')); + + console.log( + ` ${'Instruction limit'.padEnd(27)}: ${report.instructionLimit.toLocaleString('en-US')}`, + ); + + console.log( + ` ${'Ledger read bytes'.padEnd(27)}: ${report.ledgerReadBytes.toLocaleString( + 'en-US', + )} bytes (${formatBytes(report.ledgerReadBytes)})`, + ); + + console.log( + ` ${'Ledger write bytes'.padEnd( + 27, + )}: ${report.ledgerWriteBytes.toLocaleString('en-US')} bytes (${formatBytes( + report.ledgerWriteBytes, + )})`, + ); + + console.log(chalk.cyan('\n Fee estimate')); + + console.log(` ${'Soroban resource fee'.padEnd(27)}: ${formatFee(report.sorobanResourceFee)}`); + + console.log(` ${'Inclusion/base fee'.padEnd(27)}: ${formatFee(report.inclusionFee)}`); + + console.log( + chalk.bold(` ${'Total estimated fee'.padEnd(27)}: ${formatFee(report.totalEstimatedFee)}`), + ); + + printRelativeContributions(report); + + if (!report.rawCostAvailable) { + console.log( + chalk.gray( + '\n Note: this RPC/SDK combination did not expose CPU or memory cost. Transaction resource limits and fee data remain available.', + ), + ); + } +} + +/** + * Return resource values suitable for invocation-to-invocation comparison. + */ +function buildComparisonRows( + first: ResourceReport, + second: ResourceReport, +): ResourceComparisonRow[] { + return [ + { + name: 'CPU instructions', + first: first.cpuInstructions, + second: second.cpuInstructions, + unit: 'instructions', + }, + { + name: 'Memory', + first: first.memoryBytes, + second: second.memoryBytes, + unit: 'bytes', + }, + { + name: 'Ledger read entries', + first: BigInt(first.ledgerReadCount), + second: BigInt(second.ledgerReadCount), + unit: 'entries', + }, + { + name: 'Ledger write entries', + first: BigInt(first.ledgerWriteCount), + second: BigInt(second.ledgerWriteCount), + unit: 'entries', + }, + { + name: 'Instruction limit', + first: BigInt(first.instructionLimit), + second: BigInt(second.instructionLimit), + unit: 'instructions', + }, + { + name: 'Ledger read bytes', + first: BigInt(first.ledgerReadBytes), + second: BigInt(second.ledgerReadBytes), + unit: 'bytes', + }, + { + name: 'Ledger write bytes', + first: BigInt(first.ledgerWriteBytes), + second: BigInt(second.ledgerWriteBytes), + unit: 'bytes', + }, + { + name: 'Soroban resource fee', + first: first.sorobanResourceFee, + second: second.sorobanResourceFee, + unit: 'stroops', + }, + { + name: 'Total estimated fee', + first: first.totalEstimatedFee, + second: second.totalEstimatedFee, + unit: 'stroops', + }, + ]; +} + +/** + * Render a comparison value in a resource-appropriate format. + */ +function formatComparisonValue(value: bigint | undefined, unit: string): string { + if (value === undefined) { + return 'unavailable'; + } + + if (unit === 'bytes') { + return `${value.toLocaleString('en-US')} (${formatBytes(value)})`; + } + + if (unit === 'stroops') { + return formatFee(value); + } + + return `${value.toLocaleString('en-US')} ${unit}`; +} + +/** + * Detect large relative differences between the two contract invocations. + * + * "Unusually expensive" is intentionally comparison-based rather than relying + * on hard-coded network limits that validators may change. + * + * A category is highlighted when one invocation consumes more than twice the + * corresponding non-zero resource of the other invocation. + */ +export function identifyExpensiveResourceUsage( + first: ResourceReport, + second: ResourceReport, +): ExpensiveResourceFinding[] { + const findings: ExpensiveResourceFinding[] = []; + + const rows = buildComparisonRows(first, second); + + rows.forEach((row) => { + if (row.first === undefined || row.second === undefined) { + return; + } + + if (row.first > 0n && row.second > row.first * 2n) { + findings.push({ + resource: row.name, + message: `${second.method}() uses more than 2× the ${row.name.toLowerCase()} of ${first.method}().`, + }); + + return; + } + + if (row.second > 0n && row.first > row.second * 2n) { + findings.push({ + resource: row.name, + message: `${first.method}() uses more than 2× the ${row.name.toLowerCase()} of ${second.method}().`, + }); + } + }); + + /* + * CPU has an explicit transaction instruction budget, so high utilization is + * meaningful even without comparison to another invocation. + */ + [ + { + report: first, + label: first.method, + }, + { + report: second, + label: second.method, + }, + ].forEach(({ report, label }) => { + if (report.cpuInstructions !== undefined && report.instructionLimit > 0) { + const utilization = calculatePercentage(report.cpuInstructions, report.instructionLimit); + + if (utilization >= 90) { + findings.push({ + resource: 'CPU instruction budget', + message: `${label}() consumed ${utilization.toFixed( + 1, + )}% of its simulated instruction limit.`, + }); + } + } + }); + + return findings; +} + +/** + * Print invocation-to-invocation comparison. + */ +function printComparison(first: SimulationAnalysis, second: SimulationAnalysis): void { + console.log(chalk.yellow('\nResource comparison')); + + if (!first.success || !first.report) { + console.log( + chalk.gray( + ` Cannot compare resources because ${first.label} (${first.method}) did not produce a usable report.`, + ), + ); + + return; + } + + if (!second.success || !second.report) { + console.log( + chalk.gray( + ` Cannot compare resources because ${second.label} (${second.method}) did not produce a usable report.`, + ), + ); + + return; + } + + const firstReport = first.report; + const secondReport = second.report; + + console.log(` First invocation : ${firstReport.method}()`); + console.log(` Second invocation: ${secondReport.method}()`); + + const rows = buildComparisonRows(firstReport, secondReport); + + rows.forEach((row) => { + console.log(chalk.cyan(`\n ${row.name}`)); + + console.log( + ` ${firstReport.method.padEnd(16)}: ${formatComparisonValue(row.first, row.unit)}`, + ); + + console.log( + ` ${secondReport.method.padEnd(16)}: ${formatComparisonValue(row.second, row.unit)}`, + ); + + if (row.first !== undefined && row.second !== undefined) { + console.log(` Delta${''.padEnd(12)}: ${formatComparisonDelta(row.first, row.second)}`); + } else { + console.log( + chalk.gray( + ` Delta : unavailable because one or both RPC responses omitted this resource`, + ), + ); + } + }); + + // ----------------------------------------------------------------------- + // Unusually expensive usage + // ----------------------------------------------------------------------- + + console.log(chalk.yellow('\nUnusually expensive resource usage')); + + const findings = identifyExpensiveResourceUsage(first.report, second.report); + + if (findings.length === 0) { + console.log( + chalk.green( + ' No unusually expensive resource category was identified by the comparison heuristic.', + ), + ); + + console.log( + chalk.gray( + ' The heuristic flags >2× differences between comparable non-zero resources and CPU usage at >=90% of the simulated instruction budget.', + ), + ); + + return; + } + + findings.forEach((finding) => { + console.log(chalk.yellow(` ⚠ ${finding.resource}: ${finding.message}`)); + }); + + console.log( + chalk.gray( + '\n These warnings are comparison aids, not protocol failure thresholds. Network resource limits and fee rates can change.', + ), + ); +} + +/** + * Run ISSUE-119. + */ +export async function run(params: ResourceFeeAnalysisParams = {}): Promise { + const rpcUrl = params.rpcUrl?.trim() || process.env.SOROBAN_RPC_URL?.trim() || DEFAULT_RPC_URL; + + const networkPassphrase = + params.networkPassphrase?.trim() || process.env.NETWORK_PASSPHRASE?.trim() || Networks.TESTNET; + + /* + * The native XLM Stellar Asset Contract gives the example a deterministic + * Testnet contract that supports standard token read methods. + */ + const defaultContractId = Asset.native().contractId(networkPassphrase); + + const contractId = + params.contractId?.trim() || + process.env.RESOURCE_CONTRACT_ID?.trim() || + process.env.CONTRACT_ID?.trim() || + defaultContractId; + + const methodA = + params.methodA?.trim() || process.env.RESOURCE_METHOD_A?.trim() || DEFAULT_METHOD_A; + + const methodB = + params.methodB?.trim() || process.env.RESOURCE_METHOD_B?.trim() || DEFAULT_METHOD_B; + + const balanceAddress = + params.balanceAddress?.trim() || + process.env.RESOURCE_BALANCE_ADDRESS?.trim() || + Keypair.random().publicKey(); + + console.log(chalk.bold('\nSoroban Resource and Fee Analysis Example')); + + console.log( + chalk.gray( + 'Simulate two Soroban contract calls, inspect their resource consumption and fees, and compare their relative cost.', + ), + ); + + console.log(chalk.yellow('\nConfiguration')); + + console.log(` RPC endpoint : ${rpcUrl}`); + console.log(` Contract : ${contractId}`); + console.log(` Invocation A : ${methodA}()`); + console.log(` Invocation B : ${methodB}()`); + console.log(` Balance address : ${balanceAddress}`); + console.log(` Inclusion fee : ${BASE_FEE_STROOPS.toString()} stroops`); + + // ----------------------------------------------------------------------- + // Step 1: Validate inputs + // ----------------------------------------------------------------------- + + console.log(chalk.yellow('\nStep 1: Validating inputs...')); + + if (!StrKey.isValidContract(contractId)) { + console.error( + chalk.red(` Invalid contract ID "${contractId}". Expected a valid C... contract address.`), + ); + + return; + } + + if (!StrKey.isValidEd25519PublicKey(balanceAddress) && !StrKey.isValidContract(balanceAddress)) { + console.error( + chalk.red(` Invalid balance address "${balanceAddress}". Expected a G... or C... address.`), + ); + + return; + } + + console.log(chalk.green(' Input validation passed.')); + + // ----------------------------------------------------------------------- + // Step 2: Connect to RPC + // ----------------------------------------------------------------------- + + console.log(chalk.yellow('\nStep 2: Connecting to Soroban RPC...')); + + const server = new rpc.Server(rpcUrl); + + try { + const latestLedger = await server.getLatestLedger(); + + console.log(chalk.green(` Connected. Latest ledger sequence: ${latestLedger.sequence}`)); + } catch (error: unknown) { + console.error(chalk.red(` Unable to reach Soroban RPC: ${getErrorMessage(error)}`)); + + console.log( + chalk.gray(' Check SOROBAN_RPC_URL and confirm that it matches the selected network.'), + ); + + return; + } + + // ----------------------------------------------------------------------- + // Step 3: Build two contract invocations + // ----------------------------------------------------------------------- + + console.log(chalk.yellow('\nStep 3: Building sample contract invocations...')); + + let firstTransaction: Transaction; + let secondTransaction: Transaction; + + const simulationSource = Keypair.random().publicKey(); + + try { + firstTransaction = buildInvocation( + simulationSource, + networkPassphrase, + contractId, + methodA, + buildMethodArguments(methodA, balanceAddress), + ); + + secondTransaction = buildInvocation( + simulationSource, + networkPassphrase, + contractId, + methodB, + buildMethodArguments(methodB, balanceAddress), + ); + } catch (error: unknown) { + console.error(chalk.red(` Could not build sample invocations: ${getErrorMessage(error)}`)); + + return; + } + + console.log(chalk.green(' Both transactions were constructed.')); + console.log(` Invocation A: ${methodA}()`); + console.log(` Invocation B: ${methodB}()`); + console.log(chalk.gray(' Neither transaction is signed or submitted.')); + + // ----------------------------------------------------------------------- + // Step 4: Simulate first invocation + // ----------------------------------------------------------------------- + + console.log(chalk.yellow(`\nStep 4: Simulating ${methodA}()...`)); + + const firstAnalysis = await simulateAndAnalyze( + server, + rpcUrl, + 'Invocation A', + methodA, + firstTransaction, + ); + + if (firstAnalysis.success) { + console.log(chalk.green(' First simulation succeeded.')); + } else { + console.log( + chalk.yellow( + ` First simulation did not produce a resource report: ${ + firstAnalysis.error ?? 'unknown reason' + }`, + ), + ); + } + + // ----------------------------------------------------------------------- + // Step 5: Simulate second invocation + // ----------------------------------------------------------------------- + + console.log(chalk.yellow(`\nStep 5: Simulating ${methodB}()...`)); + + const secondAnalysis = await simulateAndAnalyze( + server, + rpcUrl, + 'Invocation B', + methodB, + secondTransaction, + ); + + if (secondAnalysis.success) { + console.log(chalk.green(' Second simulation succeeded.')); + } else { + console.log( + chalk.yellow( + ` Second simulation did not produce a resource report: ${ + secondAnalysis.error ?? 'unknown reason' + }`, + ), + ); + } + + // ----------------------------------------------------------------------- + // Step 6: Detailed reports + // ----------------------------------------------------------------------- + + console.log(chalk.yellow('\nStep 6: Detailed resource and fee reports')); + + printResourceReport(firstAnalysis); + printResourceReport(secondAnalysis); + + // ----------------------------------------------------------------------- + // Step 7: Compare the invocations + // ----------------------------------------------------------------------- + + console.log(chalk.yellow('\nStep 7: Comparing contract invocations...')); + + printComparison(firstAnalysis, secondAnalysis); + + // ----------------------------------------------------------------------- + // Step 8: Explain fee structure + // ----------------------------------------------------------------------- + + console.log(chalk.yellow('\nStep 8: Understanding Soroban fees')); + + console.log( + chalk.cyan( + [ + ' • Soroban resource fee: pays for the smart-contract resources declared', + ' in Soroban transaction data, including metered execution and ledger I/O.', + ' • Inclusion/base fee: the normal Stellar transaction fee used for network', + ' inclusion and prioritization.', + ' • Estimated total fee = Soroban resource fee + inclusion/base fee.', + ' • Memory consumption is metered and limited, but it is not directly priced', + ' as an independent Soroban fee category.', + ' • Some Soroban resource fees are refundable because final refundable', + ' consumption is reconciled after execution.', + ].join('\n'), + ), + ); + + // ----------------------------------------------------------------------- + // Step 9: Explain simulation and preparation + // ----------------------------------------------------------------------- + + console.log(chalk.yellow('\nStep 9: How simulation affects transaction preparation')); + + console.log( + chalk.cyan( + [ + ' 1. The application first builds an incomplete Soroban invocation.', + ' 2. simulateTransaction executes it against current ledger state without', + ' committing any changes.', + ' 3. RPC returns recommended Soroban transaction data containing the ledger', + ' footprint, instruction limit, ledger I/O limits, and resource fee.', + ' 4. Simulation also identifies required authorization entries and catches', + ' contract failures before the transaction is submitted.', + ' 5. Transaction preparation applies the simulated resource data to the', + ' transaction before authorization and signing.', + ' 6. The inclusion fee is then added on top of the resource fee.', + ' 7. If ledger state changes materially before submission, applications may', + ' need to simulate again so the footprint and resource limits remain valid.', + ].join('\n'), + ), + ); + + console.log( + chalk.gray( + '\n The SDK server.prepareTransaction() helper normally performs simulation and applies the returned Soroban transaction data automatically for transactions that will be submitted.', + ), + ); + + // ----------------------------------------------------------------------- + // Step 10: Graceful availability note + // ----------------------------------------------------------------------- + + console.log(chalk.yellow('\nStep 10: Resource information availability')); + + console.log( + chalk.gray( + [ + ' RPC implementations and protocol versions can expose different diagnostic', + ' fields. This example treats CPU and memory cost as optional, while the', + ' simulated Soroban transaction data remains the authoritative source for', + ' transaction resource limits and the recommended resource fee.', + ].join('\n'), + ), + ); + + console.log(chalk.bold.green('\nSoroban resource and fee analysis complete.')); + + console.log( + chalk.gray( + 'No transaction was signed or submitted. Both contract invocations were simulation-only.', + ), + ); +} diff --git a/src/runner/catalog.ts b/src/runner/catalog.ts index 2aed80a..815a615 100644 --- a/src/runner/catalog.ts +++ b/src/runner/catalog.ts @@ -1689,6 +1689,29 @@ export const examples: Record = { description: 'Inspect asset authorization flags and trustline authorization-related balances', run: loadExample('../examples/168-issuer-authorization-inspection'), }, + '116-soroban-token-contract': { + name: '116-soroban-token-contract', + description: + 'Inspect Soroban token metadata, balances and allowances, construct a transfer, and simulate it before submission', + run: loadExample('../examples/116-soroban-token-contract'), + }, + '117-soroban-auth-tree': { + name: '117-soroban-auth-tree', + description: + 'Simulate Soroban authorization and visualize root and nested invocation trees', + run: loadExample('../examples/117-soroban-auth-tree'), + }, + '118-ledger-footprint-analysis': { + name: '118-ledger-footprint-analysis', + description: + 'Simulate, decode, summarize, and compare Soroban ledger footprints', + run: loadExample('../examples/118-ledger-footprint-analysis'), + }, + '119-soroban-resource-fee-analysis': { + name: '119-soroban-resource-fee-analysis', + description: + 'Compare Soroban CPU, memory, ledger I/O, resource limits, and estimated fees', + run: loadExample('../examples/119-soroban-resource-fee-analysis'), '177-soroban-event-decoding': { name: '177-soroban-event-decoding', description: diff --git a/tests/soroban-examples-116-119.test.ts b/tests/soroban-examples-116-119.test.ts new file mode 100644 index 0000000..321d11d --- /dev/null +++ b/tests/soroban-examples-116-119.test.ts @@ -0,0 +1,437 @@ +/* + * stellar-sdk-v16 currently pulls an ESM-only transitive dependency that the + * repository's CommonJS Jest configuration does not transform. + * + * Mock the SDK runtime so we can test our pure helpers directly while also + * inspecting SDK-dependent example behavior from source, which matches the + * repository's existing Soroban test style. + */ +jest.mock('stellar-sdk-v16', () => ({})); + +import * as fs from 'fs'; + +import { + formatTokenAmount, + isInsufficientBalanceError, +} from '../src/examples/116-soroban-token-contract'; + +import { compareFootprints, formatDelta } from '../src/examples/118-ledger-footprint-analysis'; + +import type { FootprintSummary } from '../src/examples/118-ledger-footprint-analysis'; + +import { + calculatePercentage, + calculateShare, + formatFee, + identifyExpensiveResourceUsage, + parseOptionalBigInt, +} from '../src/examples/119-soroban-resource-fee-analysis'; + +import type { ResourceReport } from '../src/examples/119-soroban-resource-fee-analysis'; + +import { examples } from '../src/runner/catalog'; + +const exampleNames = [ + '116-soroban-token-contract', + '117-soroban-auth-tree', + '118-ledger-footprint-analysis', + '119-soroban-resource-fee-analysis', +]; + +function readExample(name: string): string { + return fs.readFileSync(`src/examples/${name}.ts`, 'utf8'); +} + +function emptyFootprintSummary(): FootprintSummary { + return { + readOnlyCount: 0, + readWriteCount: 0, + totalCount: 0, + contractEntryCount: 0, + persistentEntryCount: 0, + temporaryEntryCount: 0, + instanceEntryCount: 0, + contractCodeCount: 0, + entries: [], + }; +} + +function createResourceReport(overrides: Partial = {}): ResourceReport { + return { + label: 'Invocation', + method: 'test', + latestLedger: 1, + cpuInstructions: 100n, + memoryBytes: 1024n, + instructionLimit: 1000, + ledgerReadCount: 1, + ledgerWriteCount: 0, + ledgerReadBytes: 100, + ledgerWriteBytes: 0, + sorobanResourceFee: 1000n, + inclusionFee: 100n, + totalEstimatedFee: 1100n, + rawCostAvailable: true, + ...overrides, + }; +} + +describe('ISSUE-116: Soroban token contract interaction', () => { + const source = readExample('116-soroban-token-contract'); + + it('connects to Soroban RPC and simulates transactions', () => { + expect(source).toContain("from 'stellar-sdk-v16'"); + expect(source).toContain('new rpc.Server'); + expect(source).toContain('simulateTransaction'); + }); + + it('validates token contract IDs', () => { + expect(source).toContain('tokenContractId?: string'); + expect(source).toContain('StrKey.isValidContract'); + expect(source).toContain('Invalid token contract ID'); + }); + + it('retrieves token metadata', () => { + expect(source).toContain("'name'"); + expect(source).toContain("'symbol'"); + expect(source).toContain("'decimals'"); + }); + + it('retrieves and formats balances', () => { + expect(source).toContain("'balance'"); + + expect(formatTokenAmount(12_345_678n, 7)).toBe('1.2345678'); + expect(formatTokenAmount(10_000_000n, 7)).toBe('1'); + expect(formatTokenAmount(5n, 0)).toBe('5'); + }); + + it('supports allowance and optional total supply', () => { + expect(source).toContain("'allowance'"); + expect(source).toContain("'total_supply'"); + expect(source).toContain('total_supply is not available'); + }); + + it('constructs and simulates a transfer', () => { + expect(source).toContain('buildTokenTransfer'); + expect(source).toContain("'transfer'"); + expect(source).toContain("nativeToScVal(amount, { type: 'i128' })"); + expect(source).toContain('server.simulateTransaction(transfer.transaction)'); + }); + + it('decodes returned ScVal values', () => { + expect(source).toContain('scValToNative'); + expect(source).toContain('decodeScVal'); + expect(source).toContain("toXDR('base64')"); + }); + + it('identifies insufficient-balance scenarios', () => { + expect(isInsufficientBalanceError('contract failed: insufficient balance')).toBe(true); + + expect(isInsufficientBalanceError('BalanceError: amount exceeds balance')).toBe(true); + + expect(isInsufficientBalanceError('RPC timeout')).toBe(false); + }); + + it('handles invalid contracts and simulation failures', () => { + expect(source).toContain('Invalid token contract ID'); + expect(source).toContain('rpc.Api.isSimulationError'); + expect(source).toContain('Simulation request failed'); + }); + + it('explains Stellar assets and Soroban token contracts', () => { + expect(source).toContain('Stellar Asset Contract'); + expect(source).toContain('classic Stellar asset'); + }); +}); + +describe('ISSUE-117: Soroban authorization tree visualization', () => { + const source = readExample('117-soroban-auth-tree'); + + it('builds an authorized contract invocation', () => { + expect(source).toContain('buildAuthorizedInvocation'); + expect(source).toContain("'approve'"); + }); + + it('simulates and extracts authorization entries', () => { + expect(source).toContain('simulateTransaction'); + expect(source).toContain('simulation.result?.auth ?? []'); + }); + + it('parses root and nested invocation trees', () => { + expect(source).toContain('rootInvocation()'); + expect(source).toContain('subInvocations()'); + expect(source).toContain('flattenAuthorizationTree'); + expect(source).toContain("'root'"); + expect(source).toContain("'nested'"); + }); + + it('handles deeply nested trees iteratively', () => { + expect(source).toContain('const stack'); + expect(source).toContain('while (stack.length > 0)'); + expect(source).toContain('stack.push'); + }); + + it('decodes contract IDs, functions and arguments', () => { + expect(source).toContain('contractFn()'); + expect(source).toContain('contractAddress()'); + expect(source).toContain('functionName().toString()'); + expect(source).toContain('contractFunction.args()'); + expect(source).toContain('formatScVal'); + }); + + it('associates entries with required signers and signature status', () => { + expect(source).toContain('getAuthorizationSigner'); + expect(source).toContain('Authorized addr'); + expect(source).toContain('Signature status'); + expect(source).toContain('signatureExpirationLedger'); + }); + + it('supports multiple and empty authorization results', () => { + expect(source).toContain('authorizationEntries.forEach'); + expect(source).toContain('authorizationEntries.length === 0'); + }); + + it('handles simulation failures', () => { + expect(source).toContain('rpc.Api.isSimulationError'); + expect(source).toContain('printSimulationDiagnostics'); + }); +}); + +describe('ISSUE-118: Soroban ledger footprint analysis', () => { + const source = readExample('118-ledger-footprint-analysis'); + + it('simulates and extracts the footprint', () => { + expect(source).toContain('simulateTransaction'); + expect(source).toContain('simulation.transactionData.build()'); + expect(source).toContain('resources().footprint()'); + }); + + it('extracts read-only and read-write entries', () => { + expect(source).toContain('footprint.readOnly()'); + expect(source).toContain('footprint.readWrite()'); + expect(source).toContain("'read-only'"); + expect(source).toContain("'read-write'"); + }); + + it('decodes ledger keys and retains raw XDR', () => { + expect(source).toContain('describeLedgerKey'); + expect(source).toContain("key.toXDR('base64')"); + expect(source).toContain('Raw XDR'); + }); + + it('identifies storage entry types', () => { + expect(source).toContain("'persistent'"); + expect(source).toContain("'temporary'"); + expect(source).toContain("'instance'"); + expect(source).toContain('scvLedgerKeyContractInstance'); + }); + + it('summarizes footprint size', () => { + expect(source).toContain('Total entries'); + expect(source).toContain('Read-only entries'); + expect(source).toContain('Read-write entries'); + expect(source).toContain('Contract entries'); + }); + + it('compares two footprints correctly', () => { + const first = emptyFootprintSummary(); + + first.readOnlyCount = 2; + first.totalCount = 2; + first.entries = [ + { + access: 'read-only', + ledgerType: 'contractData', + description: 'first', + rawXdr: 'FIRST', + isContractEntry: true, + storageType: 'persistent', + }, + { + access: 'read-only', + ledgerType: 'contractData', + description: 'shared', + rawXdr: 'SHARED', + isContractEntry: true, + storageType: 'instance', + }, + ]; + + const second = emptyFootprintSummary(); + + second.readOnlyCount = 2; + second.readWriteCount = 1; + second.totalCount = 3; + second.entries = [ + { + access: 'read-only', + ledgerType: 'contractData', + description: 'shared', + rawXdr: 'SHARED', + isContractEntry: true, + storageType: 'instance', + }, + { + access: 'read-only', + ledgerType: 'contractData', + description: 'second', + rawXdr: 'SECOND', + isContractEntry: true, + storageType: 'persistent', + }, + { + access: 'read-write', + ledgerType: 'contractData', + description: 'write', + rawXdr: 'WRITE', + isContractEntry: true, + storageType: 'temporary', + }, + ]; + + const comparison = compareFootprints(first, second); + + expect(comparison.firstTotal).toBe(2); + expect(comparison.secondTotal).toBe(3); + expect(comparison.totalDelta).toBe(1); + expect(comparison.commonEntries).toBe(1); + expect(comparison.onlyInFirst).toBe(1); + expect(comparison.onlyInSecond).toBe(2); + }); + + it('formats comparison deltas', () => { + expect(formatDelta(3)).toBe('+3'); + expect(formatDelta(0)).toBe('0'); + expect(formatDelta(-3)).toBe('-3'); + }); + + it('handles empty footprints and simulation failures', () => { + expect(source).toContain('summary.totalCount === 0'); + expect(source).toContain('empty ledger footprint'); + expect(source).toContain('rpc.Api.isSimulationError'); + expect(source).toContain('RPC simulation request failed'); + }); +}); + +describe('ISSUE-119: Soroban resource and fee analysis', () => { + const source = readExample('119-soroban-resource-fee-analysis'); + + it('simulates two contract invocations', () => { + expect(source).toContain('simulateTransaction'); + expect(source).toContain('firstTransaction'); + expect(source).toContain('secondTransaction'); + }); + + it('parses CPU instruction consumption where available', () => { + expect(source).toContain('fetchRawSimulationCost'); + expect(source).toContain('cpuInsns'); + + expect(parseOptionalBigInt('12345')).toBe(12345n); + expect(parseOptionalBigInt('-1')).toBeUndefined(); + expect(parseOptionalBigInt(undefined)).toBeUndefined(); + }); + + it('parses memory usage where available', () => { + expect(source).toContain('memBytes'); + expect(source).toContain('Memory usage'); + }); + + it('extracts ledger counts and I/O limits', () => { + expect(source).toContain('footprint.readOnly().length'); + expect(source).toContain('footprint.readWrite().length'); + expect(source).toContain('resources.diskReadBytes()'); + expect(source).toContain('resources.writeBytes()'); + }); + + it('extracts the transaction instruction limit', () => { + expect(source).toContain('resources.instructions()'); + expect(source).toContain('Instruction limit'); + }); + + it('calculates relative contribution percentages', () => { + expect(calculatePercentage(50, 100)).toBe(50); + + const share = calculateShare(3, 1); + + expect(share.first).toBe(75); + expect(share.second).toBe(25); + }); + + it('separates resource and inclusion fees', () => { + expect(source).toContain('simulation.minResourceFee'); + expect(source).toContain('Soroban resource fee'); + expect(source).toContain('Inclusion/base fee'); + }); + + it('calculates total estimated fee', () => { + expect(source).toContain('totalEstimatedFee: sorobanResourceFee + inclusionFee'); + + expect(formatFee(100n)).toBe('100 stroops (0.00001 XLM)'); + expect(formatFee(10_000_000n)).toBe('10000000 stroops (1 XLM)'); + }); + + it('compares resource usage', () => { + expect(source).toContain('buildComparisonRows'); + expect(source).toContain('Resource comparison'); + }); + + it('identifies unusually expensive resource usage', () => { + const first = createResourceReport({ + method: 'small', + cpuInstructions: 100n, + sorobanResourceFee: 1000n, + totalEstimatedFee: 1100n, + }); + + const second = createResourceReport({ + method: 'large', + cpuInstructions: 301n, + sorobanResourceFee: 3001n, + totalEstimatedFee: 3101n, + }); + + const findings = identifyExpensiveResourceUsage(first, second); + + expect(findings.length).toBeGreaterThan(0); + + expect(findings.some((finding) => finding.message.includes('large() uses more than 2×'))).toBe( + true, + ); + }); + + it('handles unavailable resource information', () => { + expect(source).toContain('rawCostAvailable'); + expect(source).toContain('unavailable'); + }); + + it('handles simulation failures and restore-required responses', () => { + expect(source).toContain('rpc.Api.isSimulationError'); + expect(source).toContain('rpc.Api.isSimulationRestore'); + expect(source).toContain('RPC simulation request failed'); + }); + + it('explains simulation and transaction preparation', () => { + expect(source).toContain('How simulation affects transaction preparation'); + expect(source).toContain('server.prepareTransaction()'); + }); +}); + +describe('runner registration for ISSUE-116 through ISSUE-119', () => { + it('registers all four examples', () => { + for (const name of exampleNames) { + expect(examples[name]).toBeDefined(); + expect(examples[name].name).toBe(name); + expect(typeof examples[name].run).toBe('function'); + } + }); +}); + +describe('README documentation for ISSUE-116 through ISSUE-119', () => { + const readme = fs.readFileSync('README.md', 'utf8'); + + it('documents all four examples', () => { + for (const name of exampleNames) { + expect(readme).toContain(name); + } + }); +});