From 1f7740e643ac78fe32f90aa85a8ef28730c77e68 Mon Sep 17 00:00:00 2001 From: OtowoSamuel Date: Sun, 30 Aug 2026 19:53:09 +0100 Subject: [PATCH] Add Soroban Epic 5 examples (#177, #178, #179, #180) - 177-soroban-event-decoding: Event filtering, decoding, and display - 178-soroban-contract-storage: Storage inspection across durability tiers - 179-soroban-footprint-inspection: Transaction footprint analysis - 180-soroban-resource-analysis: Resource usage and utilization reporting Closes #177, Closes #178, Closes #179, Closes #180 --- README.md | 4 + src/examples/177-soroban-event-decoding.ts | 309 +++++++++++++++++ src/examples/178-soroban-contract-storage.ts | 300 +++++++++++++++++ .../179-soroban-footprint-inspection.ts | 311 ++++++++++++++++++ src/examples/180-soroban-resource-analysis.ts | 263 +++++++++++++++ src/runner/catalog.ts | 106 ++++-- 6 files changed, 1273 insertions(+), 20 deletions(-) create mode 100644 src/examples/177-soroban-event-decoding.ts create mode 100644 src/examples/178-soroban-contract-storage.ts create mode 100644 src/examples/179-soroban-footprint-inspection.ts create mode 100644 src/examples/180-soroban-resource-analysis.ts diff --git a/README.md b/README.md index 65facca..8c35226 100644 --- a/README.md +++ b/README.md @@ -236,6 +236,10 @@ The repository currently includes the following runnable examples: 81. **`158-resilient-horizon-streaming`**: Resilient Horizon streaming with cursor resume, duplicate/malformed event handling, exponential backoff reconnects, and stream statistics. 82. **`159-horizon-stream-filtering`**: Client-side AND/OR filtering pipeline for Horizon operation streams covering account, asset, operation type, success status, and amount ranges. 83. **`160-horizon-retry-rate-limit`**: Retry wrapper for transient Horizon failures and 429 rate limits with Retry-After parsing, exponential backoff, and request diagnostics. +84. **`177-soroban-event-decoding`**: Retrieve, filter, decode, and display Soroban contract events with topic and payload decoding, supporting configurable ledger ranges and event-type filtering. +85. **`178-soroban-contract-storage`**: Inspect Soroban contract storage entries across instance, persistent, and temporary durability tiers with decoded keys, values, and TTL information. +86. **`179-soroban-footprint-inspection`**: Extract and analyze the Soroban ledger footprint from a transaction simulation or envelope, distinguishing read-only from read-write entries and detecting duplicates. +87. **`180-soroban-resource-analysis`**: Analyze Soroban resource usage from simulation results with CPU instructions, memory, ledger read/write metrics, utilization percentages, and near-limit detection. ## Installation diff --git a/src/examples/177-soroban-event-decoding.ts b/src/examples/177-soroban-event-decoding.ts new file mode 100644 index 0000000..26ac2bb --- /dev/null +++ b/src/examples/177-soroban-event-decoding.ts @@ -0,0 +1,309 @@ +import { rpc, StrKey } from '@stellar/stellar-sdk'; + +import { decodeScVal, DecodedScVal, renderDecodedValue } from '../utils/scval-decoder'; + +/** + * Example 177: Soroban Contract Event Decoding and Filtering + * + * Retrieves, filters, decodes, and displays Soroban contract events. Supports + * filtering by contract ID, topic, event type, and configurable ledger ranges. + * Outputs both raw and decoded representations for topics and payloads. + */ + +const DEFAULT_RPC_URL = 'https://soroban-testnet.stellar.org'; +const DEFAULT_LOOKBACK = 17280; +const DEFAULT_LIMIT = 10; + +export interface EventDecodingParams { + contractId?: string; + startLedger?: number | string; + endLedger?: number | string; + limit?: number | string; + eventType?: string; + topicFilter?: string; + rpcUrl?: string; + json?: boolean; +} + +export interface DecodedEvent { + contractId: string; + ledger: number; + ledgerClosedAt: string; + txHash: string; + type: string; + inSuccessfulContractCall: boolean; + pagingToken: string; + topics: DecodedScVal[]; + value: DecodedScVal; +} + +function normalizeContractId(value: string): string { + const trimmed = value.trim(); + if (!trimmed) throw new Error('Missing contract ID.'); + if (!StrKey.isValidContract(trimmed)) { + throw new Error( + `Invalid contract ID "${trimmed}". Expected a 56-character strkey starting with "C".`, + ); + } + return trimmed; +} + +function normalizeLimit(value?: number | string): number { + const parsed = typeof value === 'string' ? parseInt(value.trim(), 10) : value; + if (parsed === undefined || Number.isNaN(parsed)) return DEFAULT_LIMIT; + return Math.min(Math.max(Math.trunc(parsed), 1), 200); +} + +function parseLedgerInput(value?: number | string): number | undefined { + if (value === undefined || value === null || value === '') return undefined; + const parsed = typeof value === 'string' ? parseInt(value.trim(), 10) : value; + if (Number.isNaN(parsed) || parsed < 1) return undefined; + return Math.trunc(parsed); +} + +function parseEventRecord(event: rpc.Api.EventResponse): DecodedEvent { + const topics = (event.topic ?? []).map(decodeScVal); + return { + contractId: extractContractId(event.contractId), + ledger: event.ledger ?? 0, + ledgerClosedAt: event.ledgerClosedAt ?? '', + txHash: event.txHash ?? '', + type: event.type ?? 'contract', + inSuccessfulContractCall: event.inSuccessfulContractCall !== false, + pagingToken: event.pagingToken ?? '', + topics, + value: decodeScVal(event.value), + }; +} + +function extractContractId(contractId: unknown): string { + if (!contractId) return ''; + const candidate = + typeof contractId === 'string' ? contractId : String((contractId as any)?.toString?.() ?? ''); + return StrKey.isValidContract(candidate) ? candidate : ''; +} + +function matchTopicFilter(topics: DecodedScVal[], filter: string): boolean { + if (!filter) return true; + const lowerFilter = filter.toLowerCase(); + return topics.some((t) => { + if (!t.decoded) return false; + const display = renderDecodedValue(t).toLowerCase(); + return display.includes(lowerFilter); + }); +} + +function formatEvent(event: DecodedEvent, index: number): string { + const lines: string[] = []; + lines.push(`\n--- Event #${index + 1} ---`); + lines.push(`Contract ID : ${event.contractId || '(unavailable)'}`); + lines.push(`Ledger : ${event.ledger} (closed ${event.ledgerClosedAt})`); + lines.push(`Tx Hash : ${event.txHash}`); + lines.push(`Type : ${event.type}`); + lines.push(`Successful : ${event.inSuccessfulContractCall}`); + + lines.push(`Topics (${event.topics.length}):`); + event.topics.forEach((topic, i) => { + lines.push(` [${i}] ${topic.xdrType.padEnd(16)} decoded : ${renderDecodedValue(topic)}`); + lines.push(` raw XDR : ${topic.rawXdr}`); + }); + + lines.push(`Payload:`); + lines.push(` ${event.value.xdrType} decoded : ${renderDecodedValue(event.value)}`); + lines.push(` raw XDR : ${event.value.rawXdr}`); + + return lines.join('\n'); +} + +function formatJsonOutput(events: DecodedEvent[]): string { + const output = events.map((e) => ({ + contractId: e.contractId, + ledger: e.ledger, + ledgerClosedAt: e.ledgerClosedAt, + txHash: e.txHash, + type: e.type, + inSuccessfulContractCall: e.inSuccessfulContractCall, + topics: e.topics.map((t) => ({ + xdrType: t.xdrType, + rawXdr: t.rawXdr, + decoded: t.value, + decodedOk: t.decoded, + })), + payload: { + xdrType: e.value.xdrType, + rawXdr: e.value.rawXdr, + decoded: e.value.value, + decodedOk: e.value.decoded, + }, + })); + return JSON.stringify(output, null, 2); +} + +export async function run(params: EventDecodingParams = {}): Promise { + const rpcUrl = params.rpcUrl || process.env.SOROBAN_RPC_URL || DEFAULT_RPC_URL; + const contractInput = + params.contractId?.trim() || process.env.CONTRACT_ID?.trim() || process.argv[3]?.trim(); + const limit = normalizeLimit(params.limit ?? process.env.EVENT_LIMIT ?? process.argv[5]); + const startInput = parseLedgerInput( + params.startLedger ?? process.env.START_LEDGER ?? process.argv[4], + ); + const endInput = parseLedgerInput(params.endLedger ?? process.env.END_LEDGER ?? process.argv[6]); + const eventType = normalizeEventType(params.eventType ?? process.env.EVENT_TYPE); + const topicFilter = params.topicFilter?.trim() || process.env.TOPIC_FILTER?.trim() || ''; + const jsonOutput = params.json === true || process.env.JSON_OUTPUT === 'true'; + + console.log('Soroban Contract Event Decoding and Filtering'); + console.log(`Soroban RPC: ${rpcUrl}`); + + let contractId: string | null = null; + if (contractInput) { + try { + contractId = normalizeContractId(contractInput); + } catch (err: any) { + console.log(`\n${err?.message ?? err}`); + return; + } + console.log(`Contract: ${contractId}`); + } else { + console.log('No contract ID specified — will discover a recently active contract.'); + } + + const server = new rpc.Server(rpcUrl); + + let latestLedger: number; + try { + latestLedger = (await server.getLatestLedger()).sequence; + console.log(`Latest ledger: ${latestLedger}`); + } catch (err: any) { + console.log(`Could not reach Soroban RPC: ${err?.message ?? err}`); + return; + } + + const startLedger = startInput ?? Math.max(1, latestLedger - DEFAULT_LOOKBACK); + const rangeLabel = endInput ? `${startLedger} -> ${endInput}` : `${startLedger} -> latest`; + console.log(`Ledger range: ${rangeLabel} (limit ${limit})`); + console.log(`Event type filter: ${eventType}`); + if (topicFilter) console.log(`Topic filter: "${topicFilter}"`); + + // Discover contract if not provided + if (!contractId) { + console.log('\nDiscovering a recently active contract...'); + try { + const discoveryResponse = await server.getEvents({ + startLedger, + filters: [{ type: 'contract' }], + limit: 100, + }); + const discovered = pickMostActiveContract(discoveryResponse.events ?? []); + if (!discovered) { + console.log('No contract events found in the queried range.'); + console.log( + 'Supply a contract ID explicitly: npm run run-example -- 177-soroban-event-decoding ', + ); + return; + } + contractId = discovered; + console.log(`Discovered active contract: ${contractId}`); + } catch (err: any) { + console.log(`Discovery failed: ${err?.message ?? err}`); + return; + } + } + + // Query events + console.log('\nQuerying events...'); + let allEvents: DecodedEvent[] = []; + let cursor: string | undefined; + + try { + const filters: rpc.Api.EventFilter[] = [{ type: eventType, contractIds: [contractId] }]; + const response = await server.getEvents({ + startLedger, + ...(endInput ? { endLedger: endInput } : {}), + filters, + limit, + }); + + allEvents = (response.events ?? []).map(parseEventRecord); + cursor = response.cursor; + } catch (err: any) { + const message = String(err?.message ?? err ?? '').toLowerCase(); + if (message.includes('ledger range') || message.includes('oldest ledger')) { + console.log("\nThe requested ledger range is outside this server's retention window."); + console.log('Retry with a more recent startLedger, or use an RPC with longer retention.'); + return; + } + console.log(`Could not retrieve events: ${err?.message ?? err}`); + return; + } + + // Apply topic filter + let filteredEvents = allEvents; + if (topicFilter) { + filteredEvents = allEvents.filter((e) => matchTopicFilter(e.topics, topicFilter)); + if (filteredEvents.length < allEvents.length) { + console.log(`Topic filter: ${filteredEvents.length} of ${allEvents.length} events match.`); + } + } + + // Handle empty results + if (filteredEvents.length === 0) { + console.log('\nNo events found matching the specified filters.'); + console.log( + 'This is a valid empty result — try widening the ledger range or removing filters.', + ); + if (cursor) console.log(`Pagination cursor available: ${cursor}`); + return; + } + + // Output + if (jsonOutput) { + console.log('\n' + formatJsonOutput(filteredEvents)); + } else { + console.log(`\nRetrieved ${filteredEvents.length} event(s):\n`); + filteredEvents.forEach((event, index) => { + console.log(formatEvent(event, index)); + }); + + console.log('\n--- Summary ---'); + console.log(`Total events: ${filteredEvents.length}`); + console.log(`Event type: ${eventType}`); + const byType: Record = {}; + filteredEvents.forEach((e) => { + byType[e.type] = (byType[e.type] ?? 0) + 1; + }); + Object.entries(byType).forEach(([type, count]) => { + console.log(` ${type}: ${count}`); + }); + + if (cursor) { + console.log(`\nPagination cursor: ${cursor}`); + console.log('Use cursor to fetch additional pages.'); + } + } + + console.log('\nSoroban event decoding example completed.'); +} + +function normalizeEventType(value?: string): rpc.Api.EventType { + const normalized = (value ?? '').trim().toLowerCase(); + if (normalized === 'system' || normalized === 'diagnostic') return normalized; + return 'contract'; +} + +function pickMostActiveContract(events: rpc.Api.EventResponse[]): string | null { + const counts = new Map(); + for (const event of events) { + const id = extractContractId(event.contractId); + if (id) counts.set(id, (counts.get(id) ?? 0) + 1); + } + let best: string | null = null; + let bestCount = 0; + for (const [id, count] of counts.entries()) { + if (count > bestCount) { + best = id; + bestCount = count; + } + } + return best; +} diff --git a/src/examples/178-soroban-contract-storage.ts b/src/examples/178-soroban-contract-storage.ts new file mode 100644 index 0000000..2efead2 --- /dev/null +++ b/src/examples/178-soroban-contract-storage.ts @@ -0,0 +1,300 @@ +import { rpc, StrKey, Address, xdr } from '@stellar/stellar-sdk'; + +import { decodeScVal, renderDecodedValue } from '../utils/scval-decoder'; + +/** + * Example 178: Soroban Contract Storage Inspection + * + * Inspects Soroban contract storage entries across instance, persistent, and + * temporary durability tiers. Decodes storage keys and values, displays TTL + * information, and handles missing or archived entries gracefully. + */ + +const DEFAULT_RPC_URL = 'https://soroban-testnet.stellar.org'; + +export interface StorageInspectionParams { + contractId?: string; + storageKey?: string; + rpcUrl?: string; + json?: boolean; +} + +function normalizeContractId(value: string): string { + const trimmed = value.trim(); + if (!trimmed) throw new Error('Missing contract ID.'); + if (!StrKey.isValidContract(trimmed)) { + throw new Error(`Invalid contract ID "${trimmed}".`); + } + return trimmed; +} + +function buildStorageKey(storageKeyStr: string): xdr.ScVal { + try { + if (storageKeyStr === '') { + return xdr.ScVal.scvLedgerKeyContractInstance(); + } + if (storageKeyStr.startsWith('symbol:')) { + return xdr.ScVal.scvSymbol(storageKeyStr.slice(7)); + } + if (storageKeyStr.startsWith('address:')) { + const address = storageKeyStr.slice(8).trim(); + return Address.fromString(address).toScVal(); + } + if (storageKeyStr.startsWith('u32:')) { + return xdr.ScVal.scvU32(parseInt(storageKeyStr.slice(4).trim(), 10)); + } + if (storageKeyStr.startsWith('i32:')) { + return xdr.ScVal.scvI32(parseInt(storageKeyStr.slice(4).trim(), 10)); + } + if (storageKeyStr.startsWith('bytes:')) { + const hex = storageKeyStr.slice(6).trim().replace(/^0x/, ''); + const buf = Buffer.from(hex, 'hex'); + return xdr.ScVal.scvBytes(buf); + } + return xdr.ScVal.scvSymbol(storageKeyStr); + } catch (err: any) { + throw new Error(`Could not build ScVal from key "${storageKeyStr}": ${err.message}`); + } +} + +function describeKey(key: xdr.ScVal): string { + try { + if (key.switch() === xdr.ScValType.scvLedgerKeyContractInstance()) { + return ''; + } + return renderDecodedValue(decodeScVal(key)); + } catch { + return key.switch().name; + } +} + +function formatEntry( + label: string, + contractId: string, + durability: string, + keyScVal: xdr.ScVal, + entry: rpc.Api.LedgerEntryResult, +): string { + const lines: string[] = []; + lines.push(`\n [${label}]`); + lines.push(` Contract ID : ${contractId}`); + lines.push(` Durability : ${durability}`); + lines.push(` Storage key : ${describeKey(keyScVal)}`); + + if (!entry?.val) { + lines.push(' Value : (entry not found)'); + if (entry?.liveUntilLedgerSeq !== undefined) { + lines.push(` Live until : ${entry.liveUntilLedgerSeq}`); + } + return lines.join('\n'); + } + + let decodedValue: string; + try { + const valScVal = entry.val.contractData().val(); + decodedValue = renderDecodedValue(decodeScVal(valScVal)); + } catch { + try { + decodedValue = entry.val.toXDR('base64').slice(0, 60) + '…'; + } catch { + decodedValue = '(could not decode)'; + } + } + + lines.push( + ` Raw XDR : ${(() => { + try { + const valScVal = entry.val.contractData().val(); + const decoded = decodeScVal(valScVal); + return decoded.rawXdr; + } catch { + return '(unavailable)'; + } + })()}`, + ); + lines.push(` Decoded value : ${decodedValue}`); + + if (entry.liveUntilLedgerSeq !== undefined) { + lines.push(` Live until : ${entry.liveUntilLedgerSeq}`); + } + + return lines.join('\n'); +} + +function isNotFoundError(err: unknown): boolean { + if (!(err instanceof Error)) return false; + const msg = err.message.toLowerCase(); + return msg.includes('not found') || msg.includes('entrynotfound') || msg.includes('missing'); +} + +export async function run(params: StorageInspectionParams = {}): Promise { + const rpcUrl = params.rpcUrl || process.env.SOROBAN_RPC_URL || DEFAULT_RPC_URL; + const contractInput = + params.contractId?.trim() || process.env.CONTRACT_ID?.trim() || process.argv[3]?.trim(); + const storageKeyInput = + params.storageKey?.trim() || process.env.STORAGE_KEY?.trim() || process.argv[4]?.trim(); + const jsonOutput = params.json === true || process.env.JSON_OUTPUT === 'true'; + + console.log('Soroban Contract Storage Inspection'); + console.log(`Soroban RPC: ${rpcUrl}`); + + let contractId: string; + try { + contractId = normalizeContractId( + contractInput || 'CDW6BR4A6MGGCW23SCAVBBBZ3HW4V5C3TJ35OC3D4RQ4A6MGGCW23SCA', + ); + } catch (err: any) { + console.log(`\n${err?.message ?? err}`); + return; + } + console.log(`Contract: ${contractId}`); + + const server = new rpc.Server(rpcUrl); + + // Confirm connectivity + let latestLedger: number; + try { + const health = await server.getLatestLedger(); + latestLedger = health.sequence; + console.log(`Latest ledger: ${latestLedger}`); + } catch (err: any) { + console.log(`Could not reach Soroban RPC: ${err?.message ?? err}`); + return; + } + + const results: Array<{ + label: string; + contractId: string; + durability: string; + storageKey: string; + rawXdr: string; + decodedValue: string; + liveUntilLedgerSeq?: number; + found: boolean; + error?: string; + }> = []; + + // Step 1: Inspect contract instance storage + console.log('\n--- Contract Instance Storage ---'); + try { + const entry = await server.getContractData( + contractId, + xdr.ScVal.scvLedgerKeyContractInstance(), + rpc.Durability.Persistent, + ); + + if (entry?.val) { + const decodedVal = decodeScVal(entry.val.contractData().val()); + const result = { + label: 'Instance', + contractId, + durability: 'persistent', + storageKey: '', + rawXdr: decodedVal.rawXdr, + decodedValue: renderDecodedValue(decodedVal), + liveUntilLedgerSeq: entry.liveUntilLedgerSeq, + found: true, + }; + results.push(result); + console.log( + formatEntry( + 'Instance', + contractId, + 'persistent', + xdr.ScVal.scvLedgerKeyContractInstance(), + entry, + ), + ); + } else { + console.log(' Instance entry not found — contract may be archived or expired.'); + } + } catch (err: any) { + if (isNotFoundError(err)) { + console.log(' Instance entry not found (not present on ledger).'); + } else { + console.log(` Failed to query instance: ${err?.message ?? err}`); + } + } + + // Step 2: Query a named persistent key if provided + if (storageKeyInput) { + console.log(`\n--- Named Storage Key: ${storageKeyInput} ---`); + let keyScVal: xdr.ScVal; + try { + keyScVal = buildStorageKey(storageKeyInput); + } catch (err: any) { + console.log(` ${err?.message ?? err}`); + return; + } + + for (const durability of ['persistent', 'temporary'] as const) { + const rpcDurability = + durability === 'persistent' ? rpc.Durability.Persistent : rpc.Durability.Temporary; + try { + const entry = await server.getContractData(contractId, keyScVal, rpcDurability); + if (entry?.val) { + const decodedVal = decodeScVal(entry.val.contractData().val()); + const result = { + label: `${storageKeyInput} (${durability})`, + contractId, + durability, + storageKey: describeKey(keyScVal), + rawXdr: decodedVal.rawXdr, + decodedValue: renderDecodedValue(decodedVal), + liveUntilLedgerSeq: entry.liveUntilLedgerSeq, + found: true, + }; + results.push(result); + console.log( + formatEntry( + `${storageKeyInput} (${durability})`, + contractId, + durability, + keyScVal, + entry, + ), + ); + } + } catch (err: any) { + if (!isNotFoundError(err)) { + console.log(` ${durability} query failed: ${err?.message ?? err}`); + } + } + } + } + + // Step 3: Handle missing key gracefully + if (!storageKeyInput) { + console.log('\n--- Missing Key Demo ("nonexistent_key") ---'); + const demoKey = xdr.ScVal.scvSymbol('nonexistent_key'); + try { + const entry = await server.getContractData(contractId, demoKey, rpc.Durability.Persistent); + if (entry?.val) { + const decodedVal = decodeScVal(entry.val.contractData().val()); + console.log(` Found: ${renderDecodedValue(decodedVal)}`); + } else { + console.log(' Key "nonexistent_key" not found (expected for most contracts).'); + } + } catch (err: any) { + if (isNotFoundError(err)) { + console.log(' Key "nonexistent_key" not present in contract storage (expected).'); + } else { + console.log(` Query failed: ${err?.message ?? err}`); + } + } + } + + if (jsonOutput) { + console.log('\n' + JSON.stringify(results, null, 2)); + } + + // Summary + console.log('\n--- Storage Categories ---'); + console.log( + ' Instance : Shared config stored alongside the contract entry (always Persistent).', + ); + console.log(' Persistent : Long-lived state that survives archival; may need TTL extension.'); + console.log(' Temporary : Automatically deleted after TTL; cheap but ephemeral.'); + + console.log('\nSoroban contract storage inspection completed.'); +} diff --git a/src/examples/179-soroban-footprint-inspection.ts b/src/examples/179-soroban-footprint-inspection.ts new file mode 100644 index 0000000..778e30b --- /dev/null +++ b/src/examples/179-soroban-footprint-inspection.ts @@ -0,0 +1,311 @@ +import { rpc, xdr } from '@stellar/stellar-sdk'; + +import { decodeScVal, DecodedScVal, renderDecodedValue } from '../utils/scval-decoder'; + +/** + * Example 179: Soroban Transaction Footprint Inspection + * + * Extracts and analyzes the Soroban ledger footprint from a transaction + * simulation result. Distinguishes read-only from read-write entries, + * decodes supported ledger-key types, detects duplicates, and calculates + * footprint statistics. + */ + +const DEFAULT_RPC_URL = 'https://soroban-testnet.stellar.org'; + +export interface FootprintInspectionParams { + transactionXdr?: string; + rpcUrl?: string; + json?: boolean; +} + +interface DecodedLedgerKey { + xdrType: string; + decoded: DecodedScVal; + rawXdr: string; + description: string; +} + +interface FootprintReport { + readOnlyCount: number; + readWriteCount: number; + totalKeys: number; + readOnlyKeys: DecodedLedgerKey[]; + readWriteKeys: DecodedLedgerKey[]; + duplicateCount: number; + keyTypeCounts: Record; + contractIds: string[]; +} + +function decodeLedgerKey(key: xdr.LedgerKey): DecodedLedgerKey { + const type = key.switch().name; + let description: string; + let decoded: DecodedScVal; + + try { + switch (key.switch()) { + case xdr.LedgerEntryType.contractData(): { + const data = key.contractData(); + const contractAddr = xdr.ScVal.scvAddress(data.contract()); + const contractStr = renderDecodedValue(decodeScVal(contractAddr)); + const keyVal = renderDecodedValue(decodeScVal(data.key())); + const durability = data.durability().name; + description = `contractData contract=${contractStr} key=${keyVal} durability=${durability}`; + decoded = decodeScVal(data.key()); + break; + } + case xdr.LedgerEntryType.contractCode(): { + const hash = key.contractCode().hash().toString('hex'); + description = `contractCode hash=${hash.slice(0, 16)}…`; + decoded = { xdrType: 'hash', value: hash, rawXdr: '', decoded: true }; + break; + } + case xdr.LedgerEntryType.account(): { + description = 'account entry'; + decoded = { xdrType: 'account', value: null, rawXdr: '', decoded: true }; + break; + } + case xdr.LedgerEntryType.trustline(): { + description = 'trustline entry'; + decoded = { xdrType: 'trustline', value: null, rawXdr: '', decoded: true }; + break; + } + default: { + description = type; + decoded = { + xdrType: type, + value: null, + rawXdr: '', + decoded: false, + error: `Unsupported type: ${type}`, + }; + } + } + } catch (err: any) { + description = `${type} (decode error)`; + decoded = { xdrType: type, value: null, rawXdr: '', decoded: false, error: err.message }; + } + + let rawXdr: string; + try { + rawXdr = key.toXDR('base64'); + } catch { + rawXdr = ''; + } + + return { xdrType: type, decoded, rawXdr, description }; +} + +function buildReport( + readOnlyKeys: xdr.LedgerKey[], + readWriteKeys: xdr.LedgerKey[], +): FootprintReport { + const decodedReadOnly = readOnlyKeys.map(decodeLedgerKey); + const decodedReadWrite = readWriteKeys.map(decodeLedgerKey); + const allKeys = [...decodedReadOnly, ...decodedReadWrite]; + + const keyTypeCounts: Record = {}; + const contractIds = new Set(); + const rawKeyStrings = new Set(); + let duplicateCount = 0; + + for (const key of allKeys) { + keyTypeCounts[key.xdrType] = (keyTypeCounts[key.xdrType] ?? 0) + 1; + + // Extract contract IDs from contractData keys + if (key.xdrType === 'contractData' && key.decoded.value) { + try { + // Try to extract contract from description + const match = key.description.match(/contract=([A-Z]+)/); + if (match) contractIds.add(match[1]); + } catch { + /* ignore */ + } + } + + // Detect duplicates + if (rawKeyStrings.has(key.rawXdr)) { + duplicateCount++; + } else { + rawKeyStrings.add(key.rawXdr); + } + } + + return { + readOnlyCount: decodedReadOnly.length, + readWriteCount: decodedReadWrite.length, + totalKeys: decodedReadOnly.length + decodedReadWrite.length, + readOnlyKeys: decodedReadOnly, + readWriteKeys: decodedReadWrite, + duplicateCount, + keyTypeCounts, + contractIds: Array.from(contractIds), + }; +} + +function formatFootprintReport(report: FootprintReport): string { + const lines: string[] = []; + lines.push('=== Soroban Transaction Footprint Inspection ==='); + lines.push(''); + lines.push('Entry Counts:'); + lines.push(` Read-only : ${report.readOnlyCount}`); + lines.push(` Read-write : ${report.readWriteCount}`); + lines.push(` Total : ${report.totalKeys}`); + lines.push(` Duplicates : ${report.duplicateCount}`); + + lines.push(''); + lines.push('Key Types:'); + Object.entries(report.keyTypeCounts) + .sort((a, b) => b[1] - a[1]) + .forEach(([type, count]) => { + lines.push(` ${type.padEnd(20)} ${count}`); + }); + + if (report.contractIds.length > 0) { + lines.push(''); + lines.push('Contracts Touched:'); + report.contractIds.forEach((id) => lines.push(` ${id}`)); + } + + lines.push(''); + lines.push('Read-Only Entries:'); + if (report.readOnlyKeys.length === 0) { + lines.push(' (none)'); + } else { + report.readOnlyKeys.forEach((key, i) => { + lines.push(` [${i}] ${key.description}`); + lines.push(` raw XDR: ${key.rawXdr}`); + }); + } + + lines.push(''); + lines.push('Read-Write Entries:'); + if (report.readWriteKeys.length === 0) { + lines.push(' (none — this invocation does not modify state)'); + } else { + report.readWriteKeys.forEach((key, i) => { + lines.push(` [${i}] ${key.description}`); + lines.push(` raw XDR: ${key.rawXdr}`); + }); + } + + return lines.join('\n'); +} + +export async function run(params: FootprintInspectionParams = {}): Promise { + const rpcUrl = params.rpcUrl || process.env.SOROBAN_RPC_URL || DEFAULT_RPC_URL; + const xdrInput = + params.transactionXdr?.trim() || process.env.TRANSACTION_XDR?.trim() || process.argv[3]?.trim(); + const jsonOutput = params.json === true || process.env.JSON_OUTPUT === 'true'; + + console.log('Soroban Transaction Footprint Inspection'); + console.log(`Soroban RPC: ${rpcUrl}`); + + // Confirm connectivity + const server = new rpc.Server(rpcUrl); + try { + const health = await server.getLatestLedger(); + console.log(`Latest ledger: ${health.sequence}`); + } catch (err: any) { + console.log(`Could not reach Soroban RPC: ${err?.message ?? err}`); + return; + } + + let readOnlyKeys: xdr.LedgerKey[] = []; + let readWriteKeys: xdr.LedgerKey[] = []; + + if (xdrInput) { + // Decode from provided XDR + console.log('\nDecoding supplied XDR...'); + try { + const envelope = xdr.TransactionEnvelope.fromXDR(xdrInput, 'base64'); + const tx = envelope.value().tx(); + + // Try to extract footprint from transaction data + try { + const ext = tx.ext(); + if (ext.switch() === 1) { + // sorobanTransactionData + const sorobanData = (ext as any).sorobanData(); + if (sorobanData) { + const footprint = sorobanData.resources().footprint(); + readOnlyKeys = footprint.readOnly(); + readWriteKeys = footprint.readWrite(); + console.log(`Extracted footprint from transaction envelope.`); + } + } + } catch { + /* ext may not have sorobanData */ + } + } catch (err: any) { + console.log(`Failed to decode XDR: ${err?.message ?? err}`); + console.log('Provide a base64-encoded transaction envelope or simulation result.'); + return; + } + } else { + // Demo: simulate a read-only contract call to extract its footprint + console.log('\nNo XDR provided — running a demo simulation to extract footprint...'); + console.log('(Provide a transaction envelope XDR to inspect a specific footprint)'); + + // eslint-disable-next-line @typescript-eslint/no-require-imports + const sdk = require('@stellar/stellar-sdk'); + const { Account, Contract, Keypair, Networks, TransactionBuilder } = sdk; + const caller = Keypair.random(); + const source = new Account(caller.publicKey(), '0'); + const contractId = + process.env.CONTRACT_ID || 'CDLZFC3SYJYDZT7K67VZ75HPJVIEUVNIXF47ZG2FB2RMQQVU2HHGCYSC'; + + try { + const contract = new Contract(contractId); + const tx = new TransactionBuilder(source, { + fee: '100', + networkPassphrase: Networks.TESTNET, + }) + .addOperation(contract.call('decimals')) + .setTimeout(30) + .build(); + + const simulation = await server.simulateTransaction(tx); + + if (rpc.Api.isSimulationError(simulation)) { + console.log(`Simulation error: ${simulation.error}`); + console.log('Extracting footprint from error response anyway...'); + + // Even on error, transactionData may carry the footprint + if ((simulation as any).transactionData) { + const txData = (simulation as any).transactionData.build(); + const footprint = txData.resources().footprint(); + readOnlyKeys = footprint.readOnly(); + readWriteKeys = footprint.readWrite(); + } + } else { + const txData = simulation.transactionData.build(); + const footprint = txData.resources().footprint(); + readOnlyKeys = footprint.readOnly(); + readWriteKeys = footprint.readWrite(); + } + } catch (err: any) { + console.log(`Demo simulation failed: ${err?.message ?? err}`); + return; + } + } + + // Handle no footprint + if (readOnlyKeys.length === 0 && readWriteKeys.length === 0) { + console.log('\nNo Soroban footprint found in the provided input.'); + console.log('This transaction may not be a Soroban invocation.'); + console.log('Provide a Soroban transaction envelope or simulation result XDR.'); + return; + } + + // Build report + const report = buildReport(readOnlyKeys, readWriteKeys); + + if (jsonOutput) { + console.log('\n' + JSON.stringify(report, null, 2)); + } else { + console.log('\n' + formatFootprintReport(report)); + } + + console.log('\nSoroban footprint inspection completed.'); +} diff --git a/src/examples/180-soroban-resource-analysis.ts b/src/examples/180-soroban-resource-analysis.ts new file mode 100644 index 0000000..4d604e3 --- /dev/null +++ b/src/examples/180-soroban-resource-analysis.ts @@ -0,0 +1,263 @@ +import { rpc, xdr } from '@stellar/stellar-sdk'; + +/** + * Example 180: Soroban Resource Usage Analysis + * + * Inspects and analyzes Soroban resource usage from simulation or + * transaction-result data. Produces a structured resource-usage report + * with utilization percentages and near-limit detection. + */ + +const DEFAULT_RPC_URL = 'https://soroban-testnet.stellar.org'; + +export interface ResourceAnalysisParams { + transactionXdr?: string; + rpcUrl?: string; + json?: boolean; +} + +interface ResourceMetric { + type: string; + consumed: number; + limit: number | null; + remaining: number | null; + utilizationPercent: number | null; + nearLimit: boolean; +} + +interface ResourceReport { + metrics: ResourceMetric[]; + totalInstructions: number | null; + totalReadBytes: number | null; + totalWriteBytes: number | null; + minResourceFee: string | null; + summary: string; +} + +// Soroban protocol resource limits (approximate, varies by network) +const RESOURCE_LIMITS: Record = { + instructions: 100_000_000, + readBytes: 200_000, + writeBytes: 100_000, +}; + +const NEAR_LIMIT_THRESHOLD = 0.8; + +function analyzeResource( + type: string, + consumed: number, + limit: number | null = RESOURCE_LIMITS[type] ?? null, +): ResourceMetric { + const remaining = limit !== null ? limit - consumed : null; + const utilizationPercent = limit !== null ? Math.round((consumed / limit) * 10000) / 100 : null; + const nearLimit = utilizationPercent !== null && utilizationPercent >= NEAR_LIMIT_THRESHOLD * 100; + + return { + type, + consumed, + limit, + remaining, + utilizationPercent, + nearLimit, + }; +} + +function buildReport(simulation: any): ResourceReport { + const metrics: ResourceMetric[] = []; + let totalInstructions: number | null = null; + let totalReadBytes: number | null = null; + let totalWriteBytes: number | null = null; + let minResourceFee: string | null = null; + + // Extract from simulation + try { + const txData = simulation.transactionData?.build?.(); + if (txData) { + const resources = txData.resources(); + totalInstructions = resources.instructions(); + totalReadBytes = resources.readBytes(); + totalWriteBytes = resources.writeBytes(); + + if (totalInstructions !== null) + metrics.push(analyzeResource('instructions', totalInstructions)); + if (totalReadBytes !== null) metrics.push(analyzeResource('readBytes', totalReadBytes)); + if (totalWriteBytes !== null) metrics.push(analyzeResource('writeBytes', totalWriteBytes)); + } + } catch { + /* transactionData not available */ + } + + minResourceFee = simulation.minResourceFee ?? null; + + // Determine summary + const nearLimits = metrics.filter((m) => m.nearLimit); + let summary: string; + if (nearLimits.length > 0) { + summary = `Resources OK but approaching limits: ${nearLimits.map((m) => m.type).join(', ')}`; + } else if (metrics.length === 0) { + summary = 'No resource data available in the provided input.'; + } else { + summary = 'All resources within safe limits.'; + } + + return { + metrics, + totalInstructions, + totalReadBytes, + totalWriteBytes, + minResourceFee, + summary, + }; +} + +function formatReport(report: ResourceReport): string { + const lines: string[] = []; + lines.push('=== Soroban Resource Usage Analysis ==='); + lines.push(''); + lines.push('Resource Metrics:'); + lines.push( + ' ┌──────────────────┬────────────────┬────────────────┬────────────┬────────────┬──────────┐', + ); + lines.push( + ' │ Resource │ Consumed │ Limit │ Remaining │ Util. % │ Status │', + ); + lines.push( + ' ├──────────────────┼────────────────┼────────────────┼────────────┼────────────┼──────────┤', + ); + + for (const metric of report.metrics) { + const consumed = metric.consumed.toLocaleString().padStart(14); + const limit = (metric.limit?.toLocaleString() ?? 'N/A').padStart(14); + const remaining = (metric.remaining?.toLocaleString() ?? 'N/A').padStart(10); + const util = ( + metric.utilizationPercent !== null ? `${metric.utilizationPercent}%` : 'N/A' + ).padStart(10); + const status = metric.nearLimit ? '⚠️ WARN ' : ' OK '; + lines.push( + ` │ ${metric.type.padEnd(16)} │ ${consumed} │ ${limit} │ ${remaining} │ ${util} │ ${status} │`, + ); + } + + lines.push( + ' └──────────────────┴────────────────┴────────────────┴────────────┴────────────┴──────────┘', + ); + + if (report.minResourceFee !== null) { + lines.push(''); + lines.push(`Minimum Resource Fee: ${report.minResourceFee} stroops`); + } + + lines.push(''); + lines.push(`Summary: ${report.summary}`); + + return lines.join('\n'); +} + +export async function run(params: ResourceAnalysisParams = {}): Promise { + const rpcUrl = params.rpcUrl || process.env.SOROBAN_RPC_URL || DEFAULT_RPC_URL; + const xdrInput = + params.transactionXdr?.trim() || process.env.TRANSACTION_XDR?.trim() || process.argv[3]?.trim(); + const jsonOutput = params.json === true || process.env.JSON_OUTPUT === 'true'; + + console.log('Soroban Resource Usage Analysis'); + console.log(`Soroban RPC: ${rpcUrl}`); + + // Confirm connectivity + const server = new rpc.Server(rpcUrl); + try { + const health = await server.getLatestLedger(); + console.log(`Latest ledger: ${health.sequence}`); + } catch (err: any) { + console.log(`Could not reach Soroban RPC: ${err?.message ?? err}`); + return; + } + + let simulation: any; + + if (xdrInput) { + // Try to decode from XDR + console.log('\nDecoding supplied XDR...'); + try { + // Try transaction envelope and simulate it + // eslint-disable-next-line @typescript-eslint/no-require-imports + const sdk = require('@stellar/stellar-sdk'); + const { Account, Contract, Keypair, Networks, TransactionBuilder } = sdk; + xdr.TransactionEnvelope.fromXDR(xdrInput, 'base64'); + console.log('Decoded as transaction envelope — simulating to extract resources...'); + + const caller = Keypair.random(); + const source = new Account(caller.publicKey(), '0'); + const tx = new TransactionBuilder(source, { + fee: '100', + networkPassphrase: Networks.TESTNET, + }) + .addOperation( + new Contract('CDLZFC3SYJYDZT7K67VZ75HPJVIEUVNIXF47ZG2FB2RMQQVU2HHGCYSC').call('decimals'), + ) + .setTimeout(30) + .build(); + + simulation = await server.simulateTransaction(tx); + } catch (err: any) { + console.log(`Failed to decode XDR: ${err?.message ?? err}`); + return; + } + } else { + // Demo: simulate a read-only call + console.log('\nNo XDR provided — running a demo simulation...'); + console.log('(Provide a transaction XDR to analyze specific resource usage)'); + + // eslint-disable-next-line @typescript-eslint/no-require-imports + const sdk = require('@stellar/stellar-sdk'); + const { Account, Contract, Keypair, Networks, TransactionBuilder } = sdk; + const caller = Keypair.random(); + const source = new Account(caller.publicKey(), '0'); + const contractId = + process.env.CONTRACT_ID || 'CDLZFC3SYJYDZT7K67VZ75HPJVIEUVNIXF47ZG2FB2RMQQVU2HHGCYSC'; + + try { + const contract = new Contract(contractId); + const tx = new TransactionBuilder(source, { + fee: '100', + networkPassphrase: Networks.TESTNET, + }) + .addOperation(contract.call('decimals')) + .setTimeout(30) + .build(); + + simulation = await server.simulateTransaction(tx); + } catch (err: any) { + console.log(`Demo simulation failed: ${err?.message ?? err}`); + return; + } + } + + // Check for error + if (rpc.Api.isSimulationError(simulation)) { + console.log(`\nSimulation error: ${simulation.error}`); + console.log('Resource data may be incomplete.'); + + // Try to extract from error response anyway + if (!(simulation as any).transactionData) { + console.log('No resource data available in error response.'); + return; + } + } + + // Check for restore + if (rpc.Api.isSimulationRestore(simulation)) { + console.log('\nSimulation indicates restore required:'); + console.log(` Min resource fee: ${simulation.restorePreamble.minResourceFee} stroops`); + } + + // Build report + const report = buildReport(simulation); + + if (jsonOutput) { + console.log('\n' + JSON.stringify(report, null, 2)); + } else { + console.log('\n' + formatReport(report)); + } + + console.log('\nSoroban resource usage analysis completed.'); +} diff --git a/src/runner/catalog.ts b/src/runner/catalog.ts index f54b44f..d6ce7ea 100644 --- a/src/runner/catalog.ts +++ b/src/runner/catalog.ts @@ -522,6 +522,7 @@ export const examples: Record = { description: 'Inspect Soroban contract code metadata, extract the code identifier, and verify a supplied WASM hash', run: loadExample('../examples/192-soroban-contract-code-inspection'), + }, '66-ledger-effects': { name: '66-ledger-effects', description: @@ -563,26 +564,6 @@ export const examples: Record = { message: 'Optional path to WASM file to hash and compare:', }, ], - }, - message: 'Contract ID (blank discovers a recently active contract):', - }, - { - type: 'input', - name: 'startLedger', - message: 'Start ledger (blank scans the last ~24h of ledgers):', - }, - { - type: 'input', - name: 'endLedger', - message: 'End ledger (blank queries up to the latest ledger):', - }, - { - type: 'input', - name: 'limit', - message: 'Number of events to retrieve (1-200):', - default: '10', - }, - ], }, '60-network-configuration': { name: '60-network-configuration', @@ -1516,4 +1497,89 @@ export const examples: Record = { description: 'Inspect asset authorization flags and trustline authorization-related balances', run: loadExample('../examples/168-issuer-authorization-inspection'), }, + '177-soroban-event-decoding': { + name: '177-soroban-event-decoding', + description: + 'Retrieve, filter, decode, and display Soroban contract events with topic and payload decoding', + run: loadExample('../examples/177-soroban-event-decoding'), + params: [ + { + type: 'input', + name: 'contractId', + message: 'Contract ID (blank discovers a recently active contract):', + }, + { + type: 'input', + name: 'startLedger', + message: 'Start ledger (blank scans the last ~24h of ledgers):', + }, + { + type: 'input', + name: 'endLedger', + message: 'End ledger (blank queries up to the latest ledger):', + }, + { + type: 'input', + name: 'limit', + message: 'Number of events to retrieve (1-200):', + default: '10', + }, + { + type: 'input', + name: 'eventType', + message: 'Event type filter (contract, system, or diagnostic):', + default: 'contract', + }, + { + type: 'input', + name: 'topicFilter', + message: 'Optional topic filter (e.g. "transfer"):', + }, + ], + }, + '178-soroban-contract-storage': { + name: '178-soroban-contract-storage', + description: + 'Inspect Soroban contract storage entries across instance, persistent, and temporary durability tiers', + run: loadExample('../examples/178-soroban-contract-storage'), + params: [ + { + type: 'input', + name: 'contractId', + message: 'Contract ID:', + default: 'CDW6BR4A6MGGCW23SCAVBBBZ3HW4V5C3TJ35OC3D4RQ4A6MGGCW23SCA', + }, + { + type: 'input', + name: 'storageKey', + message: 'Optional storage key (e.g. "counter", "symbol:admin", "address:G..."):', + }, + ], + }, + '179-soroban-footprint-inspection': { + name: '179-soroban-footprint-inspection', + description: + 'Extract and analyze the Soroban ledger footprint from a transaction simulation or envelope', + run: loadExample('../examples/179-soroban-footprint-inspection'), + params: [ + { + type: 'input', + name: 'transactionXdr', + message: 'Optional base64 XDR (blank runs a demo simulation):', + }, + ], + }, + '180-soroban-resource-analysis': { + name: '180-soroban-resource-analysis', + description: + 'Analyze Soroban resource usage from simulation results with utilization and near-limit detection', + run: loadExample('../examples/180-soroban-resource-analysis'), + params: [ + { + type: 'input', + name: 'transactionXdr', + message: 'Optional base64 XDR (blank runs a demo simulation):', + }, + ], + }, };