From c347bb15e1d0ef81fb7501288c88c64c40b59328 Mon Sep 17 00:00:00 2001 From: baedboibidex-cmyk <254755326+baedboibidex-cmyk@users.noreply.github.com> Date: Thu, 25 Jun 2026 08:42:05 +0000 Subject: [PATCH 1/2] docs: add Stellar offline transaction signing guide - Add guides/stellar-offline-signing.mdx covering threat model, three-step build-offline/sign-offline/submit-online workflow, SDK primitives usage, QR code and USB transfer patterns, hardware wallet integration with honest current limitations, and stealth-specific air-gap guidance for spending keys - Register page in docs.json under Guides > Operations - Add cross-link from sdk/chains/stellar.mdx See Also section - Add cross-link from guides/privacy-best-practices.mdx See Also section Closes #40 --- docs.json | 4 +- guides/privacy-best-practices.mdx | 1 + guides/stellar-offline-signing.mdx | 585 +++++++++++++++++++++++++++++ sdk/chains/stellar.mdx | 1 + 4 files changed, 590 insertions(+), 1 deletion(-) create mode 100644 guides/stellar-offline-signing.mdx diff --git a/docs.json b/docs.json index 008d9be..79b0236 100644 --- a/docs.json +++ b/docs.json @@ -105,7 +105,9 @@ { "group": "Operations", "pages": [ - "guides/stellar-mainnet-deployment" + "guides/stellar-mainnet-deployment", + "guides/stellar-multisig-withdrawal", + "guides/stellar-offline-signing" ] } ] diff --git a/guides/privacy-best-practices.mdx b/guides/privacy-best-practices.mdx index bde0089..a66cfef 100644 --- a/guides/privacy-best-practices.mdx +++ b/guides/privacy-best-practices.mdx @@ -170,3 +170,4 @@ Identical amounts create a fingerprint. Consider varying the amount. | Vary payment amounts slightly | Prevents amount-based fingerprinting | | Use different times of day | Avoids timezone-based profiling | | Consolidate stealth addresses periodically | Reduces on-chain footprint | +- [Stellar Offline Transaction Signing](/guides/stellar-offline-signing) — keep stealth spending keys fully air-gapped using the offline signing workflow diff --git a/guides/stellar-offline-signing.mdx b/guides/stellar-offline-signing.mdx new file mode 100644 index 0000000..6b247bf --- /dev/null +++ b/guides/stellar-offline-signing.mdx @@ -0,0 +1,585 @@ +--- +title: "Stellar Offline Transaction Signing" +description: "Build transactions online, sign them on an air-gapped machine, and submit them without the private key ever touching an internet-connected device" +--- + +Cold storage and air-gapped wallets keep private keys on a machine that never connects to the internet. Signing a Stellar transaction with such a setup requires moving data — not keys — between the online and offline environments. This guide walks through the full workflow, QR code transfer patterns, hardware wallet integration, and stealth-specific guidance. + + + All account IDs and secret keys in code examples are illustrative. Never use example keys in + production. + + +--- + +## Threat Model: When Offline Signing Actually Helps + +Offline signing protects against a specific class of threats. Understanding what it does and does not cover helps you decide when the operational overhead is justified. + +### Threats it mitigates + +| Threat | How offline signing helps | +|---|---| +| Malware on the online machine exfiltrating keys | Private key never exists on the online machine | +| Supply chain compromise of an npm package | Signing happens in an isolated environment with no network access | +| Remote code execution via a compromised RPC response | Offline machine has no RPC connection to exploit | +| Key extraction from memory on the submission machine | Key is not present at submission time | + +### Threats it does NOT mitigate + +| Threat | Why | +|---|---| +| Malware on the **offline** machine | If the air gap is broken at any point, keys are at risk | +| Social engineering into signing a malicious transaction | You must verify the transaction contents before signing | +| Side-channel attacks on the offline hardware | Requires additional hardware-level mitigations | +| Loss of the offline device | Requires a separate backup and recovery strategy | + +### When the overhead is worth it + +Use offline signing when: +- The account holds funds that justify the operational cost (large custody accounts, multisig coordinators) +- Regulatory or compliance requirements mandate cold storage +- You are managing stealth withdrawal keys for an organization and want the derived scalar to never touch an online machine + +For small day-to-day amounts, the Wraith agent handles signing automatically and the overhead is not justified. + +--- + +## Three-Step Workflow + +``` +ONLINE MACHINE OFFLINE MACHINE +───────────────── ──────────────────── +1. Build transaction ──XDR──▶ 2. Inspect + sign + (no private key) ◀──XDR── 3. Return signed XDR +4. Submit to network +``` + +### Step 1 — Build the transaction (online machine) + +Load the source account to get the current sequence number, construct the transaction, and export it as XDR. No private key is needed at this stage. + +```typescript +import { + Keypair, + Operation, + TransactionBuilder, + Asset, + BASE_FEE, +} from "@stellar/stellar-sdk"; +import { SorobanRpc } from "@stellar/stellar-sdk"; + +const NETWORK_PASSPHRASE = "Test SDF Future Network ; October 2022"; // futurenet +const RPC_URL = "https://rpc-futurenet.stellar.org"; + +const server = new SorobanRpc.Server(RPC_URL); + +async function buildUnsignedTransaction(params: { + sourceAccountId: string; + destinationId: string; + amount: string; + asset: Asset; +}): Promise { + const { sourceAccountId, destinationId, amount, asset } = params; + + // Fetch sequence number — this is the only network call required + const sourceAccount = await server.getAccount(sourceAccountId); + + const tx = new TransactionBuilder(sourceAccount, { + fee: BASE_FEE, + networkPassphrase: NETWORK_PASSPHRASE, + }) + .addOperation( + Operation.payment({ + destination: destinationId, + asset, + amount, + }) + ) + .setTimeout(300) // 5 minutes — generous window for manual offline signing + .build(); + + // XDR is safe to transfer — it contains no secret information + return tx.toXDR(); +} + +const unsignedXdr = await buildUnsignedTransaction({ + sourceAccountId: "G...SOURCE", + destinationId: "G...DESTINATION", + amount: "10.0000000", + asset: Asset.native(), +}); + +console.log("Unsigned XDR (transfer to offline machine):"); +console.log(unsignedXdr); +``` + + + The XDR contains the transaction details but no keys. It is safe to move across the network, + display as a QR code, or write to a USB drive. + + +### Step 2 — Inspect and sign (offline machine) + +On the offline machine, decode the XDR to verify the transaction contents before signing. Never sign XDR you have not inspected. + +```typescript +import { Transaction, Networks } from "@stellar/stellar-sdk"; + +const NETWORK_PASSPHRASE = "Test SDF Future Network ; October 2022"; + +function inspectTransaction(xdr: string): void { + const tx = new Transaction(xdr, NETWORK_PASSPHRASE); + + console.log("=== TRANSACTION INSPECTION ==="); + console.log("Source account:", tx.source); + console.log("Sequence number:", tx.sequence); + console.log("Fee:", tx.fee, "stroops"); + console.log("Operations:"); + + for (const op of tx.operations) { + console.log(" -", op.type); + if (op.type === "payment") { + console.log(" Destination:", op.destination); + console.log(" Amount: ", op.amount); + console.log(" Asset: ", op.asset.isNative() ? "XLM" : op.asset.code); + } + } + console.log("=============================="); +} + +function signTransaction(xdr: string, signerSecret: string): string { + const keypair = Keypair.fromSecret(signerSecret); + const tx = new Transaction(xdr, NETWORK_PASSPHRASE); + + inspectTransaction(xdr); // Always inspect before signing + tx.sign(keypair); + + return tx.toXDR(); +} + +// On the offline machine: +inspectTransaction(unsignedXdr); + +// Verify the output looks correct, then sign +const signedXdr = signTransaction(unsignedXdr, "S...COLD_STORAGE_SECRET"); +console.log("Signed XDR (transfer back to online machine):"); +console.log(signedXdr); +``` + + + Always call `inspectTransaction` before signing. A malicious party could modify the XDR in + transit to change the destination or amount. Your eyes are the last line of defence. + + +### Step 3 — Submit (online machine) + +Take the signed XDR back to the online machine and submit it. The private key is not needed here. + +```typescript +import { Transaction } from "@stellar/stellar-sdk"; +import { SorobanRpc } from "@stellar/stellar-sdk"; + +async function submitSignedTransaction(signedXdr: string): Promise { + const server = new SorobanRpc.Server(RPC_URL); + const tx = new Transaction(signedXdr, NETWORK_PASSPHRASE); + + const result = await server.sendTransaction(tx); + + if (result.status === "ERROR") { + throw new Error(`Submission failed: ${JSON.stringify(result.errorResult)}`); + } + + console.log("Submitted:", result.hash); + + // Poll for confirmation + let status = await server.getTransaction(result.hash); + while (status.status === "NOT_FOUND") { + await new Promise(r => setTimeout(r, 3000)); + status = await server.getTransaction(result.hash); + } + + if (status.status === "SUCCESS") { + console.log("Confirmed in ledger", status.ledger); + } else { + console.error("Transaction failed. Result XDR:", status.resultXdr); + } +} + +await submitSignedTransaction(signedXdr); +``` + +--- + +## SDK Helpers + + + A dedicated `prepareOfflineStellarTransaction` helper is coming to + `@wraith-protocol/sdk/chains/stellar` in an upcoming release. It will wrap the sequence number + fetch and XDR serialization steps with a cleaner interface. The patterns below use current + primitives; the helper API will be a drop-in replacement. + + +Until the helper ships, the primitives you need are already available: + +```typescript +import { + deriveStealthKeys, + deriveStealthPrivateScalar, + signWithScalar, + STEALTH_SIGNING_MESSAGE, +} from "@wraith-protocol/sdk/chains/stellar"; + +import { + Keypair, + Transaction, + TransactionBuilder, + Operation, + Asset, + BASE_FEE, +} from "@stellar/stellar-sdk"; +import { SorobanRpc } from "@stellar/stellar-sdk"; +``` + +### Utility: encode XDR for offline transfer + +Base64 encodes are printable and compact. For QR codes, keep XDR under ~500 bytes (simple payments are well within this limit). + +```typescript +function xdrToBase64(xdr: string): string { + // XDR is already base64 in the Stellar SDK; this is an identity for clarity + return Buffer.from(xdr, "base64").toString("base64"); +} + +function base64ToXdr(b64: string): string { + return b64; // XDR strings from the SDK are already base64-encoded +} + +// Verify round-trip +const roundTripped = base64ToXdr(xdrToBase64(unsignedXdr)); +console.log("Round-trip valid:", roundTripped === unsignedXdr); +``` + +--- + +## QR Code Transfer Patterns + +QR codes are the most common way to move XDR between an online and an offline machine without a USB drive or network connection. A simple Stellar payment XDR is typically 200–400 bytes — comfortably within QR code capacity. + +### Generating a QR code (online machine) + +```typescript +// Install: npm install qrcode +import QRCode from "qrcode"; + +async function xdrToQR(xdr: string, outputPath: string): Promise { + // XDR strings from the Stellar SDK are already base64; use as-is + await QRCode.toFile(outputPath, xdr, { + errorCorrectionLevel: "M", // Medium — good balance of density and error recovery + type: "png", + width: 512, + }); + console.log("QR code saved to", outputPath); +} + +await xdrToQR(unsignedXdr, "unsigned-tx.png"); +``` + +Display `unsigned-tx.png` on the online machine's screen. The offline machine scans it with a phone or dedicated QR scanner. + +### Scanning a QR code (offline machine) + +If the offline machine has a camera, use any QR scanning library or CLI tool. The scanned string is the raw XDR — pass it directly to `signTransaction`. + +```bash +# Example using zbarcam (Linux) to scan and print QR content to stdout +zbarcam --raw --oneshot /dev/video0 +``` + +```typescript +// The scanned string is the XDR — sign it directly +const scannedXdr = ""; +const signedXdr = signTransaction(scannedXdr, "S...COLD_STORAGE_SECRET"); +``` + +### Returning the signed XDR + +After signing, generate a second QR code on the offline machine containing the signed XDR. Scan it on the online machine, then submit. + +```bash +# CLI approach on the offline machine (no npm required) +node -e " +const QRCode = require('qrcode'); +const signedXdr = process.argv[1]; +QRCode.toString(signedXdr, { type: 'terminal' }, (err, str) => { console.log(str); }); +" "" +``` + +### USB drive fallback + +For environments where cameras are not permitted: + +1. Write the unsigned XDR to a text file on a USB drive +2. Carry the drive to the offline machine, sign, write the signed XDR back +3. Carry the drive back to the online machine and submit + +```bash +# Write unsigned XDR to USB +echo "$UNSIGNED_XDR" > /media/usb/unsigned-tx.txt + +# On offline machine: read, sign, write back +SIGNED_XDR=$(node sign.js "$(cat /media/usb/unsigned-tx.txt)" "$COLD_SECRET") +echo "$SIGNED_XDR" > /media/usb/signed-tx.txt + +# On online machine: submit +node submit.js "$(cat /media/usb/signed-tx.txt)" +``` + + + USB drives can carry malware. Use a dedicated, write-protected drive for key transfers, and scan + it on the online machine before use. Never plug an untrusted USB drive into the offline machine. + + +--- + +## Hardware Wallet Integration + +Hardware wallets (Ledger, Trezor) store private keys in a secure element that never exposes the raw key. The device signs transaction bytes and returns only the signature. + +### Current limitations + + + Wraith stealth keys are **derived** keys — they use a domain-separated SHA-256 derivation from + your wallet signature, not the BIP-32 path that hardware wallets natively expose. As of today, + **Ledger and Trezor cannot directly sign with a Wraith-derived stealth scalar** because the + derivation happens outside the device's firmware. + + Hardware wallet support for stealth key derivation is planned. Until it ships, hardware wallets + can be used to authorize the **source account** of a transaction (e.g., a multisig coordinator + account), but the stealth address itself must be signed with a software-derived scalar kept in + cold storage separately. + + +### What hardware wallets CAN do today + +A Ledger or Trezor can authorize operations where your **regular Stellar keypair** is the signer — for example, as one of the signers in a multisig source account, or as the funding account for a stealth withdrawal. + +```typescript +// Using @ledgerhq/hw-transport-node-hid and @ledgerhq/hw-app-stellar +import Transport from "@ledgerhq/hw-transport-node-hid"; +import Stellar from "@ledgerhq/hw-app-stellar"; +import { Transaction, xdr, Keypair } from "@stellar/stellar-sdk"; + +async function signWithLedger(txXdr: string): Promise { + const transport = await Transport.create(); + const stellar = new Stellar(transport); + + // BIP-32 path for Stellar — account index 0 + const PATH = "44'/148'/0'"; + + // Get the public key from the Ledger to verify which account we're signing for + const { publicKey } = await stellar.getPublicKey(PATH, true); // true = display on device + console.log("Signing with Ledger public key:", publicKey); + + const tx = new Transaction(txXdr, NETWORK_PASSPHRASE); + + // The Ledger signs the transaction hash and returns the raw signature + const result = await stellar.signTransaction(PATH, tx.hash()); + + // Attach the signature to the transaction + const keypair = Keypair.fromPublicKey(publicKey); + tx.signatures.push( + xdr.DecoratedSignature.decDecoratedSignature({ + hint: keypair.signatureHint(), + signature: Buffer.from(result.signature, "hex"), + }) + ); + + await transport.close(); + return tx.toXDR(); +} + +// Build unsigned transaction on online machine, transfer XDR to Ledger-connected machine +const ledgerSignedXdr = await signWithLedger(unsignedXdr); +``` + +### Verifying the address on the Ledger screen + +Pass `true` as the second argument to `getPublicKey` — this displays the address on the Ledger screen so you can confirm it matches the source account before signing. **Always do this** when signing significant transactions. + +--- + +## Stealth Withdrawal Guidance + +Stealth withdrawals have a property that makes offline signing especially valuable: the **derived stealth private scalar** only needs to exist on the offline machine. It never needs to touch an internet-connected device. + +### Why stealth keys are well-suited for cold storage + +The stealth private scalar is derived deterministically from your master keypair signature: + +```typescript +import { deriveStealthKeys, STEALTH_SIGNING_MESSAGE } from "@wraith-protocol/sdk/chains/stellar"; +import { Keypair } from "@stellar/stellar-sdk"; + +// This derivation can happen entirely offline +const masterKeypair = Keypair.fromSecret("S...MASTER_SECRET"); // never goes online +const sig = masterKeypair.sign(Buffer.from(STEALTH_SIGNING_MESSAGE)); +const keys = deriveStealthKeys(sig); + +// keys.spendingScalar is the value needed to sign a stealth withdrawal +// It can be re-derived on demand — you don't need to store it +``` + +Because the scalar is **re-derivable**, you can: +- Keep only the master secret on the offline machine +- Re-derive the stealth scalar at signing time +- Never persist the scalar itself + +### Full offline stealth withdrawal workflow + +```typescript +// ── ONLINE MACHINE ────────────────────────────────────────────── +import { + scanAnnouncements, + fetchAnnouncements, + getDeployment, +} from "@wraith-protocol/sdk/chains/stellar"; +import { SorobanRpc } from "@stellar/stellar-sdk"; + +const server = new SorobanRpc.Server(RPC_URL); + +// 1. Scan for payments (online — uses the viewing key, not the spending key) +// The viewing key CAN go online — it only detects payments, not spends. +import { deriveStealthKeys, STEALTH_SIGNING_MESSAGE } from "@wraith-protocol/sdk/chains/stellar"; +import { Keypair } from "@stellar/stellar-sdk"; + +const viewingKeypair = Keypair.fromSecret("S...VIEWING_MASTER"); // lower sensitivity +const viewingSig = viewingKeypair.sign(Buffer.from(STEALTH_SIGNING_MESSAGE)); +const viewingKeys = deriveStealthKeys(viewingSig); + +const deployment = getDeployment("futurenet"); +const announcements = await fetchAnnouncements(deployment, server); + +// Pass null for spendingKey — we only need detection, not the private scalar +const matched = scanAnnouncements( + announcements, + viewingKeys.viewingKey, + viewingKeys.spendingPubKey, + null // spending key stays offline +); + +// 2. Build the withdrawal transaction (no spending key needed) +import { TransactionBuilder, Operation, Asset, BASE_FEE } from "@stellar/stellar-sdk"; + +const payment = matched[0]; // the payment you want to withdraw + +const feePayerAccount = await server.getAccount("G...FEE_PAYER"); +const withdrawalTx = new TransactionBuilder(feePayerAccount, { + fee: BASE_FEE, + networkPassphrase: NETWORK_PASSPHRASE, +}) + .addOperation( + Operation.payment({ + source: payment.stealthAddress, // stealth address as operation source + destination: "G...DESTINATION", + asset: Asset.native(), + amount: "4.5000000", + }) + ) + .setTimeout(300) + .build(); + +const unsignedXdr = withdrawalTx.toXDR(); + +// ── Transfer XDR to offline machine via QR or USB ─────────────── + +// ── OFFLINE MACHINE ───────────────────────────────────────────── +import { + deriveStealthKeys, + scanAnnouncements, + signWithScalar, + STEALTH_SIGNING_MESSAGE, +} from "@wraith-protocol/sdk/chains/stellar"; +import { Transaction, xdr, Keypair } from "@stellar/stellar-sdk"; + +// Re-derive the stealth scalar from the master spending key (never leaves offline machine) +const spendingMaster = Keypair.fromSecret("S...SPENDING_MASTER"); +const spendingSig = spendingMaster.sign(Buffer.from(STEALTH_SIGNING_MESSAGE)); +const spendingKeys = deriveStealthKeys(spendingSig); + +// Re-derive the stealth scalar for this specific payment +// (In practice, you scan again offline to get the matched entry with the scalar) +const tx = new Transaction(unsignedXdr, NETWORK_PASSPHRASE); + +// Inspect before signing +console.log("Source of payment op:", tx.operations[0].source); +console.log("Destination:", (tx.operations[0] as any).destination); +console.log("Amount:", (tx.operations[0] as any).amount); + +// Sign with the stealth private scalar +const { signature, hint } = signWithScalar( + payment.stealthPrivateScalar, // derived offline, never transmitted + payment.stealthPubKeyBytes, + tx.hash() +); + +tx.signatures.push( + xdr.DecoratedSignature.decDecoratedSignature({ hint, signature }) +); + +const signedXdr = tx.toXDR(); + +// ── Transfer signed XDR back to online machine ────────────────── + +// ── ONLINE MACHINE ────────────────────────────────────────────── +await submitSignedTransaction(signedXdr); +``` + +### Key separation: viewing key vs. spending key + +| Key | Sensitivity | Can go online? | Why | +|---|---|---|---| +| Viewing key | Medium | Yes (with care) | Only detects incoming payments; cannot spend | +| Spending key | High | No | Derives the scalar that authorizes spending | +| Stealth scalar | High | No | Direct spending authority for a specific stealth address | + +Keeping the viewing key on an online watch-only instance lets you monitor incoming payments continuously without exposing spending authority. + +--- + +## End-to-End Workflow Summary + +``` +ONLINE MACHINE OFFLINE MACHINE +────────────────────────────── ──────────────────────────── +1. Scan announcements (viewing key) +2. Identify payment to withdraw +3. Fetch source account sequence +4. Build unsigned transaction XDR +5. Export XDR (QR / USB) ──▶ 6. Scan / read XDR + 7. Inspect transaction details + 8. Re-derive stealth scalar + 9. Sign XDR with scalar + 10. Export signed XDR (QR / USB) +11. Scan / read signed XDR ◀── +12. Submit to Soroban RPC +13. Poll for confirmation +``` + +### Common errors + +| Error | Cause | Fix | +|---|---|---| +| `tx_bad_seq` | Sequence number stale (account used between build and submit) | Rebuild the transaction with the new sequence number | +| `tx_too_late` | `setTimeout` window expired before submission | Rebuild with a longer timeout; move faster or automate the transfer | +| `tx_bad_auth` | Wrong key signed, or stealth scalar mismatch | Verify the stealth address matches the scanned announcement | +| `op_src_not_exist` | Stealth address not found on-chain | Verify the announcement was processed and the address was funded | + +--- + +## See Also + +- [Stellar Primitives](/sdk/chains/stellar) — full SDK reference including `signWithScalar` and `deriveStealthPrivateScalar` +- [Privacy Best Practices](/guides/privacy-best-practices) — operational security for stealth withdrawals +- [Stellar Multisig Stealth Withdrawals](/guides/stellar-multisig-withdrawal) — combine offline signing with N-of-M multisig coordination +- [Stellar Mainnet Deployment](/guides/stellar-mainnet-deployment) — production RPC setup and monitoring \ No newline at end of file diff --git a/sdk/chains/stellar.mdx b/sdk/chains/stellar.mdx index 3a25af6..f8d5b95 100644 --- a/sdk/chains/stellar.mdx +++ b/sdk/chains/stellar.mdx @@ -423,3 +423,4 @@ This replaces the need to manually query `sorobanServer.getEvents()` and parse X > [!NOTE] > For advanced use cases and indexer building, refer to the [Stellar Event Schemas (v2)](/reference/stellar-event-schemas) documentation to learn how to natively filter topics via the RPC. +- [Stellar Offline Transaction Signing](/guides/stellar-offline-signing) — build online, sign air-gapped, submit without the private key touching the internet From d2f653329963aecdc14933875947a4ac6d066b36 Mon Sep 17 00:00:00 2001 From: baedboibidex-cmyk <254755326+baedboibidex-cmyk@users.noreply.github.com> Date: Thu, 25 Jun 2026 08:56:22 +0000 Subject: [PATCH 2/2] docs: add Stellar transaction simulation guide - Add guides/stellar-tx-simulation.mdx covering simulateTransaction usage, prepareTransaction SDK helper, raw RPC patterns, decoding return values and events, handling sim failures with actionable user feedback, and an end-to-end announcement simulation example - Register page in docs.json under Guides > Operations - Add cross-link from sdk/chains/stellar.mdx See Also section - Add cross-link from guides/privacy-best-practices.mdx See Also section Closes #39 --- docs.json | 3 +- guides/privacy-best-practices.mdx | 1 + guides/stellar-tx-simulation.mdx | 437 ++++++++++++++++++++++++++++++ sdk/chains/stellar.mdx | 1 + 4 files changed, 441 insertions(+), 1 deletion(-) create mode 100644 guides/stellar-tx-simulation.mdx diff --git a/docs.json b/docs.json index 79b0236..044c450 100644 --- a/docs.json +++ b/docs.json @@ -107,7 +107,8 @@ "pages": [ "guides/stellar-mainnet-deployment", "guides/stellar-multisig-withdrawal", - "guides/stellar-offline-signing" + "guides/stellar-offline-signing", + "guides/stellar-tx-simulation" ] } ] diff --git a/guides/privacy-best-practices.mdx b/guides/privacy-best-practices.mdx index a66cfef..6a73a69 100644 --- a/guides/privacy-best-practices.mdx +++ b/guides/privacy-best-practices.mdx @@ -171,3 +171,4 @@ Identical amounts create a fingerprint. Consider varying the amount. | Use different times of day | Avoids timezone-based profiling | | Consolidate stealth addresses periodically | Reduces on-chain footprint | - [Stellar Offline Transaction Signing](/guides/stellar-offline-signing) — keep stealth spending keys fully air-gapped using the offline signing workflow +- [Stellar Transaction Simulation](/guides/stellar-tx-simulation) — decode contract errors before fees are spent or signatures collected diff --git a/guides/stellar-tx-simulation.mdx b/guides/stellar-tx-simulation.mdx new file mode 100644 index 0000000..2ea6edf --- /dev/null +++ b/guides/stellar-tx-simulation.mdx @@ -0,0 +1,437 @@ +--- +title: "Stellar Transaction Simulation" +description: "Use Soroban simulateTransaction for safe pre-flight checks before signing and submitting stealth payment transactions" +--- + +`simulateTransaction` is a Soroban RPC method that runs a transaction against the current ledger state without submitting it. It returns resource estimates, output previews, and — critically — decoded contract errors before any fees are spent or signatures collected. For stealth payment flows, simulation should be run on every contract call before signing. + + + Simulation applies to **Soroban contract invocations** — contract calls such as `announce`, + `register`, and `send`. Plain Stellar operations (native `Payment`, `CreateAccount`) do not need + simulation, though the Soroban SDK's `prepareTransaction` helper runs it automatically for + contract calls. + + +--- + +## What Simulation Does + +| Output | What it tells you | +|---|---| +| **Footprint** (read/write ledger keys) | Which contract storage entries the transaction touches | +| **Resource estimates** | CPU instructions, memory bytes, ledger I/O — needed to set the `sorobanData` field | +| **Return value** | The contract's return value, XDR-encoded — decode it to preview the result | +| **Events** | Contract events that would be emitted — useful for verifying announcement data | +| **Error** | If the contract would revert, the full error including contract-defined error codes | +| **Fee estimate** | Minimum fee to include for the transaction to succeed | + +Without simulation, a contract call that would fail on-chain costs fees and consumes a sequence number. With simulation, you catch errors for free before signing. + +--- + +## When to Use Simulation + +| Scenario | Simulate? | +|---|---| +| Invoking any Soroban contract (`announce`, `register`, `send`) | **Always** | +| Plain `Payment` or `CreateAccount` operation | Not needed | +| Before collecting multisig signatures | **Yes** — fail fast before distributing XDR to signers | +| Before offline signing | **Yes** — simulate online, sign offline with confidence | +| After any contract upgrade or parameter change | **Yes** — footprints and fees may have changed | + +Simulation is especially important in stealth flows because the `stealth-announcer` and `stealth-sender` contracts involve persistent storage writes. A misconfigured call (wrong asset, insufficient balance, unregistered key) returns a contract error that simulation exposes immediately. + +--- + +## SDK Helpers + +The Soroban SDK's `SorobanRpc.Server` exposes `prepareTransaction`, which runs simulation internally and returns a transaction ready to sign. Use it as the standard path for all contract calls. + +### `prepareTransaction` — the standard path + +```typescript +import { + Contract, + TransactionBuilder, + BASE_FEE, + nativeToScVal, + Address, +} from "@stellar/stellar-sdk"; +import { SorobanRpc } from "@stellar/stellar-sdk"; + +const FUTURENET_RPC = "https://rpc-futurenet.stellar.org"; +const FUTURENET_PASSPHRASE = "Test SDF Future Network ; October 2022"; + +const server = new SorobanRpc.Server(FUTURENET_RPC); + +async function buildAndPrepareContractCall(params: { + callerAccountId: string; + contractId: string; + method: string; + args: ReturnType[]; +}) { + const { callerAccountId, contractId, method, args } = params; + + const account = await server.getAccount(callerAccountId); + const contract = new Contract(contractId); + + // Build the raw transaction (no resource data yet) + const rawTx = new TransactionBuilder(account, { + fee: BASE_FEE, + networkPassphrase: FUTURENET_PASSPHRASE, + }) + .addOperation(contract.call(method, ...args)) + .setTimeout(300) + .build(); + + // prepareTransaction runs simulateTransaction internally, + // attaches the footprint and resource estimates, and returns + // a transaction ready to sign. + const preparedTx = await server.prepareTransaction(rawTx); + + return preparedTx; +} +``` + +`prepareTransaction` throws a `SorobanRpc.SimulateTransactionError` if the simulation fails, with the raw simulation result attached. Catch it to decode the contract error: + +```typescript +import { SorobanRpc, xdr } from "@stellar/stellar-sdk"; + +try { + const preparedTx = await buildAndPrepareContractCall({ + callerAccountId: "G...CALLER", + contractId: "C...ANNOUNCER", + method: "announce", + args: [ + // stealth_address, ephemeral_pub_key, metadata + ], + }); + + // Sign and submit the prepared transaction + preparedTx.sign(callerKeypair); + const result = await server.sendTransaction(preparedTx); + console.log("Submitted:", result.hash); + +} catch (e) { + if (e instanceof SorobanRpc.SimulateTransactionError) { + console.error("Simulation failed:", e.message); + console.error("Raw simulation result:", JSON.stringify(e.result, null, 2)); + // Decode the contract error — see "Handling Sim Failures" below + } else { + throw e; + } +} +``` + +--- + +## Raw RPC Patterns + +When you need full control over the simulation result — for logging, fee inspection, or event preview — call `simulateTransaction` directly before preparing. + +### Direct simulation + +```typescript +import { SorobanRpc, Transaction } from "@stellar/stellar-sdk"; + +async function simulate(rawTxXdr: string): Promise { + const tx = new Transaction(rawTxXdr, FUTURENET_PASSPHRASE); + const result = await server.simulateTransaction(tx); + return result; +} + +const simResult = await simulate(rawTx.toXDR()); + +if (SorobanRpc.Api.isSimulationError(simResult)) { + console.error("Simulation error:", simResult.error); +} else if (SorobanRpc.Api.isSimulationRestore(simResult)) { + console.warn("State restore required before this transaction can succeed"); +} else { + console.log("Simulation success"); + console.log("Min resource fee:", simResult.minResourceFee, "stroops"); +} +``` + +### Applying simulation results manually + +If you need to apply simulation results to a transaction yourself (advanced use cases): + +```typescript +import { SorobanRpc, assembleTransaction } from "@stellar/stellar-sdk"; + +// assembleTransaction applies the footprint + resource estimates from the sim result +// This is what prepareTransaction does internally +const assembledTx = assembleTransaction(rawTx, simResult); +``` + +--- + +## Decoding Simulation Results + +### Return values + +The contract's return value is an XDR `ScVal`. Decode it with `scValToNative`: + +```typescript +import { scValToNative, SorobanRpc } from "@stellar/stellar-sdk"; + +if (!SorobanRpc.Api.isSimulationError(simResult) && simResult.result) { + const returnValue = scValToNative(simResult.result.retval); + console.log("Contract return value:", returnValue); +} +``` + +### Events + +Simulated events appear in `simResult.events`. Each is a base64-encoded XDR `DiagnosticEvent`: + +```typescript +import { xdr, scValToNative } from "@stellar/stellar-sdk"; + +if (!SorobanRpc.Api.isSimulationError(simResult)) { + for (const rawEvent of simResult.events ?? []) { + const event = xdr.DiagnosticEvent.fromXDR(rawEvent.event, "base64"); + + if (event.event().type().name !== "contract") continue; + + const contractEvent = event.event().body().v0(); + const topics = contractEvent.topics().map(t => scValToNative(t)); + const data = scValToNative(contractEvent.data()); + + console.log("Event topics:", topics); + console.log("Event data: ", data); + } +} +``` + +For the `stealth-announcer` contract, you'll see a topic array like: +``` +["announce", schemeId, stealthAddress, caller] +``` +and data containing the ephemeral public key and view tag metadata — exactly what the scanner expects. + +### Fee breakdown + +```typescript +if (!SorobanRpc.Api.isSimulationError(simResult)) { + console.log("Min resource fee:", simResult.minResourceFee, "stroops"); + + const resources = simResult.transactionData?.build().resources(); + if (resources) { + console.log("CPU instructions:", resources.instructions()); + console.log("Read bytes: ", resources.readBytes()); + console.log("Write bytes: ", resources.writeBytes()); + } +} +``` + +Use `minResourceFee` to set a realistic fee on the transaction. The `BASE_FEE` (100 stroops) only covers the base transaction fee; Soroban resource fees are additional. + +--- + +## Handling Simulation Failures + +Contract errors returned by simulation are far more actionable than opaque on-chain failure codes. Decode them to give users useful feedback. + +### Decoding a contract error + +```typescript +import { xdr, SorobanRpc, scValToNative } from "@stellar/stellar-sdk"; + +function decodeSimulationError(simResult: SorobanRpc.Api.SimulateTransactionResponse): string { + if (!SorobanRpc.Api.isSimulationError(simResult)) return "No error"; + + const errorStr = simResult.error; + + // Contract errors often contain an XDR-encoded ScError + try { + const scError = xdr.ScVal.fromXDR(errorStr, "base64"); + const decoded = scValToNative(scError); + return `Contract error: ${JSON.stringify(decoded)}`; + } catch { + // Not XDR — return the raw error string + return errorStr; + } +} +``` + +### Common simulation errors and fixes + +| Error | Cause | Fix | +|---|---|---| +| `HostError: Error(Contract, #1)` | Contract-defined error code 1 — check the contract's error enum | Read the contract's error documentation for code meanings | +| `HostError: Error(WasmVm, InvalidAction)` | Contract called with wrong argument types | Check `ScVal` types match the contract function signature | +| `HostError: Error(Storage, MissingValue)` | A required ledger entry doesn't exist | Ensure the account or contract data is initialized first | +| `isSimulationRestore` | An archived ledger entry must be restored before proceeding | Submit a `RestoreFootprint` transaction first | +| Insufficient balance | Native balance too low for the operation | Fund the account via Friendbot (testnet) or direct transfer | + +### User-facing error messages for stealth flows + +```typescript +import { SorobanRpc } from "@stellar/stellar-sdk"; + +function userFacingSimError(simResult: SorobanRpc.Api.SimulateTransactionResponse): string { + if (!SorobanRpc.Api.isSimulationError(simResult)) return ""; + + const raw = simResult.error.toLowerCase(); + + if (raw.includes("missingvalue")) { + return "The stealth address or registry entry was not found. Ensure the recipient is registered."; + } + if (raw.includes("insufficient") || raw.includes("underfunded")) { + return "Insufficient balance. Top up the account before sending."; + } + if (SorobanRpc.Api.isSimulationRestore(simResult)) { + return "A state restore is required. Please retry — the restore will be handled automatically."; + } + + return `Transaction pre-flight failed: ${simResult.error}`; +} +``` + +--- + +## End-to-End: Simulating a Stealth Announcement + +This shows the full simulation flow for a `stealth-announcer` contract call — the most common Soroban interaction in a stealth payment flow. + +```typescript +import { + Contract, + TransactionBuilder, + BASE_FEE, + Keypair, + nativeToScVal, + xdr, + scValToNative, + Address, +} from "@stellar/stellar-sdk"; +import { SorobanRpc } from "@stellar/stellar-sdk"; +import { + generateStealthAddress, + deriveStealthKeys, + getDeployment, + STEALTH_SIGNING_MESSAGE, +} from "@wraith-protocol/sdk/chains/stellar"; + +const FUTURENET_RPC = "https://rpc-futurenet.stellar.org"; +const FUTURENET_PASSPHRASE = "Test SDF Future Network ; October 2022"; +const server = new SorobanRpc.Server(FUTURENET_RPC); + +async function simulateAndSendAnnouncement(params: { + callerKeypair: Keypair; + recipientSpendingPubKey: Uint8Array; + recipientViewingPubKey: Uint8Array; +}): Promise { + const { callerKeypair, recipientSpendingPubKey, recipientViewingPubKey } = params; + + // 1. Generate a stealth address for the recipient + const { stealthAddress, ephemeralPubKey, viewTag } = generateStealthAddress( + recipientSpendingPubKey, + recipientViewingPubKey + ); + + // 2. Build the raw contract call + const deployment = getDeployment("futurenet"); + const contract = new Contract(deployment.contracts.announcer); + const account = await server.getAccount(callerKeypair.publicKey()); + + const metadata = Buffer.alloc(1); + metadata[0] = viewTag; + + const rawTx = new TransactionBuilder(account, { + fee: BASE_FEE, + networkPassphrase: FUTURENET_PASSPHRASE, + }) + .addOperation( + contract.call( + "announce", + nativeToScVal(1, { type: "u32" }), // scheme_id + new Address(stealthAddress).toScVal(), // stealth_address + nativeToScVal(ephemeralPubKey, { type: "bytes" }), // ephemeral_pub_key + nativeToScVal(metadata, { type: "bytes" }), // metadata (view tag) + ) + ) + .setTimeout(300) + .build(); + + // 3. Simulate first — inspect fees and events before signing + console.log("Running pre-flight simulation..."); + const simResult = await server.simulateTransaction(rawTx); + + if (SorobanRpc.Api.isSimulationError(simResult)) { + const msg = userFacingSimError(simResult); + throw new Error(`Pre-flight failed: ${msg}`); + } + + // 4. Log the fee estimate and preview events + console.log("Min resource fee:", simResult.minResourceFee, "stroops"); + console.log("Events that will be emitted:"); + + for (const rawEvent of simResult.events ?? []) { + const event = xdr.DiagnosticEvent.fromXDR(rawEvent.event, "base64"); + if (event.event().type().name !== "contract") continue; + const topics = event.event().body().v0().topics().map(t => scValToNative(t)); + console.log(" -", topics); + } + + // 5. Assemble (apply footprint + resources), sign, and submit + const preparedTx = await server.prepareTransaction(rawTx); + preparedTx.sign(callerKeypair); + + const submitResult = await server.sendTransaction(preparedTx); + if (submitResult.status === "ERROR") { + throw new Error(`Submission failed: ${JSON.stringify(submitResult.errorResult)}`); + } + + console.log("Submitted announcement:", submitResult.hash); + + // 6. Poll for confirmation + let status = await server.getTransaction(submitResult.hash); + while (status.status === "NOT_FOUND") { + await new Promise(r => setTimeout(r, 3000)); + status = await server.getTransaction(submitResult.hash); + } + + if (status.status === "SUCCESS") { + console.log("Announcement confirmed in ledger", status.ledger); + console.log("Stealth address:", stealthAddress); + } else { + console.error("Transaction failed. Result XDR:", status.resultXdr); + } +} + +// Run on futurenet +const callerKeypair = Keypair.random(); // funded via Friendbot +const recipientMaster = Keypair.random(); +const recipientSig = recipientMaster.sign(Buffer.from(STEALTH_SIGNING_MESSAGE)); +const { spendingPubKey, viewingPubKey } = deriveStealthKeys(recipientSig); + +await simulateAndSendAnnouncement({ + callerKeypair, + recipientSpendingPubKey: spendingPubKey, + recipientViewingPubKey: viewingPubKey, +}); +``` + +### Expected output + +``` +Running pre-flight simulation... +Min resource fee: 47312 stroops +Events that will be emitted: + - [ 'announce', 1, 'GXYZ...', 'GABC...' ] +Submitted announcement: a1b2c3... +Announcement confirmed in ledger 8294710 +Stealth address: GXYZ... +``` + +--- + +## See Also + +- [Stellar Primitives](/sdk/chains/stellar) — full SDK reference including `generateStealthAddress` and `scanAnnouncements` +- [Stellar Offline Transaction Signing](/guides/stellar-offline-signing) — simulate online, sign on an air-gapped machine +- [Stellar Multisig Stealth Withdrawals](/guides/stellar-multisig-withdrawal) — simulate before distributing XDR to N signers +- [Stellar Event Schemas](/reference/stellar-event-schemas) — announcer event topic format reference \ No newline at end of file diff --git a/sdk/chains/stellar.mdx b/sdk/chains/stellar.mdx index f8d5b95..b9f0baa 100644 --- a/sdk/chains/stellar.mdx +++ b/sdk/chains/stellar.mdx @@ -424,3 +424,4 @@ This replaces the need to manually query `sorobanServer.getEvents()` and parse X > [!NOTE] > For advanced use cases and indexer building, refer to the [Stellar Event Schemas (v2)](/reference/stellar-event-schemas) documentation to learn how to natively filter topics via the RPC. - [Stellar Offline Transaction Signing](/guides/stellar-offline-signing) — build online, sign air-gapped, submit without the private key touching the internet +- [Stellar Transaction Simulation](/guides/stellar-tx-simulation) — pre-flight every Soroban contract call with simulateTransaction before signing