|
| 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