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