Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,7 @@ The repository currently includes the following runnable examples:
62. **`69-soroban-contract-storage`**: Retrieving and inspecting Soroban contract storage entries via `getLedgerEntries`, decoding keys and values, and explaining instance, persistent, and temporary storage durability.
63. **`70-soroban-authorization`**: Invoking an authorized Soroban contract method, obtaining and signing authorization entries from simulation, and explaining how authorization differs from transaction signatures.
64. **`71-soroban-storage-update`**: Demonstrating the complete lifecycle of a Soroban storage update — reading initial state, simulating and submitting the modifying transaction, polling for confirmation, and verifying the updated value.
65. **`105-contract-event-decoding`**: Retrieving Soroban contract events and decoding indexed topics and data payloads into human-readable values, with raw base64 XDR shown alongside decoded output.
65. **`107-contract-spec-introspection`**: Retrieving on-chain WASM, parsing Soroban ScSpec metadata, and displaying functions, arguments, return types, user-defined types, and documentation with dynamic function selection.
65. **`81-transaction-preflight`**: Running the full Soroban preflight workflow — simulating an invocation, extracting the footprint/authorization/resource-fee data, assembling, signing, submitting, and confirming the final transaction.
65. **`83-multi-contract-transaction`**: Composing a single orchestrator contract invocation that touches multiple downstream contracts, simulating and submitting it, and explaining atomicity and execution order across contracts within one Soroban host invocation.
Expand Down Expand Up @@ -433,6 +434,19 @@ CONTRACT_ID=<id> CONTRACT_METHOD=increment CONTRACT_READ_METHOD=get npm run run-

The example reads the initial storage value, simulates and submits a state-modifying transaction, polls for on-chain confirmation, and re-reads the storage to display a before-and-after comparison.

Decode Soroban contract event topics and payloads:

```bash
npm run run-example 105-contract-event-decoding
```

Query a specific contract, start ledger, and limit:

```bash
npm run run-example -- 105-contract-event-decoding <contract-id> <start-ledger> 10
```

The same values can be supplied through `CONTRACT_ID`, `START_LEDGER`, and `EVENT_LIMIT`. For each event the example prints the contract ID, ledger sequence, transaction hash, every topic and the data payload with raw base64 XDR beside the decoded value. Unsupported ScVal types are reported without aborting the run.
Inspect a Soroban contract specification from on-chain WASM:

