diff --git a/stellar/MULTISIG.md b/stellar/MULTISIG.md new file mode 100644 index 0000000..dd04ddc --- /dev/null +++ b/stellar/MULTISIG.md @@ -0,0 +1,255 @@ +# Stellar Multisig Setup + +Scripted, idempotent setup for the Wraith Protocol governance multisig on Stellar. +The `setup-multisig.sh` script configures signer weights and thresholds on a Stellar account in a single atomic transaction. + +## Recommended Starting Config (3-of-5) + +Per [GOVERNANCE.md](./GOVERNANCE.md), the admin account for `stealth-sender` and `wraith-names` should be a **3-of-5 Security Council multisig**. + +| Role | Named Guardian | Stellar Key | +|---|---|---| +| Guardian 1 | @truthixify | `G…` (populate before mainnet) | +| Guardian 2 | @thebabalola | `G…` | +| Guardian 3 | @bbkenny | `G…` | +| Guardian 4 | @richiey1 | `G…` | +| Guardian 5 | @drips-wave | `G…` | + +**Threshold**: 3 signatures required for all operations (low / med / high all set to 3). +**Master weight**: 0 (the base keypair is disabled; only the 5 named signers can act). + +### Initial Setup + +```bash +cd stellar/scripts + +./setup-multisig.sh \ + --network mainnet \ + --account \ + --signers "G_TRUTHIXIFY,G_THEBABALOLA,G_BBKENNY,G_RICHIEY1,G_DRIPS_WAVE" \ + --threshold 3 \ + --dry-run # review first + +# If the plan looks correct, remove --dry-run to submit +./setup-multisig.sh \ + --network mainnet \ + --account \ + --signers "G_TRUTHIXIFY,G_THEBABALOLA,G_BBKENNY,G_RICHIEY1,G_DRIPS_WAVE" \ + --threshold 3 \ + --identity deployer +``` + +The script will verify the on-chain account state after submission and append a full audit log to `multisig-setup.log`. + +--- + +## Recovery Procedure + +### Adding a Signer + +> Requires M-of-N current signers to co-sign (3 signatures for the 3-of-5 config). + +1. Collect the new signer's `G...` public key. +2. Each co-signer builds and signs the `set_options` transaction: + +```bash +stellar tx new set-options \ + --network mainnet \ + --source \ + --signer ":1" \ + --build-only \ + --xdr-out add-signer.xdr + +# Each co-signer signs: +stellar tx sign \ + --network mainnet \ + --sign-with-key \ + --xdr-in add-signer.xdr \ + --xdr-out add-signer.xdr +``` + +3. Once 3 signatures are collected, submit: + +```bash +stellar tx submit --network mainnet --xdr-in add-signer.xdr +``` + +4. After adding, if the new total signers changes the desired M-of-N ratio, also update thresholds (see below). + +### Removing a Signer + +1. Build the `set_options` transaction setting the departing signer's weight to 0: + +```bash +stellar tx new set-options \ + --network mainnet \ + --source \ + --signer ":0" \ + --build-only \ + --xdr-out remove-signer.xdr +``` + +2. Collect M co-signatures (same process as above). +3. Submit. **Important**: if removing a signer drops N below M, lower the threshold first in the same transaction, otherwise the account becomes permanently locked. + +### Updating Threshold Only + +```bash +stellar tx new set-options \ + --network mainnet \ + --source \ + --low-threshold \ + --med-threshold \ + --high-threshold \ + --build-only \ + --xdr-out update-threshold.xdr +``` + +Collect M signatures, then submit. + +### Emergency: Account Locked (threshold unreachable) + +If fewer than M signers are available and the account is locked: + +1. Contact remaining signers immediately — any M-of-N subset can still recover. +2. If keys are lost and recovery is impossible, the account is permanently locked. This is a **non-recoverable** state for the admin account itself, but deployed contracts remain operational (they only call `require_auth` on their stored admin address; the contracts themselves are unaffected). +3. To mitigate: keep an offline copy of all guardian keys in separate secure locations. + +--- + +## Audit Trail + +Every run of `setup-multisig.sh` appends a timestamped log to `multisig-setup.log` (or `--log-file` path): + +``` +[2026-06-26T11:53:00Z] [INFO] === Multisig Setup Plan === +[2026-06-26T11:53:00Z] [INFO] Network: futurenet +[2026-06-26T11:53:00Z] [INFO] Account: GABC... +[2026-06-26T11:53:00Z] [INFO] Signers (N=5): +[2026-06-26T11:53:00Z] [INFO] GA1... (weight=1) +... +[2026-06-26T11:53:01Z] [INFO] Transaction built. +[2026-06-26T11:53:02Z] [INFO] Submitting transaction... +[2026-06-26T11:53:03Z] [INFO] ✓ High threshold confirmed: 3 +[2026-06-26T11:53:03Z] [INFO] Signers found on-chain: 5 (expected 5) +[2026-06-26T11:53:03Z] [INFO] === Setup complete. Audit log: multisig-setup.log === +``` + +Commit `multisig-setup.log` to your ops runbook repo (not this repository) after each governance change. + +--- + +## On-Chain Signer Rotation (`stealth-sender`, `wraith-names`) + +The account-level Stellar multisig above governs the *admin key* that submits +transactions. Separately, `stealth-sender` and `wraith-names` — the two +contracts GOVERNANCE.md marks **Timelock + Multisig Upgradable** — each keep +their own on-chain governance signer set and quorum threshold, used to +authorise a `rotate_signers` flow without a contract redeploy (issue #104). + +This is intentionally a second, independent layer: the Stellar account +multisig above controls *who can submit transactions at all*; the contract's +own signer set controls *quorum + timelock for signer rotation specifically*, +enforced by the contract itself regardless of which account submitted the +call. + +### Flow + +1. **Propose** — any current signer proposes a new signer set + threshold. + Their approval is recorded automatically. Only one rotation may be + pending at a time. + ```bash + stellar contract invoke \ + --network futurenet \ + --id \ + --source \ + -- propose_rotate_signers \ + --caller \ + --new_signers '["G_NEW1","G_NEW2","G_NEW3","G_NEW4","G_NEW5"]' \ + --new_threshold 3 + ``` + Rejected with `InvalidThreshold` if `new_threshold` is `0` or greater than + `new_signers.len()` — a quorum that could never be reached. + +2. **Approve** — remaining current signers add their approval until quorum + (the *current* threshold) is met: + ```bash + stellar contract invoke \ + --network futurenet \ + --id \ + --source \ + -- approve_rotate_signers \ + --caller + ``` + +3. **Wait out the timelock** — 7 days from the `propose` call + (`ROTATION_TIMELOCK_SECS`), mirroring the upgrade timelock in + GOVERNANCE.md. + +4. **Execute** — once quorum is met and the timelock has elapsed, any + current signer executes the rotation. This swaps in the new signer set + and threshold, clears the proposal, and emits `SignersRotated(new_signers, + old_threshold, new_threshold)`: + ```bash + stellar contract invoke \ + --network futurenet \ + --id \ + --source \ + -- execute_rotate_signers \ + --caller + ``` + Fails with `QuorumNotMet` or `TimelockNotElapsed` if either condition + isn't satisfied yet. + +5. **Cancel (optional)** — any current signer can abort a pending rotation + before execution. This fully clears the proposal (including collected + approvals), so a fresh `propose_rotate_signers` can start immediately — + no leftover state blocks it: + ```bash + stellar contract invoke \ + --network futurenet \ + --id \ + --source \ + -- cancel_rotate_signers \ + --caller + ``` + +### Inspecting state + +```bash +stellar contract invoke --network futurenet --id --source -- signers +stellar contract invoke --network futurenet --id --source -- threshold +stellar contract invoke --network futurenet --id --source -- pending_rotation +``` + +### Futurenet rehearsal + +> **Status: not yet rehearsed.** The flow above is covered by unit tests in +> `stealth-sender/src/lib.rs` and `wraith-names/src/lib.rs` (quorum + timelock +> enforcement, invalid-threshold rejection, and cancelled-mid-rotation state +> cleanup), but has not been exercised against a live futurenet deployment. +> Before relying on this runbook for a mainnet rotation, a maintainer with +> futurenet deploy access should walk through propose → approve (x2) → +> advance ledger time past the 7-day timelock → execute, and separately +> propose → approve → cancel → propose again, confirming the CLI commands +> above match the deployed contract interface, then update this section with +> the confirmed transcript. + +## Script Reference + +``` +./setup-multisig.sh [OPTIONS] + +Required: + --network + --account + --signers + --threshold + +Optional: + --dry-run Print plan without submitting any transaction + --identity (default: $STELLAR_IDENTITY or "default") + --log-file (default: multisig-setup.log) +``` + +Exit codes: `0` = success / dry-run OK, `1` = validation error or submission failure. diff --git a/stellar/stealth-sender/src/lib.rs b/stellar/stealth-sender/src/lib.rs index 1321479..1bb0b05 100644 --- a/stellar/stealth-sender/src/lib.rs +++ b/stellar/stealth-sender/src/lib.rs @@ -4,12 +4,21 @@ use soroban_sdk::{ contract, contracterror, contractimpl, contracttype, token, Address, Bytes, BytesN, Env, Vec, }; +mod multisig; +pub use multisig::RotationProposal; + /// Storage keys. #[contracttype] #[derive(Clone)] pub enum DataKey { /// The address of the deployed StealthAnnouncer contract. Announcer, + /// Governance multisig signer set. + MultisigSigners, + /// Governance multisig quorum threshold. + MultisigThreshold, + /// Pending signer-rotation proposal, if any. + PendingRotation, } /// Errors that the sender contract can produce. @@ -23,6 +32,24 @@ pub enum SenderError { NotInitialized = 2, /// The batch input vectors have mismatched lengths. LengthMismatch = 3, + /// The governance multisig has not been initialised. + MultisigNotInitialized = 6, + /// The governance multisig has already been initialised. + MultisigAlreadyInitialized = 7, + /// The caller is not a current governance signer. + NotSigner = 8, + /// The requested threshold is invalid (zero, or greater than signer count). + InvalidThreshold = 9, + /// A signer-rotation proposal is already pending. + RotationAlreadyPending = 10, + /// No signer-rotation proposal is pending. + NoPendingRotation = 11, + /// The caller has already approved the pending rotation. + AlreadyApprovedRotation = 12, + /// The pending rotation has not collected enough approvals yet. + QuorumNotMet = 13, + /// The rotation timelock has not elapsed yet. + TimelockNotElapsed = 14, } /// Lightweight client wrapper that invokes the StealthAnnouncer contract via @@ -167,4 +194,321 @@ impl StealthSenderContract { Ok(()) } + + /// One-time setup of the governance signer set used to authorise signer + /// rotations. Independent of `init` — does not gate `send`/`batch_send`. + pub fn init_multisig( + env: Env, + signers: Vec
, + threshold: u32, + ) -> Result<(), SenderError> { + multisig::init(&env, signers, threshold) + } + + /// Current governance signer set. + pub fn signers(env: Env) -> Vec
{ + multisig::signers(&env) + } + + /// Current governance quorum threshold. + pub fn threshold(env: Env) -> u32 { + multisig::threshold(&env) + } + + /// The pending signer-rotation proposal, if any. + pub fn pending_rotation(env: Env) -> Option { + multisig::pending_rotation(&env) + } + + /// Propose a new signer set + threshold behind the rotation timelock. + /// `caller` must be a current signer; the proposal is auto-approved by + /// `caller`. Rejects thresholds that could never reach quorum. + pub fn propose_rotate_signers( + env: Env, + caller: Address, + new_signers: Vec
, + new_threshold: u32, + ) -> Result<(), SenderError> { + multisig::propose_rotate_signers(&env, caller, new_signers, new_threshold) + } + + /// Approve the pending signer-rotation proposal. + pub fn approve_rotate_signers(env: Env, caller: Address) -> Result<(), SenderError> { + multisig::approve_rotate_signers(&env, caller) + } + + /// Execute the pending rotation once quorum is met and the timelock has + /// elapsed. Emits `SignersRotated`. + pub fn execute_rotate_signers(env: Env, caller: Address) -> Result<(), SenderError> { + multisig::execute_rotate_signers(&env, caller) + } + + /// Cancel the pending rotation, clearing all of its state. + pub fn cancel_rotate_signers(env: Env, caller: Address) -> Result<(), SenderError> { + multisig::cancel_rotate_signers(&env, caller) + } +} + +#[cfg(test)] +mod test { + use super::*; + use soroban_sdk::testutils::Address as _; + use soroban_sdk::testutils::Ledger; + use soroban_sdk::{Bytes, BytesN, Env}; + + #[contract] + pub struct MockAnnouncer; + + #[contractimpl] + impl MockAnnouncer { + pub fn announce( + env: Env, + scheme_id: u32, + stealth_address: Address, + ephemeral_pub_key: BytesN<32>, + metadata: Bytes, + ) { + env.events().publish( + ( + soroban_sdk::symbol_short!("announce"), + scheme_id, + stealth_address, + ), + (env.current_contract_address(), ephemeral_pub_key, metadata), + ); + } + } + + #[test] + fn test_sender_workflow() { + let env = Env::default(); + env.mock_all_auths(); + + let announcer_id = env.register(MockAnnouncer, ()); + let sender_id = env.register(StealthSenderContract, ()); + let client = StealthSenderContractClient::new(&env, &sender_id); + + let token_admin = Address::generate(&env); + let token_id = env + .register_stellar_asset_contract_v2(token_admin) + .address(); + let token_client = token::Client::new(&env, &token_id); + + client.init(&announcer_id); + + // Verify AlreadyInitialized error + let init_res = client.try_init(&announcer_id); + assert_eq!(init_res, Err(Ok(SenderError::AlreadyInitialized))); + + // Setup transfer accounts and mint tokens + let sender = Address::generate(&env); + let stealth_address = Address::generate(&env); + + let token_admin_client = token::StellarAssetClient::new(&env, &token_id); + token_admin_client.mint(&sender, &1000); + + assert_eq!(token_client.balance(&sender), 1000); + assert_eq!(token_client.balance(&stealth_address), 0); + + // Test send functionality + let epk = BytesN::from_array(&env, &[1u8; 32]); + let meta = Bytes::from_slice(&env, &[0u8; 1]); + + client.send(&sender, &token_id, &500, &1, &stealth_address, &epk, &meta); + + // Check balances + assert_eq!(token_client.balance(&sender), 500); + assert_eq!(token_client.balance(&stealth_address), 500); + } + + #[test] + fn test_batch_send() { + let env = Env::default(); + env.mock_all_auths(); + + let announcer_id = env.register(MockAnnouncer, ()); + let sender_id = env.register(StealthSenderContract, ()); + let client = StealthSenderContractClient::new(&env, &sender_id); + + let token_admin = Address::generate(&env); + let token_id = env + .register_stellar_asset_contract_v2(token_admin) + .address(); + let token_client = token::Client::new(&env, &token_id); + + client.init(&announcer_id); + + let sender = Address::generate(&env); + let token_admin_client = token::StellarAssetClient::new(&env, &token_id); + token_admin_client.mint(&sender, &2000); + + let stealth_addr_1 = Address::generate(&env); + let stealth_addr_2 = Address::generate(&env); + + let epk_1 = BytesN::from_array(&env, &[1u8; 32]); + let epk_2 = BytesN::from_array(&env, &[2u8; 32]); + + let meta_1 = Bytes::from_slice(&env, &[10u8; 1]); + let meta_2 = Bytes::from_slice(&env, &[20u8; 1]); + + let addresses = soroban_sdk::vec![&env, stealth_addr_1.clone(), stealth_addr_2.clone()]; + let epks = soroban_sdk::vec![&env, epk_1, epk_2]; + let metadatas = soroban_sdk::vec![&env, meta_1, meta_2]; + let amounts = soroban_sdk::vec![&env, 700, 800]; + + client.batch_send( + &sender, &token_id, &1, &addresses, &epks, &metadatas, &amounts, + ); + + assert_eq!(token_client.balance(&sender), 500); + assert_eq!(token_client.balance(&stealth_addr_1), 700); + assert_eq!(token_client.balance(&stealth_addr_2), 800); + } + + fn setup_multisig(env: &Env) -> (StealthSenderContractClient, Vec
) { + let announcer_id = env.register(MockAnnouncer, ()); + let sender_id = env.register(StealthSenderContract, ()); + let client = StealthSenderContractClient::new(env, &sender_id); + client.init(&announcer_id); + + let signers = soroban_sdk::vec![ + env, + Address::generate(env), + Address::generate(env), + Address::generate(env), + Address::generate(env), + Address::generate(env), + ]; + client.init_multisig(&signers, &3); + + (client, signers) + } + + #[test] + fn test_init_multisig_rejects_invalid_threshold() { + let env = Env::default(); + env.mock_all_auths(); + + let announcer_id = env.register(MockAnnouncer, ()); + let sender_id = env.register(StealthSenderContract, ()); + let client = StealthSenderContractClient::new(&env, &sender_id); + client.init(&announcer_id); + + let signers = soroban_sdk::vec![&env, Address::generate(&env), Address::generate(&env)]; + + // Zero threshold is unreachable. + let res = client.try_init_multisig(&signers, &0); + assert_eq!(res, Err(Ok(SenderError::InvalidThreshold))); + + // Threshold greater than signer count is unreachable. + let res = client.try_init_multisig(&signers, &3); + assert_eq!(res, Err(Ok(SenderError::InvalidThreshold))); + } + + #[test] + fn test_propose_rotate_signers_rejects_invalid_threshold() { + let env = Env::default(); + env.mock_all_auths(); + + let (client, signers) = setup_multisig(&env); + let new_signers = soroban_sdk::vec![&env, Address::generate(&env), Address::generate(&env)]; + + let res = client.try_propose_rotate_signers(&signers.get(0).unwrap(), &new_signers, &0); + assert_eq!(res, Err(Ok(SenderError::InvalidThreshold))); + + let res = client.try_propose_rotate_signers(&signers.get(0).unwrap(), &new_signers, &3); + assert_eq!(res, Err(Ok(SenderError::InvalidThreshold))); + + // No proposal was recorded by the rejected attempts. + assert!(client.pending_rotation().is_none()); + } + + #[test] + fn test_rotate_signers_requires_quorum_and_timelock() { + let env = Env::default(); + env.mock_all_auths(); + + let (client, signers) = setup_multisig(&env); + + let new_signers = soroban_sdk::vec![&env, Address::generate(&env), Address::generate(&env)]; + + client.propose_rotate_signers(&signers.get(0).unwrap(), &new_signers, &2); + + // Only one rotation may be pending at a time. + let res = client.try_propose_rotate_signers(&signers.get(1).unwrap(), &new_signers, &2); + assert_eq!(res, Err(Ok(SenderError::RotationAlreadyPending))); + + // Only 1 of 3 required approvals so far (the proposer's). + let res = client.try_execute_rotate_signers(&signers.get(0).unwrap()); + assert_eq!(res, Err(Ok(SenderError::QuorumNotMet))); + + client.approve_rotate_signers(&signers.get(1).unwrap()); + client.approve_rotate_signers(&signers.get(2).unwrap()); + + // Quorum met, but the timelock has not elapsed yet. + let res = client.try_execute_rotate_signers(&signers.get(0).unwrap()); + assert_eq!(res, Err(Ok(SenderError::TimelockNotElapsed))); + + env.ledger().with_mut(|li| { + li.timestamp += multisig::ROTATION_TIMELOCK_SECS; + }); + + client.execute_rotate_signers(&signers.get(0).unwrap()); + + assert_eq!(client.signers(), new_signers); + assert_eq!(client.threshold(), 2); + assert!(client.pending_rotation().is_none()); + } + + #[test] + fn test_cancelled_rotation_clears_state() { + let env = Env::default(); + env.mock_all_auths(); + + let (client, signers) = setup_multisig(&env); + + let new_signers = soroban_sdk::vec![&env, Address::generate(&env), Address::generate(&env)]; + client.propose_rotate_signers(&signers.get(0).unwrap(), &new_signers, &2); + client.approve_rotate_signers(&signers.get(1).unwrap()); + + client.cancel_rotate_signers(&signers.get(2).unwrap()); + + // Cancelling clears the proposal entirely. + assert!(client.pending_rotation().is_none()); + + // The original signer set / threshold are untouched by the aborted rotation. + assert_eq!(client.signers(), signers); + assert_eq!(client.threshold(), 3); + + // A stale approve/execute/cancel against the cleared proposal fails cleanly. + let res = client.try_approve_rotate_signers(&signers.get(3).unwrap()); + assert_eq!(res, Err(Ok(SenderError::NoPendingRotation))); + let res = client.try_execute_rotate_signers(&signers.get(0).unwrap()); + assert_eq!(res, Err(Ok(SenderError::NoPendingRotation))); + let res = client.try_cancel_rotate_signers(&signers.get(0).unwrap()); + assert_eq!(res, Err(Ok(SenderError::NoPendingRotation))); + + // A fresh proposal can be made immediately — no leftover state blocks it. + let other_signers = + soroban_sdk::vec![&env, Address::generate(&env), Address::generate(&env)]; + client.propose_rotate_signers(&signers.get(0).unwrap(), &other_signers, &2); + assert!(client.pending_rotation().is_some()); + } + + #[test] + fn test_non_signer_cannot_propose_or_approve() { + let env = Env::default(); + env.mock_all_auths(); + + let (client, signers) = setup_multisig(&env); + let outsider = Address::generate(&env); + + let new_signers = soroban_sdk::vec![&env, Address::generate(&env), Address::generate(&env)]; + let res = client.try_propose_rotate_signers(&outsider, &new_signers, &2); + assert_eq!(res, Err(Ok(SenderError::NotSigner))); + + client.propose_rotate_signers(&signers.get(0).unwrap(), &new_signers, &2); + let res = client.try_approve_rotate_signers(&outsider); + assert_eq!(res, Err(Ok(SenderError::NotSigner))); + } } diff --git a/stellar/stealth-sender/src/multisig.rs b/stellar/stealth-sender/src/multisig.rs new file mode 100644 index 0000000..b560658 --- /dev/null +++ b/stellar/stealth-sender/src/multisig.rs @@ -0,0 +1,179 @@ +//! On-chain multi-sig quorum + timelock signer-rotation flow. +//! +//! Governance signers propose a new signer set and threshold. The proposal +//! must collect approvals from a quorum of the *current* signers and wait +//! out a timelock before it can be executed, mirroring the upgrade timelock +//! described in GOVERNANCE.md / MULTISIG.md. This module only manages the +//! governance signer set + threshold; it is deliberately independent of +//! `DataKey::Announcer` / send logic so a bad rotation can never brick sends. + +use soroban_sdk::{contracttype, Address, Env, Symbol, Vec}; + +use crate::{DataKey, SenderError}; + +/// 7 days, matching the GOVERNANCE.md upgrade timelock. +pub const ROTATION_TIMELOCK_SECS: u64 = 7 * 24 * 60 * 60; + +/// A pending signer-rotation proposal. +#[contracttype] +#[derive(Clone)] +pub struct RotationProposal { + pub new_signers: Vec
, + pub new_threshold: u32, + pub executable_at: u64, + pub approvals: Vec
, +} + +/// One-time setup of the governance signer set. Must be called before any +/// rotation function. +pub fn init(env: &Env, signers: Vec
, threshold: u32) -> Result<(), SenderError> { + if env.storage().instance().has(&DataKey::MultisigSigners) { + return Err(SenderError::MultisigAlreadyInitialized); + } + validate_threshold(&signers, threshold)?; + + env.storage() + .instance() + .set(&DataKey::MultisigSigners, &signers); + env.storage() + .instance() + .set(&DataKey::MultisigThreshold, &threshold); + Ok(()) +} + +pub fn signers(env: &Env) -> Vec
{ + env.storage() + .instance() + .get(&DataKey::MultisigSigners) + .unwrap_or_else(|| Vec::new(env)) +} + +pub fn threshold(env: &Env) -> u32 { + env.storage() + .instance() + .get(&DataKey::MultisigThreshold) + .unwrap_or(0) +} + +pub fn pending_rotation(env: &Env) -> Option { + env.storage().instance().get(&DataKey::PendingRotation) +} + +fn require_signer(env: &Env, caller: &Address) -> Result<(), SenderError> { + caller.require_auth(); + if !signers(env).contains(caller) { + return Err(SenderError::NotSigner); + } + Ok(()) +} + +/// Rejects a threshold of zero or a threshold greater than the proposed +/// signer count — a quorum that could never be reached. +fn validate_threshold(signers: &Vec
, threshold: u32) -> Result<(), SenderError> { + if threshold == 0 || threshold > signers.len() { + return Err(SenderError::InvalidThreshold); + } + Ok(()) +} + +/// Propose a new signer set + threshold. The caller's approval is recorded +/// automatically. Only one rotation may be pending at a time. +pub fn propose_rotate_signers( + env: &Env, + caller: Address, + new_signers: Vec
, + new_threshold: u32, +) -> Result<(), SenderError> { + if !env.storage().instance().has(&DataKey::MultisigSigners) { + return Err(SenderError::MultisigNotInitialized); + } + require_signer(env, &caller)?; + + if env.storage().instance().has(&DataKey::PendingRotation) { + return Err(SenderError::RotationAlreadyPending); + } + + validate_threshold(&new_signers, new_threshold)?; + + let mut approvals = Vec::new(env); + approvals.push_back(caller); + + let proposal = RotationProposal { + new_signers, + new_threshold, + executable_at: env.ledger().timestamp() + ROTATION_TIMELOCK_SECS, + approvals, + }; + env.storage() + .instance() + .set(&DataKey::PendingRotation, &proposal); + Ok(()) +} + +/// Add the caller's approval to the pending rotation proposal. +pub fn approve_rotate_signers(env: &Env, caller: Address) -> Result<(), SenderError> { + require_signer(env, &caller)?; + + let mut proposal: RotationProposal = env + .storage() + .instance() + .get(&DataKey::PendingRotation) + .ok_or(SenderError::NoPendingRotation)?; + + if proposal.approvals.contains(&caller) { + return Err(SenderError::AlreadyApprovedRotation); + } + proposal.approvals.push_back(caller); + env.storage() + .instance() + .set(&DataKey::PendingRotation, &proposal); + Ok(()) +} + +/// Execute the pending rotation once quorum (measured against the *current* +/// threshold) has been reached and the timelock has elapsed. Clears the +/// pending proposal and emits `SignersRotated`. +pub fn execute_rotate_signers(env: &Env, caller: Address) -> Result<(), SenderError> { + require_signer(env, &caller)?; + + let proposal: RotationProposal = env + .storage() + .instance() + .get(&DataKey::PendingRotation) + .ok_or(SenderError::NoPendingRotation)?; + + let old_threshold = threshold(env); + if (proposal.approvals.len()) < old_threshold { + return Err(SenderError::QuorumNotMet); + } + if env.ledger().timestamp() < proposal.executable_at { + return Err(SenderError::TimelockNotElapsed); + } + + env.storage() + .instance() + .set(&DataKey::MultisigSigners, &proposal.new_signers); + env.storage() + .instance() + .set(&DataKey::MultisigThreshold, &proposal.new_threshold); + env.storage().instance().remove(&DataKey::PendingRotation); + + env.events().publish( + (Symbol::new(env, "SignersRotated"),), + (proposal.new_signers, old_threshold, proposal.new_threshold), + ); + + Ok(()) +} + +/// Cancel the pending rotation, fully clearing its storage so a future +/// proposal starts from a clean slate. +pub fn cancel_rotate_signers(env: &Env, caller: Address) -> Result<(), SenderError> { + require_signer(env, &caller)?; + + if !env.storage().instance().has(&DataKey::PendingRotation) { + return Err(SenderError::NoPendingRotation); + } + env.storage().instance().remove(&DataKey::PendingRotation); + Ok(()) +} diff --git a/stellar/wraith-names/src/lib.rs b/stellar/wraith-names/src/lib.rs index 2be3797..dda74b1 100644 --- a/stellar/wraith-names/src/lib.rs +++ b/stellar/wraith-names/src/lib.rs @@ -2,9 +2,12 @@ use soroban_sdk::{ contract, contracterror, contractimpl, contracttype, symbol_short, Address, Bytes, BytesN, Env, - String, + String, Vec, }; +mod multisig; +pub use multisig::RotationProposal; + /// Storage keys. #[contracttype] #[derive(Clone)] @@ -13,6 +16,12 @@ pub enum DataKey { Name(BytesN<32>), /// Reverse lookup: meta-address hash (BytesN<32>) to name hash (BytesN<32>). Reverse(BytesN<32>), + /// Governance multisig signer set. + MultisigSigners, + /// Governance multisig quorum threshold. + MultisigThreshold, + /// Pending signer-rotation proposal, if any. + PendingRotation, } /// A registered name entry. @@ -39,6 +48,24 @@ pub enum NamesError { InvalidMetaAddress = 5, NameNotFound = 6, NotOwner = 7, + /// The governance multisig has not been initialised. + MultisigNotInitialized = 21, + /// The governance multisig has already been initialised. + MultisigAlreadyInitialized = 22, + /// The caller is not a current governance signer. + NotSigner = 23, + /// The requested threshold is invalid (zero, or greater than signer count). + InvalidThreshold = 24, + /// A signer-rotation proposal is already pending. + RotationAlreadyPending = 25, + /// No signer-rotation proposal is pending. + NoPendingRotation = 26, + /// The caller has already approved the pending rotation. + AlreadyApprovedRotation = 27, + /// The pending rotation has not collected enough approvals yet. + QuorumNotMet = 28, + /// The rotation timelock has not elapsed yet. + TimelockNotElapsed = 29, } #[contract] @@ -230,6 +257,57 @@ impl WraithNamesContract { BytesN::from_array(env, &env.crypto().sha256(&bytes).to_array()) } + /// One-time setup of the governance signer set used to authorise signer + /// rotations. Independent of name registration. + pub fn init_multisig( + env: Env, + signers: Vec
, + threshold: u32, + ) -> Result<(), NamesError> { + multisig::init(&env, signers, threshold) + } + + /// Current governance signer set. + pub fn signers(env: Env) -> Vec
{ + multisig::signers(&env) + } + + /// Current governance quorum threshold. + pub fn threshold(env: Env) -> u32 { + multisig::threshold(&env) + } + + /// The pending signer-rotation proposal, if any. + pub fn pending_rotation(env: Env) -> Option { + multisig::pending_rotation(&env) + } + + /// Propose a new signer set + threshold behind the rotation timelock. + pub fn propose_rotate_signers( + env: Env, + caller: Address, + new_signers: Vec
, + new_threshold: u32, + ) -> Result<(), NamesError> { + multisig::propose_rotate_signers(&env, caller, new_signers, new_threshold) + } + + /// Approve the pending signer-rotation proposal. + pub fn approve_rotate_signers(env: Env, caller: Address) -> Result<(), NamesError> { + multisig::approve_rotate_signers(&env, caller) + } + + /// Execute the pending rotation once quorum is met and the timelock has + /// elapsed. Emits `SignersRotated`. + pub fn execute_rotate_signers(env: Env, caller: Address) -> Result<(), NamesError> { + multisig::execute_rotate_signers(&env, caller) + } + + /// Cancel the pending rotation, clearing all of its state. + pub fn cancel_rotate_signers(env: Env, caller: Address) -> Result<(), NamesError> { + multisig::cancel_rotate_signers(&env, caller) + } + /// Validate name: 3-32 chars, lowercase alphanumeric only. fn validate_name(_env: &Env, name: &String) -> Result<(), NamesError> { let len = name.len() as usize; diff --git a/stellar/wraith-names/src/multisig.rs b/stellar/wraith-names/src/multisig.rs new file mode 100644 index 0000000..0471d63 --- /dev/null +++ b/stellar/wraith-names/src/multisig.rs @@ -0,0 +1,181 @@ +//! On-chain multi-sig quorum + timelock signer-rotation flow. +//! +//! Governance signers propose a new signer set and threshold. The proposal +//! must collect approvals from a quorum of the *current* signers and wait +//! out a timelock before it can be executed, mirroring the upgrade timelock +//! described in GOVERNANCE.md / MULTISIG.md. This is the protocol-level +//! governance signer set for `wraith-names` itself — distinct from the +//! per-name `GuardianConfig` recovery guardians defined elsewhere in this +//! crate, which govern individual name ownership rather than the contract. + +use soroban_sdk::{contracttype, Address, Env, Symbol, Vec}; + +use crate::{DataKey, NamesError}; + +/// 7 days, matching the GOVERNANCE.md upgrade timelock. +pub const ROTATION_TIMELOCK_SECS: u64 = 7 * 24 * 60 * 60; + +/// A pending signer-rotation proposal. +#[contracttype] +#[derive(Clone)] +pub struct RotationProposal { + pub new_signers: Vec
, + pub new_threshold: u32, + pub executable_at: u64, + pub approvals: Vec
, +} + +/// One-time setup of the governance signer set used to authorise signer +/// rotations. +pub fn init(env: &Env, signers: Vec
, threshold: u32) -> Result<(), NamesError> { + if env.storage().instance().has(&DataKey::MultisigSigners) { + return Err(NamesError::MultisigAlreadyInitialized); + } + validate_threshold(&signers, threshold)?; + + env.storage() + .instance() + .set(&DataKey::MultisigSigners, &signers); + env.storage() + .instance() + .set(&DataKey::MultisigThreshold, &threshold); + Ok(()) +} + +pub fn signers(env: &Env) -> Vec
{ + env.storage() + .instance() + .get(&DataKey::MultisigSigners) + .unwrap_or_else(|| Vec::new(env)) +} + +pub fn threshold(env: &Env) -> u32 { + env.storage() + .instance() + .get(&DataKey::MultisigThreshold) + .unwrap_or(0) +} + +pub fn pending_rotation(env: &Env) -> Option { + env.storage().instance().get(&DataKey::PendingRotation) +} + +fn require_signer(env: &Env, caller: &Address) -> Result<(), NamesError> { + caller.require_auth(); + if !signers(env).contains(caller) { + return Err(NamesError::NotSigner); + } + Ok(()) +} + +/// Rejects a threshold of zero or a threshold greater than the proposed +/// signer count — a quorum that could never be reached. +fn validate_threshold(signers: &Vec
, threshold: u32) -> Result<(), NamesError> { + if threshold == 0 || threshold > signers.len() { + return Err(NamesError::InvalidThreshold); + } + Ok(()) +} + +/// Propose a new signer set + threshold behind the rotation timelock. The +/// caller's approval is recorded automatically. Only one rotation may be +/// pending at a time. +pub fn propose_rotate_signers( + env: &Env, + caller: Address, + new_signers: Vec
, + new_threshold: u32, +) -> Result<(), NamesError> { + if !env.storage().instance().has(&DataKey::MultisigSigners) { + return Err(NamesError::MultisigNotInitialized); + } + require_signer(env, &caller)?; + + if env.storage().instance().has(&DataKey::PendingRotation) { + return Err(NamesError::RotationAlreadyPending); + } + + validate_threshold(&new_signers, new_threshold)?; + + let mut approvals = Vec::new(env); + approvals.push_back(caller); + + let proposal = RotationProposal { + new_signers, + new_threshold, + executable_at: env.ledger().timestamp() + ROTATION_TIMELOCK_SECS, + approvals, + }; + env.storage() + .instance() + .set(&DataKey::PendingRotation, &proposal); + Ok(()) +} + +/// Add the caller's approval to the pending rotation proposal. +pub fn approve_rotate_signers(env: &Env, caller: Address) -> Result<(), NamesError> { + require_signer(env, &caller)?; + + let mut proposal: RotationProposal = env + .storage() + .instance() + .get(&DataKey::PendingRotation) + .ok_or(NamesError::NoPendingRotation)?; + + if proposal.approvals.contains(&caller) { + return Err(NamesError::AlreadyApprovedRotation); + } + proposal.approvals.push_back(caller); + env.storage() + .instance() + .set(&DataKey::PendingRotation, &proposal); + Ok(()) +} + +/// Execute the pending rotation once quorum (measured against the *current* +/// threshold) has been reached and the timelock has elapsed. Clears the +/// pending proposal and emits `SignersRotated`. +pub fn execute_rotate_signers(env: &Env, caller: Address) -> Result<(), NamesError> { + require_signer(env, &caller)?; + + let proposal: RotationProposal = env + .storage() + .instance() + .get(&DataKey::PendingRotation) + .ok_or(NamesError::NoPendingRotation)?; + + let old_threshold = threshold(env); + if (proposal.approvals.len()) < old_threshold { + return Err(NamesError::QuorumNotMet); + } + if env.ledger().timestamp() < proposal.executable_at { + return Err(NamesError::TimelockNotElapsed); + } + + env.storage() + .instance() + .set(&DataKey::MultisigSigners, &proposal.new_signers); + env.storage() + .instance() + .set(&DataKey::MultisigThreshold, &proposal.new_threshold); + env.storage().instance().remove(&DataKey::PendingRotation); + + env.events().publish( + (Symbol::new(env, "SignersRotated"),), + (proposal.new_signers, old_threshold, proposal.new_threshold), + ); + + Ok(()) +} + +/// Cancel the pending rotation, fully clearing its storage so a future +/// proposal starts from a clean slate. +pub fn cancel_rotate_signers(env: &Env, caller: Address) -> Result<(), NamesError> { + require_signer(env, &caller)?; + + if !env.storage().instance().has(&DataKey::PendingRotation) { + return Err(NamesError::NoPendingRotation); + } + env.storage().instance().remove(&DataKey::PendingRotation); + Ok(()) +}