From 9a801e2079b2f1730f9a13bb275a93c6afdb6693 Mon Sep 17 00:00:00 2001 From: OpadijoIdris Date: Fri, 24 Jul 2026 20:47:08 +0100 Subject: [PATCH] feat(stellar): add on-chain multisig quorum rotation for signers Add rotate_signers flow to stealth-sender and wraith-names (the two Timelock + Multisig Upgradable contracts per GOVERNANCE.md), gated by quorum approval from current signers plus a 7-day timelock matching the existing upgrade timelock. Invalid thresholds (zero, or greater than the proposed signer count) are rejected with a dedicated error. Execution emits SignersRotated; cancellation fully clears pending proposal state so a new rotation can be proposed immediately. Update MULTISIG.md with the on-chain rotation runbook. Futurenet rehearsal is called out as a follow-up since it requires a live deployment. Closes #104 --- stellar/MULTISIG.md | 96 +++++++++++ stellar/stealth-sender/src/lib.rs | 223 +++++++++++++++++++++++++ stellar/stealth-sender/src/multisig.rs | 179 ++++++++++++++++++++ stellar/wraith-names/src/lib.rs | 219 ++++++++++++++++++++++++ stellar/wraith-names/src/multisig.rs | 181 ++++++++++++++++++++ 5 files changed, 898 insertions(+) create mode 100644 stellar/stealth-sender/src/multisig.rs create mode 100644 stellar/wraith-names/src/multisig.rs diff --git a/stellar/MULTISIG.md b/stellar/MULTISIG.md index c47619c..cadc860 100644 --- a/stellar/MULTISIG.md +++ b/stellar/MULTISIG.md @@ -139,6 +139,102 @@ Commit `multisig-setup.log` to your ops runbook repo (not this repository) after --- +## 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 ``` diff --git a/stellar/stealth-sender/src/lib.rs b/stellar/stealth-sender/src/lib.rs index eafbc18..35b5346 100644 --- a/stellar/stealth-sender/src/lib.rs +++ b/stellar/stealth-sender/src/lib.rs @@ -6,6 +6,9 @@ use soroban_sdk::{ }; use wraith_metrics::{contract_ids, dimension_names, emit_metric, metric_names}; +mod multisig; +pub use multisig::RotationProposal; + /// Storage keys. #[contracttype] #[derive(Clone)] @@ -18,6 +21,12 @@ pub enum DataKey { FeeRecipient, /// Protocol fee in basis points (max 50 bps, 0 = disabled). FeeBasisPoints, + /// Governance multisig signer set. + MultisigSigners, + /// Governance multisig quorum threshold. + MultisigThreshold, + /// Pending signer-rotation proposal, if any. + PendingRotation, } /// Errors that the sender contract can produce. @@ -35,6 +44,24 @@ pub enum SenderError { TokenNotAllowed = 4, /// The fee configuration is invalid (e.g. fee > 50 bps, or fee > 0 with no recipient). InvalidFeeConfig = 5, + /// 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 @@ -373,6 +400,59 @@ 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)] @@ -529,4 +609,147 @@ mod test { 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 sender_id = env.register(StealthSenderContract, ()); + let client = StealthSenderContractClient::new(env, &sender_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 sender_id = env.register(StealthSenderContract, ()); + let client = StealthSenderContractClient::new(&env, &sender_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 724da60..72d6c54 100644 --- a/stellar/wraith-names/src/lib.rs +++ b/stellar/wraith-names/src/lib.rs @@ -8,6 +8,9 @@ use soroban_sdk::{ String, Vec, }; +mod multisig; +pub use multisig::RotationProposal; + pub const WRAITH_NAMES_DOMAIN: &[u8] = b"wraith-names:v1"; const DELAY_WINDOW: u32 = 100_000; @@ -26,6 +29,12 @@ pub enum DataKey { Guardians(BytesN<32>), /// Pending recovery proposal for a name. Recovery(BytesN<32>), + /// Protocol-level governance multisig signer set. + MultisigSigners, + /// Protocol-level governance multisig quorum threshold. + MultisigThreshold, + /// Pending protocol-level signer-rotation proposal, if any. + PendingRotation, } /// A registered name entry. @@ -84,6 +93,22 @@ pub enum NamesError { InvalidThreshold = 18, InvalidExtendLedger = 19, ParentNotFound = 20, + /// The protocol-level governance multisig has not been initialised. + MultisigNotInitialized = 21, + /// The protocol-level governance multisig has already been initialised. + MultisigAlreadyInitialized = 22, + /// The caller is not a current protocol-level governance signer. + NotSigner = 23, + /// A signer-rotation proposal is already pending. + RotationAlreadyPending = 24, + /// No signer-rotation proposal is pending. + NoPendingRotation = 25, + /// The caller has already approved the pending rotation. + AlreadyApprovedRotation = 26, + /// The pending rotation has not collected enough approvals yet. + QuorumNotMet = 27, + /// The rotation timelock has not elapsed yet. + TimelockNotElapsed = 28, } const TTL_THRESHOLD: u32 = 17280; // ~1 day @@ -548,6 +573,59 @@ impl WraithNamesContract { } Ok(()) } + + /// One-time setup of the protocol-level governance signer set used to + /// authorise signer rotations. + pub fn init_multisig( + env: Env, + signers: Vec
, + threshold: u32, + ) -> Result<(), NamesError> { + multisig::init(&env, signers, threshold) + } + + /// Current protocol-level governance signer set. + pub fn signers(env: Env) -> Vec
{ + multisig::signers(&env) + } + + /// Current protocol-level 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<(), 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) + } } #[cfg(test)] @@ -963,4 +1041,145 @@ mod test { let result = client.try_register(&owner, &String::from_str(&env, "a.b.alice"), &meta); assert_eq!(result, Err(Ok(NamesError::InvalidNameCharacter))); } + + fn setup_multisig(env: &Env) -> (WraithNamesContractClient, Vec
) { + let contract_id = env.register(WraithNamesContract, ()); + let client = WraithNamesContractClient::new(env, &contract_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 contract_id = env.register(WraithNamesContract, ()); + let client = WraithNamesContractClient::new(&env, &contract_id); + + let signers = soroban_sdk::vec![&env, Address::generate(&env), Address::generate(&env)]; + + let res = client.try_init_multisig(&signers, &0); + assert_eq!(res, Err(Ok(NamesError::InvalidThreshold))); + + let res = client.try_init_multisig(&signers, &3); + assert_eq!(res, Err(Ok(NamesError::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(NamesError::InvalidThreshold))); + + let res = client.try_propose_rotate_signers(&signers.get(0).unwrap(), &new_signers, &3); + assert_eq!(res, Err(Ok(NamesError::InvalidThreshold))); + + assert!(client.pending_rotation().is_none()); + } + + #[test] + fn test_rotate_signers_requires_quorum_and_timelock() { + use soroban_sdk::testutils::Ledger; + + 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(NamesError::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(NamesError::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(NamesError::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(NamesError::NoPendingRotation))); + let res = client.try_execute_rotate_signers(&signers.get(0).unwrap()); + assert_eq!(res, Err(Ok(NamesError::NoPendingRotation))); + let res = client.try_cancel_rotate_signers(&signers.get(0).unwrap()); + assert_eq!(res, Err(Ok(NamesError::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(NamesError::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(NamesError::NotSigner))); + } } 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(()) +}