diff --git a/docs.json b/docs.json index 02622bc..5f06d8c 100644 --- a/docs.json +++ b/docs.json @@ -118,6 +118,14 @@ "guides/single-chain-agent", "guides/multichain-agent", "guides/bring-your-own-model", + "guides/privacy-best-practices" + ] + }, + { + "group": "Operations", + "pages": [ + "guides/stellar-mainnet-deployment", + "guides/stellar-multisig-withdrawal" "guides/privacy-best-practices", "guides/spectre-stellar-cookbook", "guides/stellar-federation", diff --git a/guides/privacy-best-practices.mdx b/guides/privacy-best-practices.mdx index a66cfef..3fba0fa 100644 --- a/guides/privacy-best-practices.mdx +++ b/guides/privacy-best-practices.mdx @@ -170,4 +170,8 @@ 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 | + +## See Also + +- [Stellar Multisig Stealth Withdrawals](/guides/stellar-multisig-withdrawal) — additional coordination and recovery considerations for multisig source accounts - [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-multisig-withdrawal.mdx b/guides/stellar-multisig-withdrawal.mdx new file mode 100644 index 0000000..294bc94 --- /dev/null +++ b/guides/stellar-multisig-withdrawal.mdx @@ -0,0 +1,530 @@ +--- +title: "Stellar Multisig Stealth Withdrawals" +description: "Coordinate multiple signers to authorize stealth withdrawals from a multisig Stellar account" +--- + +Stealth withdrawals from a multisig source account require every participating signer to contribute a valid signature before the transaction can be submitted. This guide walks through Stellar's native multisig model, how it interacts with stealth address mechanics, and how to coordinate N-of-M signing using the Wraith SDK. + + + All contract addresses and account IDs in code examples are illustrative. Replace them with your + actual futurenet or mainnet values before running. + + +--- + +## Stellar Native Multisig Primer + +Every Stellar account has a set of **signers** and three **thresholds** — low, medium, and high. Each signer carries a **weight**, and an operation is authorized only when the combined weight of attached signatures meets or exceeds the threshold required for that operation type. + +### Thresholds and operation categories + +| Threshold | Default value | Operations that use it | +|---|---|---| +| Low | 1 | `AllowTrust`, `BumpSequence`, `ClaimClaimableBalance` (as claimant) | +| Medium | 2 | `Payment`, `ManageSellOffer`, `ManageBuyOffer`, `PathPayment`, most others | +| High | 3 | `AccountMerge`, `SetOptions` (threshold/signer changes) | + +A stealth withdrawal is a **Payment** operation — it uses the **medium threshold**. + +### Signer weights + +```typescript +import { Keypair, Operation, TransactionBuilder, BASE_FEE } from "@stellar/stellar-sdk"; +import { SorobanRpc } from "@stellar/stellar-sdk"; + +const server = new SorobanRpc.Server("https://rpc-futurenet.stellar.org"); + +const account = await server.getAccount("G...SOURCE_ACCOUNT"); +console.log(account.thresholds); +// { low_threshold: 1, med_threshold: 3, high_threshold: 5 } +console.log(account.signers); +// [ +// { key: "G...SIGNER_A", weight: 1 }, +// { key: "G...SIGNER_B", weight: 1 }, +// { key: "G...SIGNER_C", weight: 1 }, +// { key: "G...SIGNER_D", weight: 1 }, +// { key: "G...SIGNER_E", weight: 1 }, +// ] +``` + +### Key rules + +- The **sequence number** is owned by the source account — only one transaction per sequence number is accepted. Signers must coordinate on the same built transaction, not independently build their own. +- If a transaction is submitted with insufficient weight, Stellar returns `tx_bad_auth`. +- Adding or removing signers, or changing thresholds, requires meeting the **high threshold**. + +--- + +## Building a Multisig-Source-Account Stealth Withdrawal + +A stealth withdrawal sends funds from a stealth address to a destination. When the **source account** paying fees is a multisig account, the transaction must carry enough signatures from that account's signers in addition to the stealth signing key. + +### Roles + +| Role | Responsibility | +|---|---| +| **Coordinator** | Builds the transaction, collects signatures, submits | +| **Signers (N)** | Each signs the XDR and returns their signature | +| **Stealth key holder** | Signs with the derived scalar to authorize the payment from the stealth address | + +### Transaction structure + +```typescript +import { Keypair, Operation, TransactionBuilder, Asset, BASE_FEE } from "@stellar/stellar-sdk"; +import { SorobanRpc } from "@stellar/stellar-sdk"; +import { signWithScalar } 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 buildWithdrawalTransaction(params: { + sourceAccountId: string; + stealthAccountId: string; + destinationId: string; + amount: string; + asset: Asset; +}) { + const { sourceAccountId, stealthAccountId, destinationId, amount, asset } = params; + const sourceAccount = await server.getAccount(sourceAccountId); + + return new TransactionBuilder(sourceAccount, { + fee: BASE_FEE, + networkPassphrase: FUTURENET_PASSPHRASE, + }) + .addOperation( + Operation.payment({ + source: stealthAccountId, + destination: destinationId, + asset, + amount, + }) + ) + .setTimeout(300) + .build(); +} +``` + + + Build the transaction **once** and share the XDR. Never let each signer build independently — they + will produce different sequence numbers and the signatures will not be compatible. + + +--- + +## Coordinating N Signatures + +### Step 1 — Coordinator builds and distributes the XDR + +```typescript +const tx = await buildWithdrawalTransaction({ + sourceAccountId: "G...SOURCE", + stealthAccountId: "G...STEALTH", + destinationId: "G...DESTINATION", + amount: "10.0000000", + asset: Asset.native(), +}); + +const txXdr = tx.toXDR(); +console.log("XDR to share:", txXdr); +``` + +### Step 2 — Each signer signs the XDR and returns their signature + +```typescript +import { Transaction } from "@stellar/stellar-sdk"; + +function signerSign(xdr: string, signerSecret: string): string { + const keypair = Keypair.fromSecret(signerSecret); + const tx = new Transaction(xdr, FUTURENET_PASSPHRASE); + return keypair.sign(tx.hash()).toString("base64"); +} + +const sigA = signerSign(txXdr, "S...SECRET_A"); +const sigB = signerSign(txXdr, "S...SECRET_B"); +const sigC = signerSign(txXdr, "S...SECRET_C"); +``` + + + Signers only need to return their **signature** (base64), not the full signed XDR. This avoids + conflicts when each signer tries to attach their signature to the transaction object independently. + + +### Step 3 — Coordinator attaches all signatures + +```typescript +import { Transaction, xdr } from "@stellar/stellar-sdk"; + +function attachSignatures( + txXdr: string, + entries: Array<{ publicKey: string; signatureBase64: string }> +): Transaction { + const tx = new Transaction(txXdr, FUTURENET_PASSPHRASE); + for (const { publicKey, signatureBase64 } of entries) { + const keypair = Keypair.fromPublicKey(publicKey); + tx.signatures.push( + xdr.DecoratedSignature.decDecoratedSignature({ + hint: keypair.signatureHint(), + signature: Buffer.from(signatureBase64, "base64"), + }) + ); + } + return tx; +} + +const signedTx = attachSignatures(txXdr, [ + { publicKey: "G...SIGNER_A", signatureBase64: sigA }, + { publicKey: "G...SIGNER_B", signatureBase64: sigB }, + { publicKey: "G...SIGNER_C", signatureBase64: sigC }, +]); +``` + +### Step 4 — Stealth key holder signs the payment operation source + +```typescript +import { signWithScalar } from "@wraith-protocol/sdk/chains/stellar"; + +function attachStealthSignature( + tx: Transaction, + stealthPrivateScalar: bigint, + stealthPubKeyBytes: Uint8Array +): Transaction { + const hash = tx.hash(); + const { signature, hint } = signWithScalar(stealthPrivateScalar, stealthPubKeyBytes, hash); + tx.signatures.push( + xdr.DecoratedSignature.decDecoratedSignature({ hint, signature }) + ); + return tx; +} + +const fullySignedTx = attachStealthSignature( + signedTx, + matchedAnnouncement.stealthPrivateScalar, + matchedAnnouncement.stealthPubKeyBytes +); +``` + +### Step 5 — Submit + +```typescript +const result = await server.sendTransaction(fullySignedTx); + +let status = await server.getTransaction(result.hash); +while (status.status === "NOT_FOUND") { + await new Promise(r => setTimeout(r, 2000)); + status = await server.getTransaction(result.hash); +} + +if (status.status === "SUCCESS") { + console.log("Withdrawal confirmed in ledger", status.ledger); +} else { + console.error("Transaction failed:", status.resultXdr); +} +``` + +--- + +## Using the SDK Multisig Helpers + + + Dedicated multisig coordination helpers are coming to `@wraith-protocol/sdk/chains/stellar` in an + upcoming release. The patterns below use current primitives. The helper API will wrap these steps + with a cleaner interface. + + +```typescript +import { + deriveStealthKeys, + scanAnnouncements, + fetchAnnouncements, + getDeployment, + signWithScalar, + STEALTH_SIGNING_MESSAGE, +} from "@wraith-protocol/sdk/chains/stellar"; +import { Keypair } from "@stellar/stellar-sdk"; + +const masterKeypair = Keypair.fromSecret("S...YOUR_SECRET"); +const signature = masterKeypair.sign(Buffer.from(STEALTH_SIGNING_MESSAGE)); +const keys = deriveStealthKeys(signature); + +const deployment = getDeployment("futurenet"); +const announcements = await fetchAnnouncements(deployment, server); + +const matched = scanAnnouncements( + announcements, + keys.viewingKey, + keys.spendingPubKey, + keys.spendingKey +); + +console.log("Funds detected at:", matched.map(m => m.stealthAddress)); +``` + +--- + +## Recovery Scenarios + +### Lost signer — threshold still reachable + +If you lose one signer but remaining signers still meet the medium threshold, remove the lost signer immediately. + +```typescript +const removeLostSignerTx = new TransactionBuilder(sourceAccount, { + fee: BASE_FEE, + networkPassphrase: FUTURENET_PASSPHRASE, +}) + .addOperation( + Operation.setOptions({ + signer: { + ed25519PublicKey: "G...LOST_SIGNER", + weight: 0, + }, + }) + ) + .setTimeout(300) + .build(); +``` + + + Weight 0 permanently removes the signer. If remaining signers cannot meet the **high threshold**, + account governance is permanently locked. Plan your weight distribution to always allow recovery. + + +### Lost signer — threshold no longer reachable + +If remaining signers cannot meet the medium threshold, funds in stealth addresses cannot be withdrawn. Mitigations to put in place **before** this happens: + +1. **Break-glass signer** — a hardware key with weight equal to the medium threshold alone, kept offline. +2. **Claimable balances** — send funds from the stealth address to a claimable balance with a time-locked predicate before the signer set is at risk. +3. **Regular threshold review** — after any signer departure, immediately verify the remaining set still covers the medium threshold. + +### Changing the threshold + +```typescript +const changeThresholdTx = new TransactionBuilder(sourceAccount, { + fee: BASE_FEE, + networkPassphrase: FUTURENET_PASSPHRASE, +}) + .addOperation(Operation.setOptions({ medThreshold: 2 })) + .setTimeout(300) + .build(); +``` + +--- + +## Worked 3-of-5 Example End-to-End + +This example runs on **futurenet**. The source account has 5 signers each with weight 1 and medium threshold 3. Three signers coordinate to authorize the withdrawal. + +### Prerequisites + +```bash +for ADDR in G...A G...B G...C G...D G...E; do + curl "https://friendbot-futurenet.stellar.org/?addr=$ADDR" +done +``` + +### 1. Configure the multisig source account + +```typescript +import { Keypair, Operation, TransactionBuilder, BASE_FEE, Asset } from "@stellar/stellar-sdk"; +import { SorobanRpc } from "@stellar/stellar-sdk"; + +const FUTURENET_PASSPHRASE = "Test SDF Future Network ; October 2022"; +const server = new SorobanRpc.Server("https://rpc-futurenet.stellar.org"); + +const [sigA, sigB, sigC, sigD, sigE] = Array.from({ length: 5 }, () => Keypair.random()); +const sourceAccount = await server.getAccount(sigA.publicKey()); + +const setupTx = new TransactionBuilder(sourceAccount, { + fee: BASE_FEE, + networkPassphrase: FUTURENET_PASSPHRASE, +}) + .addOperation(Operation.setOptions({ lowThreshold: 1, medThreshold: 3, highThreshold: 5 })) + .addOperation(Operation.setOptions({ signer: { ed25519PublicKey: sigB.publicKey(), weight: 1 } })) + .addOperation(Operation.setOptions({ signer: { ed25519PublicKey: sigC.publicKey(), weight: 1 } })) + .addOperation(Operation.setOptions({ signer: { ed25519PublicKey: sigD.publicKey(), weight: 1 } })) + .addOperation(Operation.setOptions({ signer: { ed25519PublicKey: sigE.publicKey(), weight: 1 } })) + .setTimeout(60) + .build(); + +setupTx.sign(sigA); +const setupResult = await server.sendTransaction(setupTx); +console.log("Multisig account configured:", setupResult.hash); +``` + +### 2. Send a stealth payment to a recipient + +```typescript +import { + deriveStealthKeys, + generateStealthAddress, + STEALTH_SIGNING_MESSAGE, +} from "@wraith-protocol/sdk/chains/stellar"; + +const recipientKeypair = Keypair.random(); +const recipientSig = recipientKeypair.sign(Buffer.from(STEALTH_SIGNING_MESSAGE)); +const recipientKeys = deriveStealthKeys(recipientSig); + +const { stealthAddress } = generateStealthAddress( + recipientKeys.spendingPubKey, + recipientKeys.viewingPubKey +); +console.log("Stealth address:", stealthAddress); + +const senderKeypair = Keypair.random(); +const senderAccount = await server.getAccount(senderKeypair.publicKey()); + +const fundTx = new TransactionBuilder(senderAccount, { + fee: BASE_FEE, + networkPassphrase: FUTURENET_PASSPHRASE, +}) + .addOperation(Operation.createAccount({ destination: stealthAddress, startingBalance: "5" })) + .setTimeout(60) + .build(); + +fundTx.sign(senderKeypair); +await server.sendTransaction(fundTx); +console.log("Stealth address funded:", stealthAddress); +``` + +### 3. Recipient scans and detects the payment + +```typescript +import { scanAnnouncements, fetchAnnouncements, getDeployment } from "@wraith-protocol/sdk/chains/stellar"; + +const deployment = getDeployment("futurenet"); +const announcements = await fetchAnnouncements(deployment, server); +const matched = scanAnnouncements( + announcements, + recipientKeys.viewingKey, + recipientKeys.spendingPubKey, + recipientKeys.spendingKey +); + +const payment = matched.find(m => m.stealthAddress === stealthAddress); +if (!payment) throw new Error("Payment not found — check announcements or scan window"); +console.log("Detected payment at:", payment.stealthAddress); +``` + +### 4. Coordinator builds the withdrawal transaction + +```typescript +const multisigAccount = await server.getAccount(sigA.publicKey()); + +const withdrawalTx = new TransactionBuilder(multisigAccount, { + fee: BASE_FEE, + networkPassphrase: FUTURENET_PASSPHRASE, +}) + .addOperation( + Operation.payment({ + source: payment.stealthAddress, + destination: recipientKeypair.publicKey(), + asset: Asset.native(), + amount: "4.5000000", + }) + ) + .setTimeout(300) + .build(); + +const withdrawalXdr = withdrawalTx.toXDR(); +console.log("XDR ready — distributing to signers"); +``` + +### 5. Three signers sign and return signatures + +```typescript +import { Transaction } from "@stellar/stellar-sdk"; + +function collectSig(xdr: string, keypair: Keypair): string { + const tx = new Transaction(xdr, FUTURENET_PASSPHRASE); + return keypair.sign(tx.hash()).toString("base64"); +} + +const sigFromA = collectSig(withdrawalXdr, sigA); +const sigFromB = collectSig(withdrawalXdr, sigB); +const sigFromC = collectSig(withdrawalXdr, sigC); +// sigD and sigE not needed: 3 × weight 1 = 3 >= med_threshold 3 +``` + +### 6. Coordinator assembles all signatures and submits + +```typescript +import { Transaction, xdr } from "@stellar/stellar-sdk"; +import { signWithScalar } from "@wraith-protocol/sdk/chains/stellar"; + +const finalTx = new Transaction(withdrawalXdr, FUTURENET_PASSPHRASE); + +for (const { keypair, sig } of [ + { keypair: sigA, sig: sigFromA }, + { keypair: sigB, sig: sigFromB }, + { keypair: sigC, sig: sigFromC }, +]) { + finalTx.signatures.push( + xdr.DecoratedSignature.decDecoratedSignature({ + hint: keypair.signatureHint(), + signature: Buffer.from(sig, "base64"), + }) + ); +} + +const { signature: stealthSig, hint: stealthHint } = signWithScalar( + payment.stealthPrivateScalar, + payment.stealthPubKeyBytes, + finalTx.hash() +); +finalTx.signatures.push( + xdr.DecoratedSignature.decDecoratedSignature({ hint: stealthHint, signature: stealthSig }) +); + +const submitResult = await server.sendTransaction(finalTx); +if (submitResult.status === "ERROR") { + throw new Error(`Submission failed: ${JSON.stringify(submitResult.errorResult)}`); +} + +let confirmed = await server.getTransaction(submitResult.hash); +while (confirmed.status === "NOT_FOUND") { + await new Promise(r => setTimeout(r, 3000)); + confirmed = await server.getTransaction(submitResult.hash); +} + +if (confirmed.status === "SUCCESS") { + console.log("Withdrawal confirmed in ledger", confirmed.ledger); +} else { + // tx_bad_auth — insufficient signer weight; check threshold and attached signatures + // op_src_not_exist — stealth address not found; verify address derivation + // op_underfunded — stealth address has insufficient balance + console.error("Failed. Result XDR:", confirmed.resultXdr); +} +``` + +### Expected output + +``` +Multisig account configured: a1b2c3... +Stealth address funded: GXYZ... +Detected payment at: GXYZ... +XDR ready — distributing to signers +Withdrawal confirmed in ledger 8294710 +``` + +--- + +## Summary + +| Step | Who | What | +|---|---|---| +| Configure multisig account | Account owner | Set thresholds and add signers | +| Send stealth payment | Sender | Fund stealth address and announce | +| Scan for payment | Recipient | Run `scanAnnouncements`, get scalar | +| Build transaction XDR | Coordinator | One transaction, shared with all signers | +| Sign XDR | Each signer (N of M) | Return base64 signature | +| Assemble and sign stealth | Coordinator | Attach multisig sigs and stealth scalar sig | +| Submit | Coordinator | One submission to Soroban RPC | + +## 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 Mainnet Deployment](/guides/stellar-mainnet-deployment) — production RPC setup and monitoring +- [Stellar Event Schemas](/reference/stellar-event-schemas) — announcement format reference diff --git a/sdk/chains/stellar.mdx b/sdk/chains/stellar.mdx index 7bcffad..6c830db 100644 --- a/sdk/chains/stellar.mdx +++ b/sdk/chains/stellar.mdx @@ -424,6 +424,11 @@ 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. +--- + +## See Also + +- [Stellar Multisig Stealth Withdrawals](/guides/stellar-multisig-withdrawal) — coordinate N-of-M signers to authorize withdrawals from a multisig source account ## Federation Address Resolution The SDK includes a `resolveStellarFederation()` helper for resolving `alice*example.com` federation addresses to Stellar public keys before sending payments.