From 906bc37139de4542ec8b182daff3349674842ff8 Mon Sep 17 00:00:00 2001 From: Mathew2k-hash Date: Tue, 25 Aug 2026 22:40:43 +0000 Subject: [PATCH] docs: add multisig authority rotation guide for Stellar - New guides/stellar/multisig-authority-rotation.mdx covering: - Zero-downtime staged add-then-remove rotation - Emergency rotation on suspected key compromise (fast-revoke and break-glass paths) - Auditor trail: block-by-block annotation of rotation in announcement stream - End-to-end scripted 2-of-3 to fresh 2-of-3 rotation on futurenet - Cross-link added to Recovery Scenarios section in stellar-multisig-withdrawal.mdx - Nav entry added to docs.json Operations group --- docs.json | 1 + guides/stellar-multisig-withdrawal.mdx | 6 + .../stellar/multisig-authority-rotation.mdx | 709 ++++++++++++++++++ 3 files changed, 716 insertions(+) create mode 100644 guides/stellar/multisig-authority-rotation.mdx diff --git a/docs.json b/docs.json index 268920e..11b844b 100644 --- a/docs.json +++ b/docs.json @@ -139,6 +139,7 @@ "guides/stellar-mainnet-deployment", "guides/stellar-payment-links", "guides/stellar-multisig-withdrawal", + "guides/stellar/multisig-authority-rotation", "guides/stellar-offline-signing", "guides/stellar-tx-simulation", "guides/stellar-explorer-recipes", diff --git a/guides/stellar-multisig-withdrawal.mdx b/guides/stellar-multisig-withdrawal.mdx index 0929adf..9e9355e 100644 --- a/guides/stellar-multisig-withdrawal.mdx +++ b/guides/stellar-multisig-withdrawal.mdx @@ -265,6 +265,12 @@ console.log("Funds detected at:", matched.map(m => m.stealthAddress)); ## Recovery Scenarios + + These scenarios cover emergency recovery when a key is already lost. For the **operational + runbook** — zero-downtime staged rotation, emergency revocation on suspected compromise, and the + full auditor trail — see [Multisig Authority Rotation](/guides/stellar/multisig-authority-rotation). + + ### Lost signer — threshold still reachable If you lose one signer but remaining signers still meet the medium threshold, remove the lost signer immediately. diff --git a/guides/stellar/multisig-authority-rotation.mdx b/guides/stellar/multisig-authority-rotation.mdx new file mode 100644 index 0000000..cba12e1 --- /dev/null +++ b/guides/stellar/multisig-authority-rotation.mdx @@ -0,0 +1,709 @@ +--- +title: "Multisig Authority Rotation on Stellar" +description: "Zero-downtime staged rotation, emergency key replacement on suspected compromise, and the full auditor trail for a 2-of-3 to fresh 2-of-3 rotation on futurenet." +keywords: "Stellar, soroban, multisig, key rotation, signer rotation, emergency, auditor trail, zero-downtime, authority, XLM" +--- + +The [Multisig Stealth Withdrawals](/guides/stellar-multisig-withdrawal) guide covers the normal withdrawal flow and three recovery scenarios: losing a signer when the threshold is still reachable, losing a signer when the threshold is no longer reachable, and changing a threshold value. + +This guide picks up where those recovery scenarios leave off and addresses the **operational** side of signer management: + +- **Staged rotation** — add the new signer before removing the old one so that the account is never authorization-locked mid-rotation (zero-downtime). +- **Emergency rotation** — fastest safe path when a key is believed to be compromised. +- **Auditor trail** — what a rotation looks like in the Wraith announcement stream, block-by-block, so compliance tooling can reconstruct the signer set at any past ledger. +- **End-to-end scripted example** — rotating a 2-of-3 custodial account to a fresh 2-of-3 on futurenet with annotated output. + + + All account IDs and secret keys in code examples are illustrative. Replace them with your actual + futurenet values before running. Never use futurenet keys on mainnet. + + +--- + +## How Stellar Signer Changes Work + +Every `SetOptions` operation that adds, modifies, or removes a signer, or changes a threshold, requires meeting the **high threshold**. On a typical 2-of-3 account with weight 1 per signer and thresholds `low=1 / med=2 / high=3`, all three signers must participate in the rotation transaction. + +| Operation type | Threshold required | +|---|---| +| Add signer / change weight | High | +| Remove signer (weight 0) | High | +| Change low/med/high threshold | High | +| Payment, manage offer, most others | Medium | + + + Because signer changes require the high threshold, a rotation that removes a key before adding the + replacement can leave the account unable to authorize further rotations if the removal takes the + remaining set below the high threshold. Always add first, then remove. + + +--- + +## Staged Rotation — Zero Downtime + +The safe rotation order is: + +1. **Add** the replacement signer (meets high threshold with the existing set). +2. **Verify** the new signer can co-sign a test transaction. +3. **Remove** the retiring signer (meets high threshold with new set that includes the replacement). + +This keeps the account continuously authorized. The window between steps 1 and 3 is the overlap period — both the old and new signers are active. On a 2-of-3 account this means there are temporarily 4 signers and the account could be signed by any 3 of them. + +### Step 1 — Add the replacement signer + +```typescript +import { Keypair, Operation, TransactionBuilder, BASE_FEE } 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 addSigner(params: { + sourceAccountId: string; + newSignerPublicKey: string; + newSignerWeight: number; + // All signers whose combined weight meets the high threshold + authorizers: Keypair[]; +}) { + const { sourceAccountId, newSignerPublicKey, newSignerWeight, authorizers } = params; + const account = await server.getAccount(sourceAccountId); + + const tx = new TransactionBuilder(account, { + fee: BASE_FEE, + networkPassphrase: FUTURENET_PASSPHRASE, + }) + .addOperation( + Operation.setOptions({ + signer: { ed25519PublicKey: newSignerPublicKey, weight: newSignerWeight }, + }) + ) + .setTimeout(300) + .build(); + + for (const kp of authorizers) { + tx.sign(kp); + } + return server.sendTransaction(tx); +} +``` + +### Step 2 — Verify the new signer is live + +Before removing the old signer, confirm the new one appears in the account's signer list with the expected weight. + +```typescript +async function verifySignerWeight(accountId: string, signerPublicKey: string, expectedWeight: number) { + const account = await server.getAccount(accountId); + const entry = account.signers.find((s: { key: string; weight: number }) => s.key === signerPublicKey); + + if (!entry) { + throw new Error(`Signer ${signerPublicKey} not found on account ${accountId}`); + } + if (entry.weight !== expectedWeight) { + throw new Error( + `Expected weight ${expectedWeight} for ${signerPublicKey}, got ${entry.weight}` + ); + } + console.log(`Verified: ${signerPublicKey} active with weight ${entry.weight}`); +} +``` + +### Step 3 — Remove the retiring signer + +```typescript +async function removeSigner(params: { + sourceAccountId: string; + retiringSignerPublicKey: string; + // Must include the new signer to meet the high threshold + authorizers: Keypair[]; +}) { + const { sourceAccountId, retiringSignerPublicKey, authorizers } = params; + const account = await server.getAccount(sourceAccountId); + + const tx = new TransactionBuilder(account, { + fee: BASE_FEE, + networkPassphrase: FUTURENET_PASSPHRASE, + }) + .addOperation( + // weight: 0 permanently removes the signer + Operation.setOptions({ + signer: { ed25519PublicKey: retiringSignerPublicKey, weight: 0 }, + }) + ) + .setTimeout(300) + .build(); + + for (const kp of authorizers) { + tx.sign(kp); + } + return server.sendTransaction(tx); +} +``` + + + The remove transaction **must be signed by the new signer** (plus enough others to reach the high + threshold), because it executes after the add is confirmed. The retiring key's signature is + welcome but not required. + + +--- + +## Emergency Rotation on Suspected Compromise + +When a key is suspected to be compromised, speed matters more than elegance. The goal is to revoke the compromised key as fast as possible. + +### Decision tree + +``` +Is the remaining weight (without the compromised key) >= high threshold? +├── Yes → Immediately submit a single SetOptions tx removing the key. +│ Do NOT wait for the compromised keyholder to co-sign. +└── No → You need a break-glass signer (see below). + Submit add-break-glass + remove-compromised in one atomic tx. +``` + +### Fast revocation — remaining weight covers high threshold + +```typescript +async function emergencyRevoke(params: { + sourceAccountId: string; + compromisedSignerPublicKey: string; + // Remaining good signers with combined weight >= high threshold + goodSigners: Keypair[]; +}) { + const { sourceAccountId, compromisedSignerPublicKey, goodSigners } = params; + const account = await server.getAccount(sourceAccountId); + + const tx = new TransactionBuilder(account, { + fee: BASE_FEE, + networkPassphrase: FUTURENET_PASSPHRASE, + }) + .addOperation( + Operation.setOptions({ + signer: { ed25519PublicKey: compromisedSignerPublicKey, weight: 0 }, + }) + ) + .setTimeout(60) // Keep the TTL short — submit immediately + .build(); + + for (const kp of goodSigners) { + tx.sign(kp); + } + + const result = await server.sendTransaction(tx); + console.log("Emergency revocation submitted:", result.hash); + return result; +} +``` + +### Atomic add-then-remove — break-glass path + +If the remaining good signers cannot meet the high threshold on their own, use a pre-provisioned **break-glass keypair** with sufficient weight. Add the break-glass key and remove the compromised key in one atomic transaction. + +```typescript +async function emergencyRevokeWithBreakGlass(params: { + sourceAccountId: string; + compromisedSignerPublicKey: string; + breakGlassKeypair: Keypair; + breakGlassWeight: number; + // Good signers + break-glass must reach high threshold together + goodSigners: Keypair[]; +}) { + const { + sourceAccountId, + compromisedSignerPublicKey, + breakGlassKeypair, + breakGlassWeight, + goodSigners, + } = params; + const account = await server.getAccount(sourceAccountId); + + const tx = new TransactionBuilder(account, { + fee: BASE_FEE, + networkPassphrase: FUTURENET_PASSPHRASE, + }) + // Operation 1: add break-glass signer first + .addOperation( + Operation.setOptions({ + signer: { ed25519PublicKey: breakGlassKeypair.publicKey(), weight: breakGlassWeight }, + }) + ) + // Operation 2: revoke compromised key in same atomic transaction + .addOperation( + Operation.setOptions({ + signer: { ed25519PublicKey: compromisedSignerPublicKey, weight: 0 }, + }) + ) + .setTimeout(60) + .build(); + + for (const kp of [...goodSigners, breakGlassKeypair]) { + tx.sign(kp); + } + + const result = await server.sendTransaction(tx); + console.log("Break-glass emergency rotation submitted:", result.hash); + return result; +} +``` + + + After an emergency rotation, immediately rotate any funds that may have been visible to the + compromised key. Even though the signer is revoked on-chain, an adversary holding the private key + can still scan announcement events from before the revocation ledger. Move funds to fresh stealth + addresses announced to new ephemeral keys. + + +--- + +## Auditor Trail — Rotations in the Announcement Stream + +When a custodian rotates signers, it creates an on-chain record at two levels: + +1. **Stellar ledger** — each `SetOptions` operation is recorded in the account's transaction history with its ledger number, timestamp, and signer set delta. +2. **Wraith announcement stream** — each stealth payment announced after the rotation carries the new signer epoch in its metadata, allowing auditors to correlate which signer set authorized each withdrawal. + +### What a rotation looks like in the Wraith stream + +The Wraith SDK emits `WRAITH_ANNOUNCEMENT` events (Stellar topic) for every stealth payment send and scan. Rotation operations themselves are Stellar-native `SetOptions` transactions and are **not** Wraith announcements — but they are adjacent in ledger time and can be correlated by ledger number. + +A compliance auditor reconstructing signer authority at any past ledger can: + +1. Query the account's transaction history filtered to `SetOptions` operations. +2. For each ledger of interest, walk the signer-weight deltas forward from account creation to that ledger. +3. Cross-reference any Wraith announcement at ledger L against the signer set active at ledger L. + +```typescript +// Reconstruct the signer set at a target ledger using Horizon transaction history +async function getSignerSetAtLedger(params: { + accountId: string; + targetLedger: number; + horizonUrl: string; +}) { + const { accountId, targetLedger, horizonUrl } = params; + const url = `${horizonUrl}/accounts/${accountId}/transactions?order=asc&limit=200`; + const resp = await fetch(url); + const body = await resp.json() as { + _embedded: { records: Array<{ + ledger: number; + operations_count: number; + }> }; + }; + + // Collect SetOptions transactions at or before targetLedger + const rotationLedgers = body._embedded.records + .filter((tx) => tx.ledger <= targetLedger) + .filter((tx) => tx.operations_count > 0); + + // NOTE: Full signer reconstruction requires fetching each tx's operations + // and replaying SetOptions signer deltas. This snippet shows the query pattern. + console.log( + `Found ${rotationLedgers.length} transactions at or before ledger ${targetLedger}` + ); + return rotationLedgers; +} +``` + +### Block-by-block rotation trail — annotated + +The table below shows what an auditor sees during a staged 2-of-3 rotation. Ledger numbers are illustrative. + +| Ledger | Event type | Detail | Signer set after | +|---|---|---|---| +| 8 294 100 | `SetOptions` | Add `G...NEW_C` (weight 1) | A, B, C_old, C_new — 4 signers, any 3 valid | +| 8 294 101 | `WRAITH_ANNOUNCEMENT` | Stealth payment sent using new signer set | Attributed to ledger 8 294 101 signer set | +| 8 294 210 | `SetOptions` | Remove `G...C_old` (weight 0) | A, B, C_new — rotation complete | +| 8 294 300 | `WRAITH_ANNOUNCEMENT` | Withdrawal authorized by A + B + C_new | New signer set, clean attribution | + + + The overlap window (ledgers 8 294 100 to 8 294 210 in the example) is where both the old and new + signer could have co-signed transactions. Auditors should flag withdrawals in this window for + additional confirmation if the retiring signer is under investigation. + + +### Fetching Wraith announcements around a rotation + +```typescript +import { + fetchAnnouncements, + getDeployment, +} from "@wraith-protocol/sdk/chains/stellar"; +import { SorobanRpc } from "@stellar/stellar-sdk"; + +const server = new SorobanRpc.Server("https://rpc-futurenet.stellar.org"); + +async function getAnnouncementsAroundRotation(rotationLedger: number) { + const deployment = getDeployment("futurenet"); + const all = await fetchAnnouncements(deployment, server); + + // fetchAnnouncements returns canonical announcements ordered oldest-first. + // Filter to a ±50 ledger window around the rotation for audit review. + const window = all.filter((a) => { + const ledger = (a as unknown as { ledger?: number }).ledger; + if (typeof ledger !== "number") return false; + return ledger >= rotationLedger - 50 && ledger <= rotationLedger + 50; + }); + + console.log( + `Announcements within 50 ledgers of rotation at ${rotationLedger}:`, + window.length + ); + return window; +} +``` + +--- + +## End-to-End: Rotating a 2-of-3 to a Fresh 2-of-3 on Futurenet + +This complete example rotates all three signers of a 2-of-3 custodial account to three brand-new keypairs, one at a time using the staged approach. After each rotation, it verifies the signer set before proceeding to the next. + +### Prerequisites + +Fund the source account and all six keypairs on futurenet: + +```bash +# Replace G...SOURCE with your actual account ID +curl "https://friendbot-futurenet.stellar.org/?addr=G...SOURCE" +``` + +### 1. Initial setup — configure the 2-of-3 account + +```typescript +import { Keypair, Operation, TransactionBuilder, BASE_FEE, Asset } 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); + +// The account owner is sigA with master weight. +// We'll set thresholds and add sigB + sigC. +const sigA = Keypair.fromSecret("S...SECRET_A"); // existing account key +const sigB = Keypair.random(); // old signer B +const sigC = Keypair.random(); // old signer C + +// Fund sigB and sigC on futurenet before running this. +// curl "https://friendbot-futurenet.stellar.org/?addr=" + +const sourceAccount = await server.getAccount(sigA.publicKey()); + +const setupTx = new TransactionBuilder(sourceAccount, { + fee: BASE_FEE, + networkPassphrase: FUTURENET_PASSPHRASE, +}) + // Set thresholds: low=1, med=2, high=3 + // Each signer has weight 1, so 2-of-3 meets med; all-3 meets high + .addOperation( + Operation.setOptions({ lowThreshold: 1, medThreshold: 2, highThreshold: 3 }) + ) + // Add sigB with weight 1 + .addOperation( + Operation.setOptions({ signer: { ed25519PublicKey: sigB.publicKey(), weight: 1 } }) + ) + // Add sigC with weight 1 + .addOperation( + Operation.setOptions({ signer: { ed25519PublicKey: sigC.publicKey(), weight: 1 } }) + ) + .setTimeout(60) + .build(); + +setupTx.sign(sigA); +const setupResult = await server.sendTransaction(setupTx); +console.log("Initial 2-of-3 configured. Tx hash:", setupResult.hash); +// Signer set: A(weight 1), B(weight 1), C(weight 1) | med=2, high=3 +``` + +### 2. Generate fresh replacement keypairs + +```typescript +// Three brand-new keypairs — rotate to these +const newA = Keypair.random(); +const newB = Keypair.random(); +const newC = Keypair.random(); + +console.log("New signer A:", newA.publicKey()); +console.log("New signer B:", newB.publicKey()); +console.log("New signer C:", newC.publicKey()); + +// Store secrets securely (HSM, sealed enclave, etc.) +// For this futurenet demo only: console.log("newA secret:", newA.secret()); +``` + +### 3. Helper: wait for transaction confirmation + +```typescript +async function waitForConfirmation(hash: string, timeoutMs = 30_000): Promise { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + const result = await server.getTransaction(hash); + if (result.status === "SUCCESS") { + console.log(" Confirmed in ledger", (result as unknown as { ledger: number }).ledger); + return; + } + if (result.status === "FAILED") { + throw new Error(`Transaction ${hash} failed: ${JSON.stringify(result)}`); + } + await new Promise(r => setTimeout(r, 3_000)); + } + throw new Error(`Timed out waiting for ${hash}`); +} +``` + +### 4. Rotate signer A → newA (staged: add newA, verify, remove A) + +```typescript +// --- Phase 4a: Add newA --- +// sigA + sigB + sigC all sign (we have all three, so high threshold = 3 is met) +const accountForAddA = await server.getAccount(sigA.publicKey()); + +const addNewATx = new TransactionBuilder(accountForAddA, { + fee: BASE_FEE, + networkPassphrase: FUTURENET_PASSPHRASE, +}) + .addOperation( + Operation.setOptions({ signer: { ed25519PublicKey: newA.publicKey(), weight: 1 } }) + ) + .setTimeout(300) + .build(); + +addNewATx.sign(sigA); +addNewATx.sign(sigB); +addNewATx.sign(sigC); + +const addAResult = await server.sendTransaction(addNewATx); +console.log("Step 4a — Added newA. Hash:", addAResult.hash); +await waitForConfirmation(addAResult.hash); +// Signer set: A(1), B(1), C(1), newA(1) — 4 signers, any 3 valid for high threshold + +// --- Phase 4b: Verify newA is live --- +const accountAfterAddA = await server.getAccount(sigA.publicKey()); +const newAEntry = accountAfterAddA.signers.find((s: { key: string }) => s.key === newA.publicKey()); +if (!newAEntry) throw new Error("newA not found in signer list — aborting rotation"); +console.log("Step 4b — Verified newA weight:", (newAEntry as { key: string; weight: number }).weight); + +// --- Phase 4c: Remove old sigA --- +// Now authorize with newA + sigB + sigC (all weight 1 = sum 3 = high threshold) +const accountForRemoveA = await server.getAccount(sigA.publicKey()); + +const removeATx = new TransactionBuilder(accountForRemoveA, { + fee: BASE_FEE, + networkPassphrase: FUTURENET_PASSPHRASE, +}) + .addOperation( + // weight: 0 removes sigA permanently + Operation.setOptions({ signer: { ed25519PublicKey: sigA.publicKey(), weight: 0 } }) + ) + .setTimeout(300) + .build(); + +removeATx.sign(newA); +removeATx.sign(sigB); +removeATx.sign(sigC); + +const removeAResult = await server.sendTransaction(removeATx); +console.log("Step 4c — Removed old sigA. Hash:", removeAResult.hash); +await waitForConfirmation(removeAResult.hash); +// Signer set: B(1), C(1), newA(1) — clean 3 signers, any 2 valid for med, all 3 for high +``` + +### 5. Rotate signer B → newB + +```typescript +// --- Add newB (authorized by newA + B + C) --- +const accountForAddB = await server.getAccount(newA.publicKey()); + +const addNewBTx = new TransactionBuilder(accountForAddB, { + fee: BASE_FEE, + networkPassphrase: FUTURENET_PASSPHRASE, +}) + .addOperation( + Operation.setOptions({ signer: { ed25519PublicKey: newB.publicKey(), weight: 1 } }) + ) + .setTimeout(300) + .build(); + +addNewBTx.sign(newA); +addNewBTx.sign(sigB); +addNewBTx.sign(sigC); + +const addBResult = await server.sendTransaction(addNewBTx); +console.log("Step 5a — Added newB. Hash:", addBResult.hash); +await waitForConfirmation(addBResult.hash); +// Signer set: B(1), C(1), newA(1), newB(1) + +// Verify newB +const accountAfterAddB = await server.getAccount(newA.publicKey()); +const newBEntry = accountAfterAddB.signers.find((s: { key: string }) => s.key === newB.publicKey()); +if (!newBEntry) throw new Error("newB not found — aborting"); +console.log("Step 5b — Verified newB weight:", (newBEntry as { key: string; weight: number }).weight); + +// --- Remove old sigB (authorized by newA + newB + C) --- +const accountForRemoveB = await server.getAccount(newA.publicKey()); + +const removeBTx = new TransactionBuilder(accountForRemoveB, { + fee: BASE_FEE, + networkPassphrase: FUTURENET_PASSPHRASE, +}) + .addOperation( + Operation.setOptions({ signer: { ed25519PublicKey: sigB.publicKey(), weight: 0 } }) + ) + .setTimeout(300) + .build(); + +removeBTx.sign(newA); +removeBTx.sign(newB); +removeBTx.sign(sigC); + +const removeBResult = await server.sendTransaction(removeBTx); +console.log("Step 5c — Removed old sigB. Hash:", removeBResult.hash); +await waitForConfirmation(removeBResult.hash); +// Signer set: C(1), newA(1), newB(1) +``` + +### 6. Rotate signer C → newC + +```typescript +// --- Add newC (authorized by newA + newB + C) --- +const accountForAddC = await server.getAccount(newA.publicKey()); + +const addNewCTx = new TransactionBuilder(accountForAddC, { + fee: BASE_FEE, + networkPassphrase: FUTURENET_PASSPHRASE, +}) + .addOperation( + Operation.setOptions({ signer: { ed25519PublicKey: newC.publicKey(), weight: 1 } }) + ) + .setTimeout(300) + .build(); + +addNewCTx.sign(newA); +addNewCTx.sign(newB); +addNewCTx.sign(sigC); + +const addCResult = await server.sendTransaction(addNewCTx); +console.log("Step 6a — Added newC. Hash:", addCResult.hash); +await waitForConfirmation(addCResult.hash); +// Signer set: C(1), newA(1), newB(1), newC(1) + +// Verify newC +const accountAfterAddC = await server.getAccount(newA.publicKey()); +const newCEntry = accountAfterAddC.signers.find((s: { key: string }) => s.key === newC.publicKey()); +if (!newCEntry) throw new Error("newC not found — aborting"); +console.log("Step 6b — Verified newC weight:", (newCEntry as { key: string; weight: number }).weight); + +// --- Remove old sigC (authorized by newA + newB + newC) --- +const accountForRemoveC = await server.getAccount(newA.publicKey()); + +const removeCTx = new TransactionBuilder(accountForRemoveC, { + fee: BASE_FEE, + networkPassphrase: FUTURENET_PASSPHRASE, +}) + .addOperation( + Operation.setOptions({ signer: { ed25519PublicKey: sigC.publicKey(), weight: 0 } }) + ) + .setTimeout(300) + .build(); + +removeCTx.sign(newA); +removeCTx.sign(newB); +removeCTx.sign(newC); + +const removeCResult = await server.sendTransaction(removeCTx); +console.log("Step 6c — Removed old sigC. Hash:", removeCResult.hash); +await waitForConfirmation(removeCResult.hash); +// Signer set: newA(1), newB(1), newC(1) — rotation complete +``` + +### 7. Verify the final signer set + +```typescript +const finalAccount = await server.getAccount(newA.publicKey()); + +console.log("\nFinal signer set:"); +for (const s of finalAccount.signers as Array<{ key: string; weight: number }>) { + console.log(` ${s.key} weight=${s.weight}`); +} +console.log("Thresholds:", finalAccount.thresholds); + +// Expected output: +// weight=1 +// weight=1 +// weight=1 +// Thresholds: { low_threshold: 1, med_threshold: 2, high_threshold: 3 } + +const expectedKeys = new Set([newA.publicKey(), newB.publicKey(), newC.publicKey()]); +const actualKeys = new Set((finalAccount.signers as Array<{ key: string }>).map(s => s.key)); +const unexpectedKeys = [...actualKeys].filter(k => !expectedKeys.has(k)); +if (unexpectedKeys.length > 0) { + throw new Error(`Unexpected signers still present: ${unexpectedKeys.join(", ")}`); +} +console.log("\nRotation complete — all old signers removed, all new signers confirmed."); +``` + +### Expected output + +``` +Step 4a — Added newA. Hash: a1b2c3... + Confirmed in ledger 8294100 +Step 4b — Verified newA weight: 1 +Step 4c — Removed old sigA. Hash: d4e5f6... + Confirmed in ledger 8294210 +Step 5a — Added newB. Hash: 789abc... + Confirmed in ledger 8294320 +Step 5b — Verified newB weight: 1 +Step 5c — Removed old sigB. Hash: def012... + Confirmed in ledger 8294430 +Step 6a — Added newC. Hash: 345678... + Confirmed in ledger 8294540 +Step 6b — Verified newC weight: 1 +Step 6c — Removed old sigC. Hash: 9abcde... + Confirmed in ledger 8294650 + +Final signer set: + G...NEW_A weight=1 + G...NEW_B weight=1 + G...NEW_C weight=1 +Thresholds: { low_threshold: 1, med_threshold: 2, high_threshold: 3 } + +Rotation complete — all old signers removed, all new signers confirmed. +``` + +--- + +## Rotation Runbook Summary + +| Phase | Signers needed | Who signs | High-threshold met? | +|---|---|---|---| +| Add newA | old A + B + C | All three old signers | ✓ (3 × 1 = 3) | +| Remove old A | newA + B + C | 2 old + 1 new | ✓ (3 × 1 = 3) | +| Add newB | newA + B + C | 1 new + 2 old | ✓ | +| Remove old B | newA + newB + C | 2 new + 1 old | ✓ | +| Add newC | newA + newB + C | 2 new + 1 old | ✓ | +| Remove old C | newA + newB + newC | All three new | ✓ | + +Each rotation step is an independent on-chain transaction. There is no time pressure between steps — the overlap window is safe to leave open for hours or days if co-signers are in different time zones. + +--- + +## Operational Checklist + +Before starting any rotation: + +- [ ] All replacement keypairs generated and stored in HSM/enclave. +- [ ] Replacement public keys reviewed and confirmed by a second operator (anti-clipboard-hijack). +- [ ] Current high threshold verified — know exactly which co-signers you need. +- [ ] Test transaction signed by each replacement keypair on testnet before mainnet use. +- [ ] Horizon or Stellar Expert monitoring open so you can watch each `SetOptions` land. +- [ ] Post-rotation verification step scripted (check all expected keys present, no unexpected keys remain). + +--- + +## See Also + +- [Multisig Stealth Withdrawals](/guides/stellar-multisig-withdrawal) — the full withdrawal flow and recovery scenarios that precede this guide +- [Stellar Primitives](/sdk/chains/stellar) — `signWithScalar`, `deriveStealthKeys`, full SDK reference +- [Cross-Chain Announcement Format](/architecture/announcement-format) — `WRAITH_ANNOUNCEMENT` Stellar event schema +- [Privacy Best Practices](/guides/privacy-best-practices) — move funds to fresh stealth addresses after any key compromise +- [Stellar Mainnet Deployment](/guides/stellar-mainnet-deployment) — production RPC setup and monitoring