Skip to content

Commit 9d91d93

Browse files
authored
Merge branch 'main' into fix/109-add-soroban-transaction-time-bounds-example
2 parents aad6983 + 2c98a88 commit 9d91d93

20 files changed

Lines changed: 5060 additions & 0 deletions

README.md

Lines changed: 209 additions & 0 deletions
Large diffs are not rendered by default.
Lines changed: 367 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,367 @@
1+
import {
2+
Asset,
3+
Account,
4+
Contract,
5+
Keypair,
6+
Networks,
7+
rpc,
8+
scValToNative,
9+
TransactionBuilder,
10+
xdr,
11+
} from '@stellar/stellar-sdk';
12+
import chalk from 'chalk';
13+
14+
/**
15+
* Soroban Authorization Entry Inspection Example
16+
*
17+
* A `SorobanAuthorizationEntry` records that a specific account consented to a
18+
* specific contract call, with specific arguments, for a bounded period. Wallets,
19+
* account-abstraction contracts, and signing UIs must be able to *read* these
20+
* entries before asking a user to approve them — otherwise the user is signing
21+
* an opaque blob.
22+
*
23+
* This example is about **decoding** an authorization entry: taking the XDR and
24+
* answering "who is being asked to authorize what?". It deliberately does not
25+
* repeat the signing flow — see `70-soroban-authorization` for obtaining and
26+
* signing entries end to end, and `68-soroban-contract-simulation` for
27+
* simulation in general.
28+
*
29+
* An entry has two halves:
30+
*
31+
* credentials – *who* authorizes, and under what replay protection.
32+
* Either `sourceAccount` (the transaction submitter, implicit,
33+
* no nonce needed) or `address` (any account, carrying a nonce
34+
* and an expiration ledger).
35+
*
36+
* rootInvocation – *what* is authorized: a contract, a function, its arguments,
37+
* and a tree of sub-invocations the callee may make in turn.
38+
* The tree matters — authorizing a swap may implicitly
39+
* authorize the token transfers underneath it.
40+
*
41+
* This example demonstrates:
42+
* 1. Obtaining authorization entries from simulation
43+
* 2. Decoding credentials, distinguishing source-account from address credentials
44+
* 3. Walking the invocation tree, including nested sub-invocations
45+
* 4. Decoding invocation arguments into native values
46+
* 5. Reporting replay-protection fields (nonce, signature expiration ledger)
47+
* 6. Decoding a standalone entry supplied as base64 XDR, as a wallet would
48+
*/
49+
50+
export async function run(): Promise<void> {
51+
const rpcUrl = process.env.SOROBAN_RPC_URL || 'https://soroban-testnet.stellar.org';
52+
// Default to the native XLM Stellar Asset Contract: its address is derived
53+
// deterministically from the network passphrase and it is always deployed, so the
54+
// example runs out of the box instead of against a placeholder that does not exist.
55+
const contractId = process.env.CONTRACT_ID || Asset.native().contractId(Networks.TESTNET);
56+
// `decimals` is a read-only SAC method taking no arguments — a safe default probe.
57+
const contractMethod = process.env.CONTRACT_METHOD || 'decimals';
58+
59+
console.log(chalk.bold('Soroban Authorization Entry Inspection Example'));
60+
console.log(
61+
chalk.gray(
62+
'Decode a SorobanAuthorizationEntry to see who is authorizing which call, with what arguments.',
63+
),
64+
);
65+
console.log(chalk.blue(`\nConnecting to Soroban RPC: ${rpcUrl}`));
66+
67+
const server = new rpc.Server(rpcUrl);
68+
69+
// ──────────────────────────────────────────────────────────────────────────
70+
// Step 1: Confirm connectivity
71+
// ──────────────────────────────────────────────────────────────────────────
72+
console.log(chalk.yellow('\nStep 1: Confirming RPC connectivity...'));
73+
let latestLedger: number;
74+
try {
75+
const health = await server.getLatestLedger();
76+
latestLedger = health.sequence;
77+
console.log(chalk.green(`Connected. Latest ledger sequence: ${latestLedger}`));
78+
} catch (err: any) {
79+
console.error(chalk.red('Failed to reach Soroban RPC:'), err.message);
80+
return;
81+
}
82+
83+
// ──────────────────────────────────────────────────────────────────────────
84+
// Step 2: Decode a standalone entry, if one was supplied
85+
//
86+
// This is the wallet path: a dApp hands you base64 XDR and you must render it
87+
// for a human before collecting a signature. No network access is required to
88+
// decode — only to interpret expiration against the current ledger.
89+
// ──────────────────────────────────────────────────────────────────────────
90+
const suppliedXdr = process.env.AUTH_ENTRY_XDR;
91+
if (suppliedXdr) {
92+
console.log(chalk.yellow('\nStep 2: Decoding AUTH_ENTRY_XDR supplied by the caller...'));
93+
try {
94+
const entry = xdr.SorobanAuthorizationEntry.fromXDR(suppliedXdr, 'base64');
95+
describeAuthorizationEntry(entry, 0, latestLedger);
96+
} catch (err: any) {
97+
console.error(
98+
chalk.red(' Could not decode AUTH_ENTRY_XDR — is it a base64 SorobanAuthorizationEntry?'),
99+
);
100+
console.error(chalk.gray(` ${err.message}`));
101+
}
102+
} else {
103+
console.log(
104+
chalk.gray(
105+
'\nStep 2: Skipped — set AUTH_ENTRY_XDR=<base64> to decode an entry supplied by a dApp.',
106+
),
107+
);
108+
}
109+
110+
// ──────────────────────────────────────────────────────────────────────────
111+
// Step 3: Obtain entries from simulation
112+
//
113+
// Simulation reports which accounts the host will require authorization from.
114+
// The entries come back *unsigned*: credentials are present, signatures are not.
115+
// ──────────────────────────────────────────────────────────────────────────
116+
console.log(chalk.yellow('\nStep 3: Simulating an invocation to obtain its auth entries...'));
117+
console.log(chalk.gray(` Contract: ${contractId}`));
118+
console.log(chalk.gray(` Method: ${contractMethod}`));
119+
120+
// A throwaway keypair is fine: simulation never submits, so the account need
121+
// not exist or hold a balance. Sequence 0 is accepted for a dry run.
122+
const caller = Keypair.random();
123+
const sourceAccount = new Account(caller.publicKey(), '0');
124+
125+
let simulation: rpc.Api.SimulateTransactionResponse;
126+
try {
127+
const contract = new Contract(contractId);
128+
const tx = new TransactionBuilder(sourceAccount, {
129+
fee: '100',
130+
networkPassphrase: Networks.TESTNET,
131+
})
132+
.addOperation(contract.call(contractMethod))
133+
.setTimeout(30)
134+
.build();
135+
136+
simulation = await server.simulateTransaction(tx);
137+
} catch (err: any) {
138+
console.error(chalk.red(' Simulation request failed:'), err.message);
139+
console.log(
140+
chalk.gray(' Set CONTRACT_ID and CONTRACT_METHOD to a contract reachable on this network.'),
141+
);
142+
return;
143+
}
144+
145+
if (rpc.Api.isSimulationError(simulation)) {
146+
console.log(chalk.red(' Simulation returned an error:'));
147+
console.log(chalk.gray(` ${simulation.error}`));
148+
console.log(
149+
chalk.gray(
150+
' A contract that does not exist, or a method name that does not match, both land here.',
151+
),
152+
);
153+
return;
154+
}
155+
156+
const entries = rpc.Api.isSimulationSuccess(simulation) ? (simulation.result?.auth ?? []) : [];
157+
158+
if (entries.length === 0) {
159+
console.log(chalk.green(' Simulation succeeded and required no authorization entries.'));
160+
console.log(
161+
chalk.gray(
162+
' That is normal: a method that neither moves value nor calls `require_auth` needs no\n' +
163+
' explicit consent. Try a token transfer to see a populated entry.',
164+
),
165+
);
166+
} else {
167+
console.log(
168+
chalk.green(` Simulation returned ${entries.length} authorization entr${plural(entries)}.`),
169+
);
170+
entries.forEach((entry, index) => describeAuthorizationEntry(entry, index, latestLedger));
171+
}
172+
173+
// ──────────────────────────────────────────────────────────────────────────
174+
// Step 4: What to check before signing
175+
// ──────────────────────────────────────────────────────────────────────────
176+
console.log(chalk.yellow('\nStep 4: What a wallet should verify before signing'));
177+
console.log(
178+
chalk.gray(
179+
' - The invoked contract is the one the user believes they are dealing with.\n' +
180+
' - Every sub-invocation is expected. A single approval can authorize a whole tree.\n' +
181+
' - Arguments match what the UI displayed, especially amounts and destinations.\n' +
182+
' - signatureExpirationLedger is near, not years away — a distant expiry widens the\n' +
183+
' window in which a captured signature can be replayed.\n' +
184+
' - The nonce has not been seen before for this address.',
185+
),
186+
);
187+
188+
console.log(chalk.bold.green('\nAuthorization entry inspection complete.'));
189+
console.log(
190+
chalk.gray(
191+
'See 70-soroban-authorization for signing these entries, and 101-simulation-result-analysis\n' +
192+
'for the rest of what simulation reports.',
193+
),
194+
);
195+
}
196+
197+
/** `entr(y|ies)` without the awkward parenthetical. */
198+
function plural(items: unknown[]): string {
199+
return items.length === 1 ? 'y' : 'ies';
200+
}
201+
202+
/**
203+
* Print one authorization entry: who authorizes, and what they authorize.
204+
*/
205+
function describeAuthorizationEntry(
206+
entry: xdr.SorobanAuthorizationEntry,
207+
index: number,
208+
latestLedger: number,
209+
): void {
210+
console.log(chalk.bold(`\n Authorization entry #${index + 1}`));
211+
212+
describeCredentials(entry.credentials(), latestLedger);
213+
214+
console.log(chalk.cyan(' Invocation tree:'));
215+
describeInvocation(entry.rootInvocation(), 3);
216+
}
217+
218+
/**
219+
* Decode the credentials half — *who* is authorizing, and the replay protection
220+
* that binds their consent.
221+
*/
222+
function describeCredentials(credentials: xdr.SorobanCredentials, latestLedger: number): void {
223+
switch (credentials.switch()) {
224+
case xdr.SorobanCredentialsType.sorobanCredentialsSourceAccount(): {
225+
console.log(chalk.cyan(' Credentials: source account'));
226+
console.log(
227+
chalk.gray(
228+
' The transaction source implicitly authorizes this call. No nonce or\n' +
229+
' expiration is carried — the transaction sequence number already prevents replay,\n' +
230+
' and no separate signature is needed.',
231+
),
232+
);
233+
break;
234+
}
235+
236+
case xdr.SorobanCredentialsType.sorobanCredentialsAddress(): {
237+
const address = credentials.address();
238+
const expiration = address.signatureExpirationLedger();
239+
240+
console.log(chalk.cyan(' Credentials: address'));
241+
console.log(` Address : ${describeScAddress(address.address())}`);
242+
console.log(` Nonce : ${address.nonce().toString()}`);
243+
console.log(` Expires at : ledger ${expiration}`);
244+
245+
// Relate the expiry to now, so the reader can judge whether it is sane.
246+
if (latestLedger > 0) {
247+
const remaining = expiration - latestLedger;
248+
if (remaining <= 0) {
249+
console.log(
250+
chalk.red(
251+
` This entry expired ${Math.abs(remaining)} ledger(s) ago and will be rejected.`,
252+
),
253+
);
254+
} else {
255+
console.log(
256+
chalk.gray(
257+
` Valid for ~${remaining} more ledger(s) (roughly ${estimateMinutes(remaining)} minutes).`,
258+
),
259+
);
260+
}
261+
}
262+
263+
const signature = address.signature();
264+
const signed = signature.switch() !== xdr.ScValType.scvVoid();
265+
console.log(
266+
signed
267+
? chalk.green(' Signature : present (entry has been signed)')
268+
: chalk.gray(' Signature : absent (unsigned — as returned by simulation)'),
269+
);
270+
break;
271+
}
272+
273+
default:
274+
console.log(
275+
chalk.gray(` Credentials: unrecognised variant (${credentials.switch().name})`),
276+
);
277+
}
278+
}
279+
280+
/**
281+
* Walk the invocation tree.
282+
*
283+
* Sub-invocations are the part most easily overlooked: approving the root also
284+
* approves everything beneath it, so a signing UI must render the whole tree.
285+
*/
286+
function describeInvocation(invocation: xdr.SorobanAuthorizedInvocation, depth: number): void {
287+
const pad = ' '.repeat(depth);
288+
const fn = invocation.function();
289+
290+
switch (fn.switch()) {
291+
case xdr.SorobanAuthorizedFunctionType.sorobanAuthorizedFunctionTypeContractFn(): {
292+
const contractFn = fn.contractFn();
293+
console.log(`${pad}Contract : ${describeScAddress(contractFn.contractAddress())}`);
294+
console.log(`${pad}Function : ${contractFn.functionName().toString()}`);
295+
296+
const args = contractFn.args();
297+
if (args.length === 0) {
298+
console.log(`${pad}Args : (none)`);
299+
} else {
300+
console.log(`${pad}Args :`);
301+
args.forEach((arg, i) => {
302+
console.log(`${pad} [${i}] ${formatScVal(arg)}`);
303+
});
304+
}
305+
break;
306+
}
307+
308+
case xdr.SorobanAuthorizedFunctionType.sorobanAuthorizedFunctionTypeCreateContractHostFn(): {
309+
console.log(`${pad}Function : create contract (host function)`);
310+
console.log(
311+
chalk.gray(
312+
`${pad} Authorizes deploying a new contract, not calling an existing one.`,
313+
),
314+
);
315+
break;
316+
}
317+
318+
default:
319+
console.log(`${pad}Function : unrecognised variant (${fn.switch().name})`);
320+
}
321+
322+
const subInvocations = invocation.subInvocations();
323+
if (subInvocations.length > 0) {
324+
console.log(
325+
chalk.gray(
326+
`${pad}Sub-invocations (${subInvocations.length}) — also authorized by this entry:`,
327+
),
328+
);
329+
subInvocations.forEach((sub) => describeInvocation(sub, depth + 1));
330+
}
331+
}
332+
333+
/** Render an ScAddress as a readable account or contract identifier. */
334+
function describeScAddress(address: xdr.ScAddress): string {
335+
try {
336+
// scValToNative understands addresses once wrapped back into an ScVal.
337+
return String(scValToNative(xdr.ScVal.scvAddress(address)));
338+
} catch {
339+
return `(undecodable ${address.switch().name})`;
340+
}
341+
}
342+
343+
/**
344+
* Decode an argument for display, falling back to its XDR type rather than
345+
* throwing — an example that dies on one unusual argument is not much use.
346+
*/
347+
function formatScVal(value: xdr.ScVal): string {
348+
try {
349+
const native = scValToNative(value);
350+
if (typeof native === 'object' && native !== null) {
351+
return JSON.stringify(native, bigintReplacer);
352+
}
353+
return String(native);
354+
} catch {
355+
return chalk.gray(`(could not decode ${value.switch().name})`);
356+
}
357+
}
358+
359+
/** JSON.stringify cannot serialise bigint, which Soroban i128/u64 decode to. */
360+
function bigintReplacer(_key: string, value: unknown): unknown {
361+
return typeof value === 'bigint' ? value.toString() : value;
362+
}
363+
364+
/** Ledgers close roughly every 5 seconds on Stellar. */
365+
function estimateMinutes(ledgers: number): number {
366+
return Math.round((ledgers * 5) / 60);
367+
}

0 commit comments

Comments
 (0)