```bash
Expand Down
160 changes: 160 additions & 0 deletions src/examples/105-contract-event-decoding.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,160 @@
import { rpc, StrKey } from '@stellar/stellar-sdk';

import {
compareScVal,
decodePayload,
decodeTopics,
formatEventDecodingReport,
} from '../utils/scval-decoder';

/**
* Example 105: Soroban Contract Event Decoding
*
* Contract events carry topics (indexed ScVals) and a data payload (one ScVal).
* This example retrieves events for a contract and decodes every topic and the
* payload into human-readable values, showing raw base64 XDR alongside decoded
* output for side-by-side comparison.
*/

const DEFAULT_RPC_URL = 'https://soroban-testnet.stellar.org';
const DEFAULT_CONTRACT_ID = 'CDLZFC3SYJYDZT7K67VZ75HPJVIEUVNIXF47ZG2FB2RMQQVU2HHGCYSC';
const DEFAULT_LOOKBACK = 17280;
const DEFAULT_LIMIT = 5;

export interface ContractEventDecodingParams {
contractId?: string;
startLedger?: number | string;
limit?: number | string;
rpcUrl?: string;
}

export function normalizeContractId(value: string): string {
const trimmed = value.trim();
if (!trimmed) {
throw new Error('Missing contract ID. Provide a contract ID starting with "C".');
}
if (!StrKey.isValidContract(trimmed)) {
throw new Error(`Invalid contract ID "${trimmed}".`);
}
return trimmed;
}

export 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), 50);
}

export 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);
}

export async function run(params: ContractEventDecodingParams = {}): Promise<void> {
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() ||
DEFAULT_CONTRACT_ID;
const limit = normalizeLimit(params.limit ?? process.env.EVENT_LIMIT ?? process.argv[5]);
const startInput = parseLedgerInput(
params.startLedger ?? process.env.START_LEDGER ?? process.argv[4],
);

console.log('Soroban Contract Event Decoding Example');
console.log(`Soroban RPC: ${rpcUrl}`);

let contractId: string;
try {
contractId = normalizeContractId(contractInput);
} catch (err: any) {
console.log(`\n${err?.message ?? err}`);
return;
}

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);
console.log(`Contract: ${contractId}`);
console.log(`Ledger range: ${startLedger} -> latest (limit ${limit})`);

let response: rpc.Api.GetEventsResponse;
try {
response = await server.getEvents({
startLedger,
filters: [{ type: 'contract', contractIds: [contractId] }],
limit,
});
} catch (err: any) {
console.log(`\nCould not retrieve events: ${err?.message ?? err}`);
return;
}

const events = response.events ?? [];
if (events.length === 0) {
console.log('\nNo contract events found in the queried ledger range.');
console.log('This is a valid empty result — try a more recent start ledger or another contract.');

Check failure on line 108 in src/examples/105-contract-event-decoding.ts

View workflow job for this annotation

GitHub Actions / Lint · Type-check · Test · Build (22.x)

Replace `'This·is·a·valid·empty·result·—·try·a·more·recent·start·ledger·or·another·contract.'` with `⏎······'This·is·a·valid·empty·result·—·try·a·more·recent·start·ledger·or·another·contract.',⏎····`

Check failure on line 108 in src/examples/105-contract-event-decoding.ts

View workflow job for this annotation

GitHub Actions / Lint · Type-check · Test · Build (20.x)

Replace `'This·is·a·valid·empty·result·—·try·a·more·recent·start·ledger·or·another·contract.'` with `⏎······'This·is·a·valid·empty·result·—·try·a·more·recent·start·ledger·or·another·contract.',⏎····`
return;
}

console.log(`\nRetrieved ${events.length} event(s). Decoding topics and payloads...\n`);

events.forEach((event, index) => {
console.log(formatEventDecodingReport({

Check failure on line 115 in src/examples/105-contract-event-decoding.ts

View workflow job for this annotation

GitHub Actions / Lint · Type-check · Test · Build (22.x)

Insert `⏎······`

Check failure on line 115 in src/examples/105-contract-event-decoding.ts

View workflow job for this annotation

GitHub Actions / Lint · Type-check · Test · Build (20.x)

Insert `⏎······`
contractId,

Check failure on line 116 in src/examples/105-contract-event-decoding.ts

View workflow job for this annotation

GitHub Actions / Lint · Type-check · Test · Build (22.x)

Insert `··`

Check failure on line 116 in src/examples/105-contract-event-decoding.ts

View workflow job for this annotation

GitHub Actions / Lint · Type-check · Test · Build (20.x)

Insert `··`
ledger: event.ledger ?? 0,

Check failure on line 117 in src/examples/105-contract-event-decoding.ts

View workflow job for this annotation

GitHub Actions / Lint · Type-check · Test · Build (22.x)

Insert `··`

Check failure on line 117 in src/examples/105-contract-event-decoding.ts

View workflow job for this annotation

GitHub Actions / Lint · Type-check · Test · Build (20.x)

Insert `··`
txHash: event.txHash ?? '',

Check failure on line 118 in src/examples/105-contract-event-decoding.ts

View workflow job for this annotation

GitHub Actions / Lint · Type-check · Test · Build (22.x)

Insert `··`

Check failure on line 118 in src/examples/105-contract-event-decoding.ts

View workflow job for this annotation

GitHub Actions / Lint · Type-check · Test · Build (20.x)

Insert `··`
topics: event.topic ?? [],

Check failure on line 119 in src/examples/105-contract-event-decoding.ts

View workflow job for this annotation

GitHub Actions / Lint · Type-check · Test · Build (22.x)

Insert `··`

Check failure on line 119 in src/examples/105-contract-event-decoding.ts

View workflow job for this annotation

GitHub Actions / Lint · Type-check · Test · Build (20.x)

Insert `··`
value: event.value,

Check failure on line 120 in src/examples/105-contract-event-decoding.ts

View workflow job for this annotation

GitHub Actions / Lint · Type-check · Test · Build (22.x)

Replace `······` with `········`

Check failure on line 120 in src/examples/105-contract-event-decoding.ts

View workflow job for this annotation

GitHub Actions / Lint · Type-check · Test · Build (20.x)

Replace `······` with `········`
}));

Check failure on line 121 in src/examples/105-contract-event-decoding.ts

View workflow job for this annotation

GitHub Actions / Lint · Type-check · Test · Build (22.x)

Replace `})` with `··}),⏎····`

Check failure on line 121 in src/examples/105-contract-event-decoding.ts

View workflow job for this annotation

GitHub Actions / Lint · Type-check · Test · Build (20.x)

Replace `})` with `··}),⏎····`

const topicComparisons = (event.topic ?? []).map(compareScVal);
const payloadComparison = compareScVal(event.value);

console.log('\nSide-by-side summary:');
topicComparisons.forEach((row, topicIndex) => {
console.log(
` topic[${topicIndex}] ${row.xdrType.padEnd(12)} raw=${row.rawXdr.slice(0, 24)}… decoded=${row.decodedDisplay}`,
);
});
console.log(
` payload ${payloadComparison.xdrType.padEnd(12)} raw=${payloadComparison.rawXdr.slice(0, 24)}… decoded=${payloadComparison.decodedDisplay}`,
);

const decodedTopics = decodeTopics(event.topic);
const decodedPayload = decodePayload(event.value);
const unsupported = [...decodedTopics, decodedPayload].filter((item) => !item.decoded);
if (unsupported.length > 0) {
console.log('\nUnsupported or undecodable values:');
unsupported.forEach((item) => {
console.log(` - ${item.xdrType}: ${item.error ?? 'decode failed'}`);
});
}

if (index < events.length - 1) {
console.log('\n' + '-'.repeat(72));
}
});

console.log('\nDecoding reference:');
console.log(' Address -> G… or C… strkey');
console.log(' Symbol -> short identifier string');
console.log(' String -> UTF-8 text');
console.log(' Bool -> true / false');
console.log(' Integer -> string (preserves i128/u256 precision)');
console.log(' Bytes -> 0x-prefixed hex');
console.log(' Vec/Map -> JSON array or object');
console.log('\nContract event decoding example completed.');
}
15 changes: 15 additions & 0 deletions src/runner/catalog.ts
Original file line number Diff line number Diff line change
Expand Up @@ -621,6 +621,11 @@ export const examples: Record<string, Example> = {
'Read initial contract storage, invoke a state-modifying method, confirm the transaction, and verify the updated storage value',
run: loadExample('../examples/71-soroban-storage-update'),
},
'105-contract-event-decoding': {
name: '105-contract-event-decoding',
description:
'Retrieve Soroban contract events and decode topics and payloads with raw XDR side-by-side',
run: loadExample('../examples/105-contract-event-decoding'),
'107-contract-spec-introspection': {
name: '107-contract-spec-introspection',
description:
Expand All @@ -635,6 +640,16 @@ export const examples: Record<string, Example> = {
},
{
type: 'input',
name: 'startLedger',
message: 'Start ledger (blank scans ~24h):',
},
{
type: 'input',
name: 'limit',
message: 'Number of events to decode (1-50):',
default: '5',
},
],
name: 'functionName',
message: 'Optional function name for dynamic selection:',
},
Expand Down
181 changes: 181 additions & 0 deletions src/utils/scval-decoder.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,181 @@
import { scValToNative, xdr } from '@stellar/stellar-sdk';

/** A decoded ScVal with its XDR type name and JSON-safe native value. */
export interface DecodedScVal {
xdrType: string;
rawXdr: string;
value: unknown;
decoded: boolean;
error?: string;
}

/** Side-by-side raw XDR and decoded representation for display. */
export interface ScValComparison {
xdrType: string;
rawXdr: string;
decodedDisplay: string;
decoded: boolean;
}

const UNSUPPORTED_XDR_TYPES = new Set(['scvContractInstanceWasm', 'scvLedgerKeyContractInstance']);

/** Returns the XDR discriminant name for an ScVal, or "unknown" on failure. */
export function getScValXdrType(scVal: xdr.ScVal): string {
try {
const discriminant = (scVal as unknown as { switch?: () => { name?: string } }).switch?.();
return discriminant?.name ?? String(discriminant ?? 'unknown');
} catch {
return 'unknown';
}
}

/**
* Converts decoded native values into JSON-safe representations.
*
* BigInts become strings, byte buffers become hex, and Maps become plain objects.
*/
export function formatNativeValue(value: unknown): unknown {
if (typeof value === 'bigint') {
return value.toString();
}

if (value instanceof Uint8Array) {
return `0x${Buffer.from(value).toString('hex')}`;
}

if (Array.isArray(value)) {
return value.map(formatNativeValue);
}

if (value instanceof Map) {
const entries: Record<string, unknown> = {};
for (const [key, entry] of value.entries()) {
entries[String(formatNativeValue(key))] = formatNativeValue(entry);
}
return entries;
}

if (value && typeof value === 'object') {
const out: Record<string, unknown> = {};
for (const [key, entry] of Object.entries(value as Record<string, unknown>)) {
out[key] = formatNativeValue(entry);
}
return out;
}

return value;
}

/** Decodes one ScVal without throwing; unsupported types are reported gracefully. */
export function decodeScVal(scVal: xdr.ScVal | undefined): DecodedScVal {
if (!scVal) {
return { xdrType: 'void', rawXdr: '', value: null, decoded: true };
}

const xdrType = getScValXdrType(scVal);
let rawXdr = '';
try {
rawXdr = scVal.toXDR('base64');
} catch {
rawXdr = '(could not serialize)';
}

if (UNSUPPORTED_XDR_TYPES.has(xdrType)) {
return {
xdrType,
rawXdr,
value: null,
decoded: false,
error: `ScVal type "${xdrType}" is not supported for native decoding in this example`,
};
}

try {
return {
xdrType,
rawXdr,
value: formatNativeValue(scValToNative(scVal)),
decoded: true,
};
} catch (error: any) {
return {
xdrType,
rawXdr,
value: null,
decoded: false,
error: error?.message || String(error),
};
}
}

/** Renders a decoded value as a single-line human-readable string. */
export function renderDecodedValue(decoded: DecodedScVal): string {
if (!decoded.decoded) {
return `<undecodable: ${decoded.error ?? 'unknown error'}>`;
}
if (typeof decoded.value === 'string') {
return `"${decoded.value}"`;
}
if (decoded.value === null || decoded.value === undefined) {
return 'void';
}
return JSON.stringify(decoded.value);
}

/** Builds a raw-vs-decoded comparison row for console output. */
export function compareScVal(scVal: xdr.ScVal | undefined): ScValComparison {
const decoded = decodeScVal(scVal);
return {
xdrType: decoded.xdrType,
rawXdr: decoded.rawXdr,
decodedDisplay: renderDecodedValue(decoded),
decoded: decoded.decoded,
};
}

/** Decodes an event topic tuple in order. */
export function decodeTopics(topics: xdr.ScVal[] | undefined): DecodedScVal[] {
return (topics ?? []).map(decodeScVal);
}

/** Decodes an event data payload. */
export function decodePayload(value: xdr.ScVal | undefined): DecodedScVal {
return decodeScVal(value);
}

/** Formats topics and payload with raw XDR shown alongside decoded values. */
export function formatEventDecodingReport(options: {
contractId: string;
ledger: number;
txHash: string;
topics: xdr.ScVal[];
value?: xdr.ScVal;
}): string {
const lines: string[] = [];
lines.push('=== Soroban Contract Event Decoding ===');
lines.push(`Contract ID : ${options.contractId}`);
lines.push(`Ledger sequence : ${options.ledger}`);
lines.push(`Transaction hash: ${options.txHash}`);
lines.push('');
lines.push('Topics (indexed):');

const topicDecoded = decodeTopics(options.topics);
if (topicDecoded.length === 0) {
lines.push(' (none)');
} else {
topicDecoded.forEach((topic, index) => {
lines.push(` [${index}] type=${topic.xdrType}`);
lines.push(` raw XDR : ${topic.rawXdr}`);
lines.push(` decoded : ${renderDecodedValue(topic)}`);
});
}

lines.push('');
lines.push('Payload (data):');
const payload = decodePayload(options.value);
lines.push(` type=${payload.xdrType}`);
lines.push(` raw XDR : ${payload.rawXdr}`);
lines.push(` decoded : ${renderDecodedValue(payload)}`);

return lines.join('\n');
}
Loading