Skip to content

Commit 2cea525

Browse files
authored
Merge branch 'main' into feature/104-contract-restoration
2 parents e54087e + 0e83a77 commit 2cea525

13 files changed

Lines changed: 2623 additions & 0 deletions

README.md

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -111,6 +111,11 @@ The repository currently includes the following runnable examples:
111111
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.
112112
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.
113113
65. **`104-contract-restoration`**: Detecting archived Soroban contract ledger entries, building and simulating a `RestoreFootprint` transaction, submitting restoration when required, and verifying the contract becomes accessible again — with guidance on TTL extension versus restoration.
114+
65. **`106-scval-serialization`**: Converting JavaScript values to Soroban ScVal objects and back with reusable helpers, displaying raw XDR, and explaining common serialization pitfalls.
115+
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.
116+
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.
117+
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.
118+
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.
114119

115120
## Installation
116121

@@ -444,6 +449,65 @@ npm run run-example -- 104-contract-restoration <contract-id>
444449
```
445450

446451
The same contract ID can be supplied through `CONTRACT_ID`. For accessible contracts the example simulates restoration and reports the estimated fee and footprint without submitting an unnecessary transaction. When simulation detects archived entries (`isSimulationRestore`), it prepares, submits, and polls a `RestoreFootprint` transaction, then re-checks accessibility. The output explains the difference between proactive `extendFootprintTtl` and reactive `restoreFootprint`.
452+
Convert JavaScript values to Soroban ScVal and back:
453+
454+
```bash
455+
npm run run-example 106-scval-serialization
456+
```
457+
458+
This offline example encodes booleans, integers, BigInts, strings, symbols, bytes, addresses, vectors, maps, and nested objects using `src/utils/scval-utils.ts`, prints raw base64 XDR for each value, compares originals with decoded round-trip results, and demonstrates graceful handling of unsupported JavaScript types.
459+
Decode Soroban contract event topics and payloads:
460+
461+
```bash
462+
npm run run-example 105-contract-event-decoding
463+
```
464+
465+
Query a specific contract, start ledger, and limit:
466+
467+
```bash
468+
npm run run-example -- 105-contract-event-decoding <contract-id> <start-ledger> 10
469+
```
470+
471+
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.
472+
Inspect a Soroban contract specification from on-chain WASM:
473+
474+
```bash
475+
npm run run-example 107-contract-spec-introspection
476+
```
477+
478+
Select a contract and function dynamically:
479+
480+
```bash
481+
npm run run-example -- 107-contract-spec-introspection <contract-id> balance
482+
```
483+
484+
The same values can be supplied through `CONTRACT_ID` and `CONTRACT_FUNCTION`. The example fetches WASM via Soroban RPC, parses ScSpec metadata with `spec-parser` utilities, lists functions, structs, enums, unions, and error enums, and shows how SDK tooling and explorers use the same metadata. Missing or empty specifications are reported gracefully.
485+
Run the full Soroban transaction preflight workflow:
486+
487+
```bash
488+
npm run run-example 81-transaction-preflight
489+
```
490+
491+
Supply a custom contract ID and method via environment variables:
492+
493+
```bash
494+
CONTRACT_ID=<contract-id> CONTRACT_METHOD=<method> npm run run-example 81-transaction-preflight
495+
```
496+
497+
The example funds an ephemeral fee-payer account, builds a contract invocation transaction, and submits it for preflight simulation to extract the ledger footprint, authorization entries, and estimated resource fee. It then assembles the final transaction from that simulation data, signs and submits it, and polls until on-chain confirmation — while also explaining how a full preflight (simulate → assemble → sign → submit) differs from an ordinary read-only simulation that is never meant to be submitted, and reporting any preflight failures with clear, actionable guidance.
498+
Compose a multi-contract transaction through an orchestrator invocation:
499+
500+
```bash
501+
npm run run-example 83-multi-contract-transaction
502+
```
503+
504+
Supply a custom orchestrator and downstream contract IDs:
505+
506+
```bash
507+
CONTRACT_ID=<orchestrator-id> CONTRACT_ID_A=<contract-a-id> CONTRACT_ID_B=<contract-b-id> npm run run-example 83-multi-contract-transaction
508+
```
509+
510+
Soroban only allows a single host-function (contract invocation) operation per transaction, so "multiple contract invocations in one transaction" is achieved by invoking one orchestrator/router contract whose method internally makes cross-contract calls into other contracts, rather than by adding several top-level `contract.call(...)` operations. The example builds that single orchestrator invocation with two downstream contract IDs as arguments, simulates it to display the combined resource footprint and authorization entries spanning every contract touched, signs and submits it, and explains why a failure anywhere in the call chain — including a downstream cross-contract call — rolls back the entire transaction atomically, and why execution order follows the orchestrator's own code path rather than the order arguments are listed.
447511

448512
_Note: You can configure custom environment variables in a local `.env` file, including `HORIZON_URL`, `SOROBAN_RPC_URL`, `NETWORK_PASSPHRASE`, and `TRANSACTION_HASH`._
449513

Lines changed: 160 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,160 @@
1+
import { rpc, StrKey } from '@stellar/stellar-sdk';
2+
3+
import {
4+
compareScVal,
5+
decodePayload,
6+
decodeTopics,
7+
formatEventDecodingReport,
8+
} from '../utils/scval-decoder';
9+
10+
/**
11+
* Example 105: Soroban Contract Event Decoding
12+
*
13+
* Contract events carry topics (indexed ScVals) and a data payload (one ScVal).
14+
* This example retrieves events for a contract and decodes every topic and the
15+
* payload into human-readable values, showing raw base64 XDR alongside decoded
16+
* output for side-by-side comparison.
17+
*/
18+
19+
const DEFAULT_RPC_URL = 'https://soroban-testnet.stellar.org';
20+
const DEFAULT_CONTRACT_ID = 'CDLZFC3SYJYDZT7K67VZ75HPJVIEUVNIXF47ZG2FB2RMQQVU2HHGCYSC';
21+
const DEFAULT_LOOKBACK = 17280;
22+
const DEFAULT_LIMIT = 5;
23+
24+
export interface ContractEventDecodingParams {
25+
contractId?: string;
26+
startLedger?: number | string;
27+
limit?: number | string;
28+
rpcUrl?: string;
29+
}
30+
31+
export function normalizeContractId(value: string): string {
32+
const trimmed = value.trim();
33+
if (!trimmed) {
34+
throw new Error('Missing contract ID. Provide a contract ID starting with "C".');
35+
}
36+
if (!StrKey.isValidContract(trimmed)) {
37+
throw new Error(`Invalid contract ID "${trimmed}".`);
38+
}
39+
return trimmed;
40+
}
41+
42+
export function normalizeLimit(value?: number | string): number {
43+
const parsed = typeof value === 'string' ? parseInt(value.trim(), 10) : value;
44+
if (parsed === undefined || Number.isNaN(parsed)) return DEFAULT_LIMIT;
45+
return Math.min(Math.max(Math.trunc(parsed), 1), 50);
46+
}
47+
48+
export function parseLedgerInput(value?: number | string): number | undefined {
49+
if (value === undefined || value === null || value === '') return undefined;
50+
const parsed = typeof value === 'string' ? parseInt(value.trim(), 10) : value;
51+
if (Number.isNaN(parsed) || parsed < 1) return undefined;
52+
return Math.trunc(parsed);
53+
}
54+
55+
export async function run(params: ContractEventDecodingParams = {}): Promise<void> {
56+
const rpcUrl = params.rpcUrl || process.env.SOROBAN_RPC_URL || DEFAULT_RPC_URL;
57+
const contractInput =
58+
params.contractId?.trim() ||
59+
process.env.CONTRACT_ID?.trim() ||
60+
process.argv[3]?.trim() ||
61+
DEFAULT_CONTRACT_ID;
62+
const limit = normalizeLimit(params.limit ?? process.env.EVENT_LIMIT ?? process.argv[5]);
63+
const startInput = parseLedgerInput(
64+
params.startLedger ?? process.env.START_LEDGER ?? process.argv[4],
65+
);
66+
67+
console.log('Soroban Contract Event Decoding Example');
68+
console.log(`Soroban RPC: ${rpcUrl}`);
69+
70+
let contractId: string;
71+
try {
72+
contractId = normalizeContractId(contractInput);
73+
} catch (err: any) {
74+
console.log(`\n${err?.message ?? err}`);
75+
return;
76+
}
77+
78+
const server = new rpc.Server(rpcUrl);
79+
80+
let latestLedger: number;
81+
try {
82+
latestLedger = (await server.getLatestLedger()).sequence;
83+
console.log(`Latest ledger: ${latestLedger}`);
84+
} catch (err: any) {
85+
console.log(`Could not reach Soroban RPC: ${err?.message ?? err}`);
86+
return;
87+
}
88+
89+
const startLedger = startInput ?? Math.max(1, latestLedger - DEFAULT_LOOKBACK);
90+
console.log(`Contract: ${contractId}`);
91+
console.log(`Ledger range: ${startLedger} -> latest (limit ${limit})`);
92+
93+
let response: rpc.Api.GetEventsResponse;
94+
try {
95+
response = await server.getEvents({
96+
startLedger,
97+
filters: [{ type: 'contract', contractIds: [contractId] }],
98+
limit,
99+
});
100+
} catch (err: any) {
101+
console.log(`\nCould not retrieve events: ${err?.message ?? err}`);
102+
return;
103+
}
104+
105+
const events = response.events ?? [];
106+
if (events.length === 0) {
107+
console.log('\nNo contract events found in the queried ledger range.');
108+
console.log('This is a valid empty result — try a more recent start ledger or another contract.');
109+
return;
110+
}
111+
112+
console.log(`\nRetrieved ${events.length} event(s). Decoding topics and payloads...\n`);
113+
114+
events.forEach((event, index) => {
115+
console.log(formatEventDecodingReport({
116+
contractId,
117+
ledger: event.ledger ?? 0,
118+
txHash: event.txHash ?? '',
119+
topics: event.topic ?? [],
120+
value: event.value,
121+
}));
122+
123+
const topicComparisons = (event.topic ?? []).map(compareScVal);
124+
const payloadComparison = compareScVal(event.value);
125+
126+
console.log('\nSide-by-side summary:');
127+
topicComparisons.forEach((row, topicIndex) => {
128+
console.log(
129+
` topic[${topicIndex}] ${row.xdrType.padEnd(12)} raw=${row.rawXdr.slice(0, 24)}… decoded=${row.decodedDisplay}`,
130+
);
131+
});
132+
console.log(
133+
` payload ${payloadComparison.xdrType.padEnd(12)} raw=${payloadComparison.rawXdr.slice(0, 24)}… decoded=${payloadComparison.decodedDisplay}`,
134+
);
135+
136+
const decodedTopics = decodeTopics(event.topic);
137+
const decodedPayload = decodePayload(event.value);
138+
const unsupported = [...decodedTopics, decodedPayload].filter((item) => !item.decoded);
139+
if (unsupported.length > 0) {
140+
console.log('\nUnsupported or undecodable values:');
141+
unsupported.forEach((item) => {
142+
console.log(` - ${item.xdrType}: ${item.error ?? 'decode failed'}`);
143+
});
144+
}
145+
146+
if (index < events.length - 1) {
147+
console.log('\n' + '-'.repeat(72));
148+
}
149+
});
150+
151+
console.log('\nDecoding reference:');
152+
console.log(' Address -> G… or C… strkey');
153+
console.log(' Symbol -> short identifier string');
154+
console.log(' String -> UTF-8 text');
155+
console.log(' Bool -> true / false');
156+
console.log(' Integer -> string (preserves i128/u256 precision)');
157+
console.log(' Bytes -> 0x-prefixed hex');
158+
console.log(' Vec/Map -> JSON array or object');
159+
console.log('\nContract event decoding example completed.');
160+
}
Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
1+
import { Keypair } from '@stellar/stellar-sdk';
2+
import chalk from 'chalk';
3+
4+
import {
5+
describeUnsupportedJsType,
6+
encodeNested,
7+
roundTrip,
8+
trySerialize,
9+
} from '../utils/scval-utils';
10+
11+
/**
12+
* Example 106: ScVal Serialization and Deserialization
13+
*
14+
* Soroban contracts exchange data as XDR-encoded ScVal values. This example
15+
* demonstrates converting common JavaScript values to ScVal and back using
16+
* reusable helpers, displaying raw base64 XDR, and comparing originals with
17+
* decoded round-trip results.
18+
*/
19+
20+
interface DemoCase {
21+
label: string;
22+
value: unknown;
23+
hint: { type: string; element?: { type: string }; key?: { type: string }; value?: { type: string } };
24+
}
25+
26+
const DEMO_CASES: DemoCase[] = [
27+
{ label: 'Boolean', value: true, hint: { type: 'bool' } },
28+
{ label: 'Integer (u32)', value: 42, hint: { type: 'u32' } },
29+
{ label: 'BigInt (i128)', value: 10_000_000_000n, hint: { type: 'i128' } },
30+
{ label: 'String', value: 'hello Soroban', hint: { type: 'string' } },
31+
{ label: 'Symbol', value: 'transfer', hint: { type: 'symbol' } },
32+
{ label: 'Bytes', value: Buffer.from('cafebabe', 'hex'), hint: { type: 'bytes' } },
33+
{
34+
label: 'Address',
35+
value: Keypair.random().publicKey(),
36+
hint: { type: 'address' },
37+
},
38+
{
39+
label: 'Vector<u32>',
40+
value: [1, 2, 3, 5, 8],
41+
hint: { type: 'vec', element: { type: 'u32' } },
42+
},
43+
{
44+
label: 'Map<symbol,u32>',
45+
value: [
46+
['alice', 100],
47+
['bob', 250],
48+
],
49+
hint: { type: 'map', key: { type: 'symbol' }, value: { type: 'u32' } },
50+
},
51+
];
52+
53+
function printRoundTrip(result: ReturnType<typeof roundTrip>): void {
54+
console.log(` original : ${JSON.stringify(result.original)}`);
55+
console.log(` xdr type : ${chalk.cyan(result.encoded.xdrType)}`);
56+
console.log(` raw XDR : ${result.encoded.rawXdr}`);
57+
console.log(` decoded : ${JSON.stringify(result.decoded)}`);
58+
console.log(
59+
result.matches
60+
? chalk.green(' match : yes')
61+
: chalk.yellow(' match : no (see serialization pitfalls below)'),
62+
);
63+
}
64+
65+
export function explainSerializationPitfalls(): string {
66+
return [
67+
'Serialization pitfalls:',
68+
' - JavaScript numbers above 2^53-1 must use BigInt for u64/i128/u256 types.',
69+
' - Symbol (scvSymbol) is not the same as String (scvString); contracts validate strictly.',
70+
' - Soroban maps decode to [key, value][] arrays, not plain objects, unless reshaped.',
71+
' - Option<T> uses scvVoid for None; undefined is not a valid ScVal input.',
72+
' - Passing the wrong hint throws before any RPC call — validate locally first.',
73+
].join('\n');
74+
}
75+
76+
export async function run(): Promise<void> {
77+
console.log(chalk.bold('ScVal Serialization and Deserialization Example'));
78+
console.log(chalk.gray('Offline round-trip encoding using reusable scval-utils helpers.\n'));
79+
80+
console.log(chalk.bold('Primitive and collection types'));
81+
for (const demo of DEMO_CASES) {
82+
console.log(chalk.yellow(`\n${demo.label}`));
83+
printRoundTrip(roundTrip(demo.value, demo.hint));
84+
}
85+
86+
console.log(chalk.bold('\nNested object (manual scvMap construction)'));
87+
const nested = {
88+
active: true,
89+
count: 3,
90+
label: 'nested',
91+
scores: [10, 20, 30],
92+
meta: { version: 2, owner: 'alice' },
93+
};
94+
const nestedScVal = encodeNested(nested);
95+
console.log(` original : ${JSON.stringify(nested)}`);
96+
console.log(` xdr type : ${chalk.cyan(nestedScVal.switch().name)}`);
97+
console.log(` raw XDR : ${nestedScVal.toXDR('base64')}`);
98+
99+
console.log(chalk.bold('\nUnsupported JavaScript types'));
100+
for (const bad of [undefined, () => 'noop', new Date()]) {
101+
const attempt = trySerialize(bad, { type: 'u32' });
102+
if (!attempt.ok) {
103+
console.log(chalk.red(` ✗ ${describeUnsupportedJsType(bad)}`));
104+
console.log(chalk.gray(` encoder error: ${attempt.error}`));
105+
}
106+
}
107+
108+
console.log('\n' + explainSerializationPitfalls());
109+
console.log(chalk.green('\nScVal serialization example completed.'));
110+
}

0 commit comments

Comments
 (0)