diff --git a/README.md b/README.md index 136e270..31e8b8a 100644 --- a/README.md +++ b/README.md @@ -164,6 +164,7 @@ The repository currently includes the following runnable examples: 51. **`54-fee-stats`**: Inspecting network fee statistics, fee percentiles, capacity usage, and recommended fee values. 52. **`57-account-reserve-calculator`**: Calculating account minimum reserve requirements and available XLM balance from ledger entry breakdowns. 53. **`58-account-relationship-discovery`**: Discovering and grouping account relationships including signers, asset issuers, sponsorships, and counterparties. +54. **`190-soroban-transaction-event-monitor`**: Monitoring a submitted Soroban transaction by polling for its terminal state, extracting and decoding emitted contract events and diagnostic events, grouping them by contract and event type, and preserving raw XDR alongside decoded values. 54. **`192-soroban-contract-code-inspection`**: Inspecting Soroban contract code metadata, extracting the deployed code identifier, retrieving TTL and ledger information, and optionally comparing a supplied WASM hash against the on-chain code identifier. 54. **`66-ledger-effects`**: Retrieving every effect produced by one closed ledger, grouping them by effect type and category, and summarizing the state changes a ledger introduced. 55. **`67-soroban-contract-events`**: Querying Soroban contract events over a ledger range, decoding event topics and data payloads, and reporting the ledger and transaction that produced each event. diff --git a/src/examples/190-soroban-transaction-event-monitor.ts b/src/examples/190-soroban-transaction-event-monitor.ts new file mode 100644 index 0000000..2bf1a7a --- /dev/null +++ b/src/examples/190-soroban-transaction-event-monitor.ts @@ -0,0 +1,556 @@ +import { xdr, rpc, scValToNative, Contract } from '@stellar/stellar-sdk'; +import chalk from 'chalk'; + +// --------------------------------------------------------------------------- +// Constants +// --------------------------------------------------------------------------- + +const DEFAULT_RPC_URL = 'https://soroban-testnet.stellar.org'; +const DEFAULT_POLL_INTERVAL_MS = 1_500; +const DEFAULT_MAX_INTERVAL_MS = 10_000; +const DEFAULT_TIMEOUT_MS = 60_000; + +// --------------------------------------------------------------------------- +// Public types +// --------------------------------------------------------------------------- + +export interface MonitorParams { + rpcUrl?: string; + txHash?: string; + pollIntervalMs?: number; + maxIntervalMs?: number; + timeoutMs?: number; + json?: boolean; +} + +export interface DecodedValue { + xdrType: string; + value: unknown; + decoded: boolean; +} + +export interface DecodedEvent { + id: string; + type: string; + contractId: string; + ledger: number; + ledgerClosedAt: string; + txHash: string; + pagingToken: string; + inSuccessfulContractCall: boolean; + /** Decoded topics */ + topics: DecodedValue[]; + /** First symbol topic, if any */ + eventName: string | null; + /** Decoded payload */ + value: DecodedValue; + /** Raw base64 XDR for each topic */ + rawTopics: string[]; + /** Raw base64 XDR payload */ + rawValue: string; +} + +export interface EventGroup { + contractId: string; + events: DecodedEvent[]; +} + +export interface MonitorReport { + txHash: string; + status: 'SUCCESS' | 'FAILED' | 'NOT_FOUND' | 'TIMEOUT' | 'ERROR'; + ledger: number | null; + ledgerClosedAt: string | null; + envelopeXdr: string | null; + resultXdr: string | null; + returnValue: DecodedValue | null; + events: DecodedEvent[]; + diagnosticEvents: DecodedEvent[]; + byContract: Record; + byEventName: Record; + totalEvents: number; + error: string | null; + pollAttempts: number; +} + +// --------------------------------------------------------------------------- +// Input validation +// --------------------------------------------------------------------------- + +/** Returns true when the string looks like a 64-hex-character transaction hash. */ +export function isValidTxHash(hash: string): boolean { + if (typeof hash !== 'string') return false; + return /^[0-9a-fA-F]{64}$/.test(hash.trim()); +} + +// --------------------------------------------------------------------------- +// ScVal decoding +// --------------------------------------------------------------------------- + +/** + * Decode a single ScVal, returning a structured result that preserves type + * information. Never throws — returns decoded=false on failure. + */ +export function decodeScVal(val: xdr.ScVal | undefined): DecodedValue { + if (!val) return { xdrType: 'void', value: null, decoded: true }; + + const xdrType = val.switch()?.name ?? 'unknown'; + try { + const native = scValToNative(val); + return { xdrType, value: formatNativeValue(native), decoded: true }; + } catch { + return { xdrType, value: null, decoded: false }; + } +} + +/** + * Recursively make a native ScVal value JSON-serializable. + * Converts BigInt → string, Buffer → 0x-hex, Map → plain object. + */ +export function formatNativeValue(v: unknown): unknown { + if (v === null || v === undefined) return v; + if (typeof v === 'bigint') return v.toString(); + if (v instanceof Uint8Array) return '0x' + Buffer.from(v).toString('hex'); + if (v instanceof Map) { + const obj: Record = {}; + v.forEach((val, key) => { + obj[String(key)] = formatNativeValue(val); + }); + return obj; + } + if (Array.isArray(v)) return v.map(formatNativeValue); + if (typeof v === 'object') { + const obj: Record = {}; + for (const [k, val] of Object.entries(v as Record)) { + obj[k] = formatNativeValue(val); + } + return obj; + } + return v; +} + +/** Extract the event name from the first symbol topic, if present. */ +export function extractEventName(topics: DecodedValue[]): string | null { + if (!topics.length) return null; + const first = topics[0]; + if (first.xdrType === 'scvSymbol' && typeof first.value === 'string') return first.value; + return null; +} + +/** Extract a contract strkey from either a Contract instance or raw string. */ +export function extractContractId(raw: unknown): string { + if (!raw) return ''; + if (typeof raw === 'string') return raw; + if (raw instanceof Contract) return raw.address().toString(); + return ''; +} + +// --------------------------------------------------------------------------- +// Event parsing +// --------------------------------------------------------------------------- + +/** Shape returned by server.getEvents */ +export type RawEventRecord = rpc.Api.EventRecord; + +/** Parse a single RPC event record into a fully decoded structure. */ +export function parseEventRecord(ev: RawEventRecord): DecodedEvent { + const rawTopics = (ev.topic ?? []).map((t: xdr.ScVal) => t.toXDR('base64')); + const rawValue = ev.value ? (ev.value as xdr.ScVal).toXDR('base64') : ''; + + const topics = (ev.topic ?? []).map((t: xdr.ScVal) => decodeScVal(t)); + const value = decodeScVal(ev.value as xdr.ScVal | undefined); + + return { + id: ev.id ?? '', + type: ev.type ?? 'contract', + contractId: extractContractId(ev.contractId), + ledger: ev.ledger ?? 0, + ledgerClosedAt: ev.ledgerClosedAt ?? '', + txHash: ev.txHash ?? '', + pagingToken: ev.pagingToken ?? '', + inSuccessfulContractCall: ev.inSuccessfulContractCall ?? true, + topics, + eventName: extractEventName(topics), + value, + rawTopics, + rawValue, + }; +} + +/** + * Group an array of decoded events by contractId and by eventName. + */ +export function groupEvents(events: DecodedEvent[]): { + byContract: Record; + byEventName: Record; +} { + const byContract: Record = {}; + const byEventName: Record = {}; + + for (const ev of events) { + const cid = ev.contractId || 'unknown'; + (byContract[cid] ??= []).push(ev); + + const name = ev.eventName ?? ev.type ?? 'unknown'; + (byEventName[name] ??= []).push(ev); + } + return { byContract, byEventName }; +} + +// --------------------------------------------------------------------------- +// Polling helpers +// --------------------------------------------------------------------------- + +export interface PollConfig { + intervalMs: number; + maxIntervalMs: number; + timeoutMs: number; +} + +export type PollResult = + | { kind: 'SUCCESS'; response: rpc.Api.GetSuccessfulTransactionResponse; attempts: number } + | { kind: 'FAILED'; response: rpc.Api.GetFailedTransactionResponse; attempts: number } + | { kind: 'NOT_FOUND'; attempts: number } + | { kind: 'TIMEOUT'; attempts: number } + | { kind: 'ERROR'; error: string; attempts: number }; + +/** + * Poll a transaction until it reaches a terminal state or the deadline passes. + * + * Temporary RPC failures (network blips) are retried without terminating. + * Interval grows up to maxIntervalMs to reduce load on the RPC node. + */ +export async function pollTransaction( + server: rpc.Server, + txHash: string, + config: PollConfig, +): Promise { + const deadline = Date.now() + config.timeoutMs; + let interval = config.intervalMs; + let attempts = 0; + + while (Date.now() < deadline) { + await sleep(interval); + attempts++; + + let response: rpc.Api.GetTransactionResponse; + try { + response = await server.getTransaction(txHash); + } catch (err: any) { + // Transient RPC failure — back off and retry + interval = Math.min(interval * 2, config.maxIntervalMs); + continue; + } + + switch (response.status) { + case rpc.Api.GetTransactionStatus.SUCCESS: + return { + kind: 'SUCCESS', + response: response as rpc.Api.GetSuccessfulTransactionResponse, + attempts, + }; + case rpc.Api.GetTransactionStatus.FAILED: + return { + kind: 'FAILED', + response: response as rpc.Api.GetFailedTransactionResponse, + attempts, + }; + case rpc.Api.GetTransactionStatus.NOT_FOUND: + // Still pending or not yet ingested — continue polling + interval = Math.min(interval * 1.5, config.maxIntervalMs); + break; + } + } + + return { kind: 'TIMEOUT', attempts }; +} + +function sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +// --------------------------------------------------------------------------- +// Event retrieval +// --------------------------------------------------------------------------- + +/** + * Fetch contract events associated with a specific transaction hash from a + * surrounding ledger window. + * + * getEvents does not support direct hash filtering, so we query around the + * transaction's ledger and filter client-side. + */ +export async function fetchTransactionEvents( + server: rpc.Server, + txHash: string, + txLedger: number, +): Promise<{ events: DecodedEvent[]; diagnosticEvents: DecodedEvent[] }> { + const startLedger = Math.max(1, txLedger - 2); + + let rawEvents: RawEventRecord[] = []; + try { + const response = await server.getEvents({ + startLedger, + filters: [{ type: 'contract' }], + limit: 200, + }); + rawEvents = response.events ?? []; + } catch { + // Non-fatal; return empty + } + + const txEvents = rawEvents.filter((ev) => ev.txHash === txHash); + + let diagnosticRaw: RawEventRecord[] = []; + try { + const diagResponse = await server.getEvents({ + startLedger, + filters: [{ type: 'diagnostic' }], + limit: 200, + }); + diagnosticRaw = (diagResponse.events ?? []).filter((ev) => ev.txHash === txHash); + } catch { + // Diagnostic events are optional + } + + return { + events: txEvents.map(parseEventRecord), + diagnosticEvents: diagnosticRaw.map(parseEventRecord), + }; +} + +// --------------------------------------------------------------------------- +// Core monitor +// --------------------------------------------------------------------------- + +/** + * Monitor a submitted Soroban transaction: poll until terminal, then collect + * and decode all associated contract events. + */ +export async function monitorTransaction( + server: rpc.Server, + txHash: string, + config: PollConfig, +): Promise { + const report: MonitorReport = { + txHash, + status: 'ERROR', + ledger: null, + ledgerClosedAt: null, + envelopeXdr: null, + resultXdr: null, + returnValue: null, + events: [], + diagnosticEvents: [], + byContract: {}, + byEventName: {}, + totalEvents: 0, + error: null, + pollAttempts: 0, + }; + + const pollResult = await pollTransaction(server, txHash, config); + report.pollAttempts = pollResult.attempts; + + if (pollResult.kind === 'TIMEOUT') { + report.status = 'TIMEOUT'; + report.error = `Timed out after ${config.timeoutMs}ms (${pollResult.attempts} attempts)`; + return report; + } + + if (pollResult.kind === 'ERROR') { + report.status = 'ERROR'; + report.error = pollResult.error; + return report; + } + + if (pollResult.kind === 'NOT_FOUND') { + report.status = 'NOT_FOUND'; + report.error = 'Transaction not found on the network after polling'; + return report; + } + + if (pollResult.kind === 'FAILED') { + const failedResp = pollResult.response; + report.status = 'FAILED'; + report.ledger = failedResp.ledger ?? null; + report.ledgerClosedAt = failedResp.createdAt + ? new Date(Number(failedResp.createdAt) * 1000).toISOString() + : null; + report.envelopeXdr = failedResp.envelopeXdr?.toXDR('base64') ?? null; + report.resultXdr = failedResp.resultXdr?.toXDR('base64') ?? null; + report.error = 'Transaction failed on-chain'; + return report; + } + + // SUCCESS path + const successResp = pollResult.response; + report.status = 'SUCCESS'; + report.ledger = successResp.ledger ?? null; + report.ledgerClosedAt = successResp.createdAt + ? new Date(Number(successResp.createdAt) * 1000).toISOString() + : null; + report.envelopeXdr = successResp.envelopeXdr?.toXDR('base64') ?? null; + report.resultXdr = successResp.resultXdr?.toXDR('base64') ?? null; + + if (successResp.returnValue) { + report.returnValue = decodeScVal(successResp.returnValue); + } + + // Collect events + if (report.ledger !== null) { + const { events, diagnosticEvents } = await fetchTransactionEvents( + server, + txHash, + report.ledger, + ); + report.events = events; + report.diagnosticEvents = diagnosticEvents; + } + + const { byContract, byEventName } = groupEvents(report.events); + report.byContract = byContract; + report.byEventName = byEventName; + report.totalEvents = report.events.length; + + return report; +} + +// --------------------------------------------------------------------------- +// Display helpers +// --------------------------------------------------------------------------- + +function renderValue(dv: DecodedValue): string { + if (!dv.decoded) return chalk.yellow(``); + if (dv.value === null) return chalk.gray('void'); + return chalk.cyan(JSON.stringify(dv.value)); +} + +function printEvent(ev: DecodedEvent, index: number): void { + console.log(chalk.bold(`\n Event #${index + 1}`)); + console.log(` ID: ${ev.id}`); + console.log(` Type: ${ev.type}`); + console.log(` Contract: ${ev.contractId || chalk.gray('n/a')}`); + console.log(` Ledger: ${ev.ledger}`); + console.log(` Tx Hash: ${ev.txHash}`); + if (ev.eventName) { + console.log(` Event Name: ${chalk.green(ev.eventName)}`); + } + if (!ev.inSuccessfulContractCall) { + console.log(chalk.yellow(' ⚠ emitted by a sub-call that failed')); + } + console.log(` Topics (${ev.topics.length}):`); + ev.topics.forEach((t, i) => { + console.log(` [${i}] ${chalk.gray(t.xdrType)} = ${renderValue(t)}`); + console.log(` raw: ${chalk.gray(ev.rawTopics[i] ?? '')}`); + }); + console.log(` Value: ${chalk.gray(ev.value.xdrType)} = ${renderValue(ev.value)}`); + console.log(` Raw Value: ${chalk.gray(ev.rawValue || '(empty)')}`); +} + +function printReport(report: MonitorReport, jsonOutput: boolean): void { + if (jsonOutput) { + console.log(JSON.stringify(report, null, 2)); + return; + } + + console.log(chalk.bold('\n=== Soroban Transaction Event Monitor ===')); + console.log(`${chalk.bold('Tx Hash:')} ${report.txHash}`); + console.log( + `${chalk.bold('Status:')} ${ + report.status === 'SUCCESS' + ? chalk.green(report.status) + : report.status === 'FAILED' + ? chalk.red(report.status) + : chalk.yellow(report.status) + }`, + ); + + if (report.ledger) { + console.log(`${chalk.bold('Ledger:')} ${report.ledger}`); + } + if (report.ledgerClosedAt) { + console.log(`${chalk.bold('Closed At:')} ${report.ledgerClosedAt}`); + } + if (report.pollAttempts) { + console.log(`${chalk.bold('Poll Attempts:')} ${report.pollAttempts}`); + } + + if (report.returnValue) { + console.log(`${chalk.bold('Return Value:')} ${renderValue(report.returnValue)}`); + } + + if (report.error) { + console.log(chalk.red(`\nError: ${report.error}`)); + } + + // Events + console.log(chalk.bold(`\n--- Contract Events (${report.totalEvents}) ---`)); + if (report.events.length === 0) { + console.log(chalk.gray(' No contract events emitted by this transaction.')); + } else { + report.events.forEach((ev, i) => printEvent(ev, i)); + + // Group summary + const contractIds = Object.keys(report.byContract); + if (contractIds.length > 1) { + console.log(chalk.bold('\n--- By Contract ---')); + for (const cid of contractIds) { + console.log(` ${cid}: ${report.byContract[cid].length} event(s)`); + } + } + + const eventNames = Object.keys(report.byEventName); + if (eventNames.length > 0) { + console.log(chalk.bold('\n--- By Event Type ---')); + for (const name of eventNames) { + console.log(` ${name}: ${report.byEventName[name].length} event(s)`); + } + } + } + + // Diagnostic events + if (report.diagnosticEvents.length > 0) { + console.log(chalk.bold(`\n--- Diagnostic Events (${report.diagnosticEvents.length}) ---`)); + report.diagnosticEvents.forEach((ev: DecodedEvent, i: number) => printEvent(ev, i)); + } + + console.log(''); +} + +// --------------------------------------------------------------------------- +// Entrypoint +// --------------------------------------------------------------------------- + +export async function run(params?: MonitorParams): Promise { + const rpcUrl = params?.rpcUrl ?? process.env.SOROBAN_RPC_URL ?? DEFAULT_RPC_URL; + const jsonOutput = params?.json === true || process.env.JSON_OUTPUT === 'true'; + + const txHash = + (params?.txHash ?? process.env.TX_HASH ?? '').trim(); + + const pollConfig: PollConfig = { + intervalMs: (params?.pollIntervalMs ?? Number(process.env.POLL_INTERVAL_MS)) || DEFAULT_POLL_INTERVAL_MS, + maxIntervalMs: (params?.maxIntervalMs ?? Number(process.env.MAX_INTERVAL_MS)) || DEFAULT_MAX_INTERVAL_MS, + timeoutMs: (params?.timeoutMs ?? Number(process.env.POLL_TIMEOUT_MS)) || DEFAULT_TIMEOUT_MS, + }; + + if (!jsonOutput) { + console.log(chalk.blue('Soroban Transaction Event Monitor')); + console.log(chalk.gray(`RPC: ${rpcUrl}`)); + } + + if (!isValidTxHash(txHash)) { + const msg = `Invalid transaction hash: "${txHash}". Expected 64 hex characters.`; + if (jsonOutput) { + console.log(JSON.stringify({ error: msg })); + } else { + console.error(chalk.red(msg)); + } + return; + } + + const server = new rpc.Server(rpcUrl); + const report = await monitorTransaction(server, txHash, pollConfig); + + printReport(report, jsonOutput); +} diff --git a/src/runner/catalog.ts b/src/runner/catalog.ts index 22fef68..513151f 100644 --- a/src/runner/catalog.ts +++ b/src/runner/catalog.ts @@ -517,6 +517,37 @@ export const examples: Record = { }, ], }, + '190-soroban-transaction-event-monitor': { + name: '190-soroban-transaction-event-monitor', + description: + 'Monitor a submitted Soroban transaction, poll for its terminal state, and extract and decode emitted contract events', + run: loadExample('../examples/190-soroban-transaction-event-monitor'), + params: [ + { + type: 'input', + name: 'rpcUrl', + message: 'Soroban RPC URL (blank uses testnet):', + default: 'https://soroban-testnet.stellar.org', + }, + { + type: 'input', + name: 'txHash', + message: 'Transaction hash to monitor (64 hex characters):', + }, + { + type: 'input', + name: 'pollIntervalMs', + message: 'Poll interval in ms (blank uses 1500):', + default: '1500', + }, + { + type: 'input', + name: 'timeoutMs', + message: 'Timeout in ms (blank uses 60000):', + default: '60000', + }, + ], + }, '192-soroban-contract-code-inspection': { name: '192-soroban-contract-code-inspection', description: diff --git a/tests/soroban-transaction-event-monitor.test.ts b/tests/soroban-transaction-event-monitor.test.ts new file mode 100644 index 0000000..a17dae4 --- /dev/null +++ b/tests/soroban-transaction-event-monitor.test.ts @@ -0,0 +1,482 @@ +import { Address, Contract, nativeToScVal, xdr } from '@stellar/stellar-sdk'; + +import { + isValidTxHash, + decodeScVal, + formatNativeValue, + extractEventName, + extractContractId, + parseEventRecord, + groupEvents, + pollTransaction, + monitorTransaction, + RawEventRecord, + DecodedEvent, + PollConfig, +} from '../src/examples/190-soroban-transaction-event-monitor'; +import { examples } from '../src/runner/catalog'; + +// --------------------------------------------------------------------------- +// Fixtures +// --------------------------------------------------------------------------- + +const VALID_HASH = 'a'.repeat(64); +const CONTRACT_ID = 'CDLZFC3SYJYDZT7K67VZ75HPJVIEUVNIXF47ZG2FB2RMQQVU2HHGCYSC'; +const ACCOUNT_ID = 'GAAZI4TCR3TY5OJHCTJC2A4QSY6CJWJH5IAJTGKIN2ER7LBNVKOCCWN7'; + +function buildEvent(overrides: Partial = {}): RawEventRecord { + return { + id: '0001234567890-0000000001', + type: 'contract', + ledger: 1000, + ledgerClosedAt: '2026-07-28T10:00:00Z', + txHash: VALID_HASH, + contractId: CONTRACT_ID, + topic: [ + xdr.ScVal.scvSymbol('transfer'), + nativeToScVal(new Address(ACCOUNT_ID)), + ], + value: nativeToScVal(999n, { type: 'i128' }), + inSuccessfulContractCall: true, + pagingToken: '0001234567890-0000000001', + ...overrides, + } as unknown as RawEventRecord; +} + +// --------------------------------------------------------------------------- +// isValidTxHash +// --------------------------------------------------------------------------- + +describe('isValidTxHash', () => { + it('accepts a 64-char lowercase hex string', () => { + expect(isValidTxHash(VALID_HASH)).toBe(true); + }); + + it('accepts uppercase hex', () => { + expect(isValidTxHash('B'.repeat(64))).toBe(true); + }); + + it('rejects strings shorter than 64 chars', () => { + expect(isValidTxHash('a'.repeat(63))).toBe(false); + }); + + it('rejects strings longer than 64 chars', () => { + expect(isValidTxHash('a'.repeat(65))).toBe(false); + }); + + it('rejects non-hex characters', () => { + expect(isValidTxHash('g'.repeat(64))).toBe(false); + }); + + it('rejects empty string', () => { + expect(isValidTxHash('')).toBe(false); + }); + + it('rejects non-string input', () => { + expect(isValidTxHash(null as any)).toBe(false); + expect(isValidTxHash(undefined as any)).toBe(false); + }); +}); + +// --------------------------------------------------------------------------- +// decodeScVal +// --------------------------------------------------------------------------- + +describe('decodeScVal', () => { + it('decodes a symbol', () => { + const val = xdr.ScVal.scvSymbol('mint'); + const result = decodeScVal(val); + expect(result.xdrType).toBe('scvSymbol'); + expect(result.value).toBe('mint'); + expect(result.decoded).toBe(true); + }); + + it('decodes an i128 as a string', () => { + const val = nativeToScVal(12345n, { type: 'i128' }); + const result = decodeScVal(val); + expect(result.xdrType).toBe('scvI128'); + expect(result.value).toBe('12345'); + expect(result.decoded).toBe(true); + }); + + it('returns void for undefined input', () => { + const result = decodeScVal(undefined); + expect(result).toEqual({ xdrType: 'void', value: null, decoded: true }); + }); + + it('handles unsupported types without throwing', () => { + const broken = { switch: () => ({ name: 'scvUnknown' }) } as any; + const result = decodeScVal(broken); + expect(result.decoded).toBe(false); + expect(result.xdrType).toBe('scvUnknown'); + }); +}); + +// --------------------------------------------------------------------------- +// formatNativeValue +// --------------------------------------------------------------------------- + +describe('formatNativeValue', () => { + it('converts BigInt to string', () => { + expect(formatNativeValue(42n)).toBe('42'); + }); + + it('converts Buffer to 0x-prefixed hex', () => { + expect(formatNativeValue(Buffer.from([0xca, 0xfe]))).toBe('0xcafe'); + }); + + it('recursively handles arrays', () => { + expect(formatNativeValue([1n, 2n])).toEqual(['1', '2']); + }); + + it('handles plain objects with BigInt values', () => { + expect(formatNativeValue({ amount: 7n })).toEqual({ amount: '7' }); + }); + + it('handles Map instances', () => { + const m = new Map([['k', 5n]]); + expect(formatNativeValue(m)).toEqual({ k: '5' }); + }); + + it('passes through primitives unchanged', () => { + expect(formatNativeValue('hello')).toBe('hello'); + expect(formatNativeValue(42)).toBe(42); + expect(formatNativeValue(null)).toBeNull(); + }); + + it('produces JSON-serializable output for complex values', () => { + const result = formatNativeValue({ total: 2n ** 100n, nested: { x: [1n] } }); + expect(() => JSON.stringify(result)).not.toThrow(); + }); +}); + +// --------------------------------------------------------------------------- +// extractEventName +// --------------------------------------------------------------------------- + +describe('extractEventName', () => { + it('returns the symbol string from the first topic', () => { + const topics = [{ xdrType: 'scvSymbol', value: 'burn', decoded: true }]; + expect(extractEventName(topics)).toBe('burn'); + }); + + it('returns null when first topic is not a symbol', () => { + const topics = [{ xdrType: 'scvAddress', value: ACCOUNT_ID, decoded: true }]; + expect(extractEventName(topics)).toBeNull(); + }); + + it('returns null for empty topics', () => { + expect(extractEventName([])).toBeNull(); + }); +}); + +// --------------------------------------------------------------------------- +// extractContractId +// --------------------------------------------------------------------------- + +describe('extractContractId', () => { + it('returns the string as-is', () => { + expect(extractContractId(CONTRACT_ID)).toBe(CONTRACT_ID); + }); + + it('extracts the ID from a Contract instance', () => { + const contract = new Contract(CONTRACT_ID); + expect(extractContractId(contract)).toBe(contract.address().toString()); + }); + + it('returns empty string for null/undefined', () => { + expect(extractContractId(null)).toBe(''); + expect(extractContractId(undefined)).toBe(''); + }); + + it('returns empty string for unexpected types', () => { + expect(extractContractId({ unexpected: true })).toBe(''); + }); +}); + +// --------------------------------------------------------------------------- +// parseEventRecord +// --------------------------------------------------------------------------- + +describe('parseEventRecord', () => { + it('decodes topics and value, captures event name from first symbol', () => { + const parsed = parseEventRecord(buildEvent()); + expect(parsed.eventName).toBe('transfer'); + expect(parsed.topics).toHaveLength(2); + expect(parsed.topics[0]).toMatchObject({ xdrType: 'scvSymbol', value: 'transfer' }); + expect(parsed.value.xdrType).toBe('scvI128'); + expect(parsed.value.value).toBe('999'); + }); + + it('preserves ledger, txHash, contractId, and pagingToken', () => { + const parsed = parseEventRecord(buildEvent()); + expect(parsed.ledger).toBe(1000); + expect(parsed.txHash).toBe(VALID_HASH); + expect(parsed.contractId).toBe(CONTRACT_ID); + expect(parsed.pagingToken).toBe('0001234567890-0000000001'); + }); + + it('preserves raw XDR alongside decoded values', () => { + const parsed = parseEventRecord(buildEvent()); + expect(parsed.rawTopics).toHaveLength(2); + expect(parsed.rawTopics[0]).toBeTruthy(); + expect(parsed.rawValue).toBeTruthy(); + }); + + it('handles an event with no topics', () => { + const parsed = parseEventRecord(buildEvent({ topic: [] })); + expect(parsed.topics).toHaveLength(0); + expect(parsed.eventName).toBeNull(); + }); + + it('defaults inSuccessfulContractCall to true when absent', () => { + const parsed = parseEventRecord(buildEvent({ inSuccessfulContractCall: undefined })); + expect(parsed.inSuccessfulContractCall).toBe(true); + }); + + it('flags failed sub-call events', () => { + const parsed = parseEventRecord(buildEvent({ inSuccessfulContractCall: false })); + expect(parsed.inSuccessfulContractCall).toBe(false); + }); +}); + +// --------------------------------------------------------------------------- +// groupEvents +// --------------------------------------------------------------------------- + +describe('groupEvents', () => { + const OTHER_CONTRACT = 'CBIELTK6YBZJU5UP2WWQEUCYKLPU6AUNZ2BQ4WWFEIE3USCIHMXQDAMA'; + + function makeDecoded(overrides: Partial = {}): DecodedEvent { + return { + id: '1', + type: 'contract', + contractId: CONTRACT_ID, + ledger: 1000, + ledgerClosedAt: '', + txHash: VALID_HASH, + pagingToken: '', + inSuccessfulContractCall: true, + topics: [], + eventName: 'transfer', + value: { xdrType: 'void', value: null, decoded: true }, + rawTopics: [], + rawValue: '', + ...overrides, + }; + } + + it('groups by contractId', () => { + const events = [ + makeDecoded({ contractId: CONTRACT_ID }), + makeDecoded({ contractId: OTHER_CONTRACT }), + makeDecoded({ contractId: CONTRACT_ID }), + ]; + const { byContract } = groupEvents(events); + expect(byContract[CONTRACT_ID]).toHaveLength(2); + expect(byContract[OTHER_CONTRACT]).toHaveLength(1); + }); + + it('groups by eventName', () => { + const events = [ + makeDecoded({ eventName: 'mint' }), + makeDecoded({ eventName: 'burn' }), + makeDecoded({ eventName: 'mint' }), + ]; + const { byEventName } = groupEvents(events); + expect(byEventName['mint']).toHaveLength(2); + expect(byEventName['burn']).toHaveLength(1); + }); + + it('returns empty groups for an empty event array', () => { + const { byContract, byEventName } = groupEvents([]); + expect(Object.keys(byContract)).toHaveLength(0); + expect(Object.keys(byEventName)).toHaveLength(0); + }); +}); + +// --------------------------------------------------------------------------- +// pollTransaction +// --------------------------------------------------------------------------- + +describe('pollTransaction', () => { + const config: PollConfig = { intervalMs: 0, maxIntervalMs: 10, timeoutMs: 5_000 }; + + function makeServer(responses: Array<() => rpc.Api.GetTransactionResponse | never>): any { + let call = 0; + return { + getTransaction: jest.fn(async () => { + const fn = responses[Math.min(call++, responses.length - 1)]; + return fn(); + }), + }; + } + + it('returns SUCCESS on first successful response', async () => { + const server = makeServer([ + () => ({ status: rpc.Api.GetTransactionStatus.SUCCESS }) as any, + ]); + const result = await pollTransaction(server, VALID_HASH, config); + expect(result.kind).toBe('SUCCESS'); + expect(result.attempts).toBe(1); + }); + + it('returns FAILED on a failed transaction', async () => { + const server = makeServer([ + () => ({ status: rpc.Api.GetTransactionStatus.FAILED }) as any, + ]); + const result = await pollTransaction(server, VALID_HASH, config); + expect(result.kind).toBe('FAILED'); + }); + + it('polls past NOT_FOUND before reaching SUCCESS', async () => { + let calls = 0; + const server = { + getTransaction: jest.fn(async () => { + calls++; + if (calls < 3) return { status: rpc.Api.GetTransactionStatus.NOT_FOUND } as any; + return { status: rpc.Api.GetTransactionStatus.SUCCESS } as any; + }), + }; + const result = await pollTransaction(server, VALID_HASH, config); + expect(result.kind).toBe('SUCCESS'); + expect(result.attempts).toBeGreaterThanOrEqual(3); + }); + + it('returns TIMEOUT when the deadline expires', async () => { + const tightConfig: PollConfig = { intervalMs: 10, maxIntervalMs: 10, timeoutMs: 1 }; + const server = makeServer([ + () => ({ status: rpc.Api.GetTransactionStatus.NOT_FOUND }) as any, + ]); + const result = await pollTransaction(server, VALID_HASH, tightConfig); + expect(result.kind).toBe('TIMEOUT'); + }); + + it('retries after a transient RPC error without immediately failing', async () => { + let calls = 0; + const server = { + getTransaction: jest.fn(async () => { + calls++; + if (calls === 1) throw new Error('network error'); + return { status: rpc.Api.GetTransactionStatus.SUCCESS } as any; + }), + }; + const result = await pollTransaction(server, VALID_HASH, config); + expect(result.kind).toBe('SUCCESS'); + }); +}); + +// --------------------------------------------------------------------------- +// monitorTransaction +// --------------------------------------------------------------------------- + +describe('monitorTransaction', () => { + const config: PollConfig = { intervalMs: 0, maxIntervalMs: 10, timeoutMs: 5_000 }; + + function makeServer(txStatus: string, ledger = 1000): any { + return { + getTransaction: jest.fn(async () => ({ + status: txStatus, + ledger, + createdAt: '1700000000', + envelopeXdr: null, + resultXdr: null, + returnValue: undefined, + })), + getEvents: jest.fn(async () => ({ events: [] })), + }; + } + + it('builds a SUCCESS report with correct ledger and status', async () => { + const server = makeServer(rpc.Api.GetTransactionStatus.SUCCESS); + const report = await monitorTransaction(server, VALID_HASH, config); + expect(report.status).toBe('SUCCESS'); + expect(report.ledger).toBe(1000); + expect(report.error).toBeNull(); + }); + + it('builds a FAILED report with an error message', async () => { + const server = makeServer(rpc.Api.GetTransactionStatus.FAILED); + const report = await monitorTransaction(server, VALID_HASH, config); + expect(report.status).toBe('FAILED'); + expect(report.error).toBeTruthy(); + }); + + it('handles empty event list gracefully', async () => { + const server = makeServer(rpc.Api.GetTransactionStatus.SUCCESS); + const report = await monitorTransaction(server, VALID_HASH, config); + expect(report.events).toHaveLength(0); + expect(report.totalEvents).toBe(0); + }); + + it('returns TIMEOUT report when deadline is exceeded', async () => { + const tightConfig: PollConfig = { intervalMs: 10, maxIntervalMs: 10, timeoutMs: 1 }; + const server = { + getTransaction: jest.fn(async () => ({ + status: rpc.Api.GetTransactionStatus.NOT_FOUND, + })), + getEvents: jest.fn(async () => ({ events: [] })), + }; + const report = await monitorTransaction(server, VALID_HASH, tightConfig); + expect(report.status).toBe('TIMEOUT'); + expect(report.error).toContain('Timed out'); + }); +}); + +// --------------------------------------------------------------------------- +// JSON output +// --------------------------------------------------------------------------- + +describe('JSON output', () => { + it('isValidTxHash rejects short hash before any RPC call', () => { + expect(isValidTxHash('abc')).toBe(false); + }); + + it('a full report serializes without throwing', () => { + const report = { + txHash: VALID_HASH, + status: 'SUCCESS', + ledger: 1000, + events: [parseEventRecord(buildEvent())], + }; + expect(() => JSON.stringify(report)).not.toThrow(); + }); +}); + +// --------------------------------------------------------------------------- +// Runner registration +// --------------------------------------------------------------------------- + +describe('runner registration', () => { + it('is registered in the catalog', () => { + expect(examples['190-soroban-transaction-event-monitor']).toBeDefined(); + }); + + it('has a run function', () => { + expect(typeof examples['190-soroban-transaction-event-monitor'].run).toBe('function'); + }); + + it('has a description', () => { + expect(examples['190-soroban-transaction-event-monitor'].description.length).toBeGreaterThan( + 10, + ); + }); + + it('prompts for rpcUrl and txHash', () => { + const params = examples['190-soroban-transaction-event-monitor'].params ?? []; + const names = params.map((p) => p.name); + expect(names).toContain('rpcUrl'); + expect(names).toContain('txHash'); + }); +}); + +// --------------------------------------------------------------------------- +// README documentation +// --------------------------------------------------------------------------- + +describe('README documentation', () => { + it('lists the example in the README catalog', () => { + const readme = require('fs').readFileSync('README.md', 'utf8'); + expect(readme).toContain('190-soroban-transaction-event-monitor'); + }); +});