From 1873024dd91ed0476b904a68002c9310f3be06ee Mon Sep 17 00:00:00 2001 From: jayteemoney Date: Fri, 24 Jul 2026 17:44:13 +0100 Subject: [PATCH] feat: add governance signer rotation for the upgrade guard (#27) The governance-guard multi-sig previously froze its signer set and threshold at initialize, with no way to change them short of a full contract upgrade. This adds a timelocked, threshold-gated signer rotation flow (propose -> approve -> execute) to the shared guard and wires it into every host contract that consumes it. Security model: - Authorized by the current signer set at the current (upgrade) threshold -- rotation grants the set no power it lacked via upgrade. - Mandatory timelock between reaching threshold and execution gives a minority about to be removed a visible on-chain window: no silent, instant lockout (minority protection). - No unilateral veto; only one live rotation at a time, so a lone signer can neither reset a gathering rotation's approvals nor displace a scheduled one -- a compromised signer cannot block its own removal (majority protection). - Executing a rotation clears any pending upgrade, whose approvals came from the old signer set. Adds PendingRotation storage, RotationProposed/Approved/Scheduled/ Executed events, five rotation error variants, and 37 guard tests (now covering the full rotation lifecycle and both protection properties). Host contracts (escrow, reputation, loyalty-token, loyalty-emissions) gain propose/approve/execute_signer_rotation and get_pending_rotation entrypoints plus the mapped error variants. Follow-up from #26 / #16. --- soroban-contracts/contracts/escrow/src/lib.rs | 54 +- .../contracts/governance-guard/src/lib.rs | 431 +++++++++++++++- .../contracts/governance-guard/src/test.rs | 463 +++++++++++++++++- .../contracts/loyalty-emissions/src/lib.rs | 54 +- .../contracts/loyalty-token/src/lib.rs | 54 +- .../contracts/reputation/src/lib.rs | 54 +- 6 files changed, 1089 insertions(+), 21 deletions(-) diff --git a/soroban-contracts/contracts/escrow/src/lib.rs b/soroban-contracts/contracts/escrow/src/lib.rs index 331f2d1..e514ed4 100644 --- a/soroban-contracts/contracts/escrow/src/lib.rs +++ b/soroban-contracts/contracts/escrow/src/lib.rs @@ -38,7 +38,7 @@ use soroban_sdk::{ }; use guildworkman_governance_guard as governance; -pub use guildworkman_governance_guard::PendingUpgrade; +pub use guildworkman_governance_guard::{PendingRotation, PendingUpgrade}; /// Bump when this contract's storage layout actually changes shape and /// needs a real transformation in `migrate`. There's no such change yet. @@ -158,6 +158,13 @@ pub enum Error { MilestoneAmountMismatch = 28, InvalidMilestoneCount = 29, InvalidDeadline = 30, + // --- Signer rotation (see guildworkman-governance-guard) --- + NoPendingRotation = 31, + RotationMismatch = 32, + RotationNotReady = 33, + RotationTimelockActive = 34, + RotationExpired = 35, + RotationInProgress = 36, } impl From for Error { @@ -173,6 +180,12 @@ impl From for Error { governance::GovernanceError::ProposalExpired => Error::ProposalExpired, governance::GovernanceError::HashMismatch => Error::HashMismatch, governance::GovernanceError::AlreadyMigrated => Error::AlreadyMigrated, + governance::GovernanceError::NoPendingRotation => Error::NoPendingRotation, + governance::GovernanceError::RotationMismatch => Error::RotationMismatch, + governance::GovernanceError::RotationNotReady => Error::RotationNotReady, + governance::GovernanceError::RotationTimelockActive => Error::RotationTimelockActive, + governance::GovernanceError::RotationExpired => Error::RotationExpired, + governance::GovernanceError::RotationInProgress => Error::RotationInProgress, } } } @@ -237,6 +250,45 @@ impl EscrowContract { governance::cancel_upgrade(&env, caller).map_err(Into::into) } + // ----- Signer rotation ----- + + /// Opens a timelocked proposal to rotate the governance signer set and + /// threshold, authorized by the current signers at the current + /// threshold. Reaching threshold only *schedules* the rotation; + /// `execute_signer_rotation` applies it after the timelock. Returns + /// `true` if this call reached threshold. + pub fn propose_signer_rotation( + env: Env, + proposer: Address, + new_signers: Vec
, + new_threshold: u32, + ) -> Result { + governance::propose_signer_rotation(&env, proposer, new_signers, new_threshold) + .map_err(Into::into) + } + + /// Approves the pending signer rotation. Returns `true` when this + /// approval reaches threshold and schedules the rotation. + pub fn approve_signer_rotation( + env: Env, + approver: Address, + new_signers: Vec
, + new_threshold: u32, + ) -> Result { + governance::approve_signer_rotation(&env, approver, new_signers, new_threshold) + .map_err(Into::into) + } + + /// Applies a scheduled rotation once its timelock has elapsed. Any + /// current signer may trigger it. + pub fn execute_signer_rotation(env: Env, caller: Address) -> Result<(), Error> { + governance::execute_signer_rotation(&env, caller).map_err(Into::into) + } + + pub fn get_pending_rotation(env: Env) -> Option { + governance::get_pending_rotation(&env) + } + pub fn migrate(env: Env, signer: Address) -> Result<(), Error> { governance::require_signer(&env, &signer)?; if governance::current_storage_version(&env) >= CURRENT_STORAGE_VERSION { diff --git a/soroban-contracts/contracts/governance-guard/src/lib.rs b/soroban-contracts/contracts/governance-guard/src/lib.rs index c4b5575..73f1f45 100644 --- a/soroban-contracts/contracts/governance-guard/src/lib.rs +++ b/soroban-contracts/contracts/governance-guard/src/lib.rs @@ -48,8 +48,50 @@ //! contract's. So [`approve_upgrade`] returns `true` once the threshold is //! reached, and it's the host contract's job to react to that by calling //! `env.deployer().update_current_contract_wasm(wasm_hash)` itself. +//! +//! ## Signer rotation +//! +//! The signer set and threshold are no longer frozen at +//! [`init_governance`]. They can be rotated through a three-step flow: +//! [`propose_signer_rotation`] → [`approve_signer_rotation`] → +//! [`execute_signer_rotation`]. The security model is deliberately stricter +//! than the upgrade flow's, because rotation is the one action that can +//! change *who* governs: +//! +//! * **Authorization — same threshold as an upgrade.** A rotation is +//! approved by the *current* signer set at the *current* threshold. It is +//! gated no more weakly than an upgrade, and no more strongly either: +//! requiring a strictly-higher threshold is impossible once the threshold +//! is already N-of-N, and the current set can in any case already replace +//! *all* of a contract's code (governance included) through the upgrade +//! flow. So the same threshold is the honest bound — rotation grants the +//! signer set no power it didn't already have via upgrade. +//! +//! * **Minority protection — a mandatory timelock.** Reaching threshold does +//! not execute the rotation; it *schedules* it, +//! [`ROTATION_TIMELOCK_LEDGERS`] in the future. Only after that delay can +//! [`execute_signer_rotation`] apply it. This guarantees a minority about +//! to be removed a visible, on-chain window to react — no *silent, +//! instant* lockout is possible. +//! +//! * **Majority protection — no unilateral veto.** Unlike an upgrade (which +//! any single signer may [`cancel_upgrade`], because blocking a *change* +//! is the safe default), a rotation cannot be cancelled by one signer. +//! For rotation the status quo itself may be the threat — a lost or +//! compromised key — so letting any single signer block a rotation would +//! let a compromised signer entrench itself against its own removal. +//! Aborting or amending a pending rotation therefore requires a fresh +//! threshold agreement, expressed by proposing a superseding rotation +//! (e.g. back to the current set); see [`propose_signer_rotation`]. +//! +//! * **Cross-effect — a rotation clears any pending upgrade.** An in-flight +//! upgrade's approvals were gathered under the *old* signer set; carrying +//! them past a rotation could let a removed signer's approval count, or +//! let a stale half-approved upgrade cross threshold under a set that +//! never really approved it. So [`execute_signer_rotation`] discards any +//! pending upgrade. -use soroban_sdk::{contracterror, contracttype, Address, BytesN, Env, Vec}; +use soroban_sdk::{contracterror, contractevent, contracttype, Address, BytesN, Env, Vec}; #[contracttype] pub enum GovernanceDataKey { @@ -57,6 +99,11 @@ pub enum GovernanceDataKey { Threshold, PendingUpgrade, StorageVersion, + /// The single in-flight signer-rotation, if any. Distinct key from + /// `PendingUpgrade` so a rotation and an upgrade can be pending at the + /// same time without clobbering each other. Appended last to keep the + /// existing keys' encodings stable for already-deployed contracts. + PendingRotation, } #[contracttype] @@ -67,6 +114,38 @@ pub struct PendingUpgrade { pub proposed_at_ledger: u32, } +/// An in-flight proposal to replace the whole signer set and threshold. +/// +/// A rotation moves through two phases, tracked by `eta_ledger`: +/// +/// * **Gathering** (`eta_ledger == 0`): approvals are still being collected +/// from the *current* signer set, exactly like an upgrade proposal. It +/// must reach `threshold` within [`PROPOSAL_TTL_LEDGERS`] of +/// `proposed_at_ledger` or it expires. +/// * **Scheduled / timelocked** (`eta_ledger != 0`): threshold was reached +/// and `eta_ledger` is the earliest ledger at which the swap may be +/// executed. This mandatory delay is the minority-protection guarantee — +/// see the module-level "Signer rotation" notes. +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct PendingRotation { + /// The signer set that will replace the current one on execution. + pub new_signers: Vec
, + /// The threshold that will apply to `new_signers` after execution. + pub new_threshold: u32, + /// Current signers who have approved *this* rotation. Gated by the + /// *current* threshold, not `new_threshold` — the old set authorizes + /// its own replacement. + pub approvals: Vec
, + /// Ledger at which the rotation was (most recently) proposed. Bounds the + /// approval-gathering phase via [`PROPOSAL_TTL_LEDGERS`]. + pub proposed_at_ledger: u32, + /// `0` while approvals are still being gathered; once threshold is + /// reached this becomes `now + `[`ROTATION_TIMELOCK_LEDGERS`], the + /// earliest ledger [`execute_signer_rotation`] will act on. + pub eta_ledger: u32, +} + /// Bundles the two `init_governance` arguments into one so host contracts' /// own `initialize` — already taking an admin and whatever else it needs — /// doesn't creep past clippy's argument-count lint by bolting on two more @@ -91,6 +170,30 @@ pub enum GovernanceError { ProposalExpired = 8, HashMismatch = 9, AlreadyMigrated = 10, + // --- Signer rotation (see the "Signer rotation" module notes) --- + /// No rotation is currently pending. + NoPendingRotation = 11, + /// The `new_signers`/`new_threshold` passed to `approve_signer_rotation` + /// don't match the pending rotation — the analogue of `HashMismatch`, + /// so a signer can't approve a set different from the one they were + /// shown. + RotationMismatch = 12, + /// `execute_signer_rotation` was called while the rotation is still + /// gathering approvals (threshold not yet reached, so no timelock has + /// started). + RotationNotReady = 13, + /// `execute_signer_rotation` was called after threshold but before the + /// timelock (`eta_ledger`) elapsed. + RotationTimelockActive = 14, + /// The rotation timed out — either it never reached threshold within + /// the approval window, or it was scheduled but not executed before its + /// execution window closed. + RotationExpired = 15, + /// A rotation is already live (still gathering approvals, or scheduled + /// and inside its execution window). Only one rotation may be in flight + /// at a time — this is what stops a lone signer from resetting a + /// rotation's approvals or displacing a scheduled one. + RotationInProgress = 16, } /// How long a proposal stays open for approval before it must be @@ -99,19 +202,31 @@ pub enum GovernanceError { /// over that kind of timescale. pub const PROPOSAL_TTL_LEDGERS: u32 = 120_960; -/// One-time setup, called from the host contract's own `initialize`. The -/// signer set and threshold are immutable after this — there's no -/// signer-rotation flow in this version. Changing signers safely (without -/// letting a majority silently lock out a minority, or vice versa) is its -/// own governance problem; shipping it here would roughly double this -/// crate's attack surface for a capability this issue didn't ask for. -/// Left as a deliberate follow-up. -pub fn init_governance(env: &Env, init: GovernanceInit) -> Result<(), GovernanceError> { - let GovernanceInit { signers, threshold } = init; +/// Mandatory delay between a signer rotation reaching threshold and it +/// becoming executable. ~3 days at 5s/ledger. +/// +/// This is the core minority-protection mechanism (see the "Signer +/// rotation" module notes): once the current threshold agrees to a new +/// signer set, the change does not take effect immediately. Every current +/// signer — including any minority about to be removed — gets a guaranteed, +/// publicly visible on-chain window in which the pending rotation can be +/// observed and reacted to before it lands. It cannot prevent a determined +/// threshold from eventually rotating (that power is inherent to any M-of-N +/// set, which can already swap all the code), but it makes a *silent, +/// instant* lockout impossible. +/// +/// Chosen long enough to be noticed and acted on, short enough to remain +/// usable when responding to a genuinely lost or compromised key. +pub const ROTATION_TIMELOCK_LEDGERS: u32 = 51_840; - if env.storage().instance().has(&GovernanceDataKey::Signers) { - return Err(GovernanceError::AlreadyInitialized); - } +/// Validates a `(signers, threshold)` pair for both initial setup and +/// rotation. An empty signer set is rejected here too: with zero signers the +/// only valid threshold would be zero, which the `threshold == 0` check +/// already forbids, so an empty set can never pass. Enforcing these same +/// rules on rotation is what stops a rotation from bricking governance (a +/// set no one can meet) or unlocking it (threshold below what the set can +/// satisfy). +fn validate_signer_set(signers: &Vec
, threshold: u32) -> Result<(), GovernanceError> { if threshold == 0 || threshold > signers.len() { return Err(GovernanceError::InvalidThreshold); } @@ -122,6 +237,20 @@ pub fn init_governance(env: &Env, init: GovernanceInit) -> Result<(), Governance } } } + Ok(()) +} + +/// One-time setup, called from the host contract's own `initialize`. The +/// signer set and threshold are configured here and can afterwards only be +/// changed through the timelocked rotation flow +/// ([`propose_signer_rotation`] and friends) — never rewritten directly. +pub fn init_governance(env: &Env, init: GovernanceInit) -> Result<(), GovernanceError> { + let GovernanceInit { signers, threshold } = init; + + if env.storage().instance().has(&GovernanceDataKey::Signers) { + return Err(GovernanceError::AlreadyInitialized); + } + validate_signer_set(&signers, threshold)?; env.storage() .instance() @@ -312,5 +441,281 @@ pub fn mark_migrated(env: &Env, to_version: u32) -> Result<(), GovernanceError> Ok(()) } +// --------------------------------------------------------------------------- +// Signer rotation +// --------------------------------------------------------------------------- + +/// Emitted when a new rotation is opened. Topics: `["gov_rot", "proposed", +/// proposer]`; data carries the threshold the new set will run at. +#[contractevent(topics = ["gov_rot", "proposed"])] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct RotationProposed { + #[topic] + pub proposer: Address, + pub new_threshold: u32, +} + +/// Emitted on each approval of the pending rotation. `approvals` is the +/// running count after this approval. Topics: `["gov_rot", "approved", +/// approver]`. +#[contractevent(topics = ["gov_rot", "approved"])] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct RotationApproved { + #[topic] + pub approver: Address, + pub approvals: u32, +} + +/// Emitted the moment a rotation reaches threshold and its timelock starts. +/// `eta_ledger` is the earliest ledger it can be executed at. Topics: +/// `["gov_rot", "scheduled"]`. +#[contractevent(topics = ["gov_rot", "scheduled"])] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct RotationScheduled { + pub eta_ledger: u32, +} + +/// Emitted when a rotation is applied and the signer set is swapped. Topics: +/// `["gov_rot", "executed"]`; data carries the new set's size and threshold. +#[contractevent(topics = ["gov_rot", "executed"])] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct RotationExecuted { + pub new_signer_count: u32, + pub new_threshold: u32, +} + +/// A rotation is "live" — occupying the single rotation slot — while it is +/// still gathering approvals within its approval window, or scheduled and +/// still inside its post-timelock execution window. Once either window has +/// closed it is expired: it can no longer be acted on and may be replaced by +/// a fresh proposal. +fn rotation_is_live(pending: &PendingRotation, now: u32) -> bool { + if pending.eta_ledger == 0 { + // Gathering: live until the approval window closes. + now <= pending.proposed_at_ledger + PROPOSAL_TTL_LEDGERS + } else { + // Scheduled: live until the execution window (after the timelock) + // closes. It is still "live" during the timelock even though it + // isn't executable yet — the slot is taken. + now <= pending.eta_ledger + PROPOSAL_TTL_LEDGERS + } +} + +/// Opens a proposal to replace the *entire* signer set and threshold with +/// `new_signers` / `new_threshold`, authorized by the current signer set at +/// the current threshold. The proposer's approval counts immediately. +/// +/// Only one rotation may be in flight at a time. Unlike [`propose_upgrade`], +/// this does **not** silently discard an existing live proposal: if a +/// rotation is still gathering approvals or is scheduled, this returns +/// [`GovernanceError::RotationInProgress`]. That single-slot rule is +/// deliberate and load-bearing for the majority-protection property — if a +/// fresh proposal reset the approval count, a lone signer could call this +/// repeatedly to stop any rotation from ever reaching threshold, and if it +/// could displace a *scheduled* rotation, a signer about to be removed could +/// veto their own removal. An expired rotation (either window closed) is not +/// live and is cleared and replaced here. +/// +/// Returns `Ok(true)` if this call alone reached threshold (a 1-of-N guard), +/// in which case the rotation is now *scheduled* behind the timelock — it is +/// still not applied until [`execute_signer_rotation`] is called after +/// [`ROTATION_TIMELOCK_LEDGERS`] have passed. +pub fn propose_signer_rotation( + env: &Env, + proposer: Address, + new_signers: Vec
, + new_threshold: u32, +) -> Result { + require_signer(env, &proposer)?; + validate_signer_set(&new_signers, new_threshold)?; + + let now = env.ledger().sequence(); + if let Some(existing) = get_pending_rotation(env) { + if rotation_is_live(&existing, now) { + return Err(GovernanceError::RotationInProgress); + } + // Otherwise it's expired — fall through and overwrite it. + } + + let mut approvals = Vec::new(env); + approvals.push_back(proposer.clone()); + + let threshold = get_threshold(env); + let scheduled = approvals.len() >= threshold; + let eta_ledger = if scheduled { + now + ROTATION_TIMELOCK_LEDGERS + } else { + 0 + }; + + let pending = PendingRotation { + new_signers, + new_threshold, + approvals, + proposed_at_ledger: now, + eta_ledger, + }; + env.storage() + .instance() + .set(&GovernanceDataKey::PendingRotation, &pending); + + RotationProposed { + proposer, + new_threshold, + } + .publish(env); + if scheduled { + RotationScheduled { eta_ledger }.publish(env); + } + Ok(scheduled) +} + +/// Approves the pending rotation, which must still be gathering approvals and +/// must match the `new_signers` / `new_threshold` the caller passes — the +/// same-args check is the rotation analogue of [`approve_upgrade`]'s hash +/// match, so a signer can't be tricked into approving a different set than +/// the one they reviewed. +/// +/// Returns `Ok(true)` when this approval brings the count to the current +/// threshold, which *schedules* the rotation (starts the timelock) rather +/// than applying it. Approving a rotation that is already scheduled returns +/// [`GovernanceError::RotationTimelockActive`] — the approval stage is over. +pub fn approve_signer_rotation( + env: &Env, + approver: Address, + new_signers: Vec
, + new_threshold: u32, +) -> Result { + require_signer(env, &approver)?; + + let mut pending: PendingRotation = env + .storage() + .instance() + .get(&GovernanceDataKey::PendingRotation) + .ok_or(GovernanceError::NoPendingRotation)?; + + let now = env.ledger().sequence(); + + if pending.eta_ledger != 0 { + // Already past threshold and into its timelock — no more approvals. + return Err(GovernanceError::RotationTimelockActive); + } + if now > pending.proposed_at_ledger + PROPOSAL_TTL_LEDGERS { + env.storage() + .instance() + .remove(&GovernanceDataKey::PendingRotation); + return Err(GovernanceError::RotationExpired); + } + if pending.new_signers != new_signers || pending.new_threshold != new_threshold { + return Err(GovernanceError::RotationMismatch); + } + if pending.approvals.contains(&approver) { + return Err(GovernanceError::AlreadyApproved); + } + + pending.approvals.push_back(approver.clone()); + let approvals_len = pending.approvals.len(); + + let threshold = get_threshold(env); + let scheduled = approvals_len >= threshold; + if scheduled { + pending.eta_ledger = now + ROTATION_TIMELOCK_LEDGERS; + } + env.storage() + .instance() + .set(&GovernanceDataKey::PendingRotation, &pending); + + RotationApproved { + approver, + approvals: approvals_len, + } + .publish(env); + if scheduled { + RotationScheduled { + eta_ledger: pending.eta_ledger, + } + .publish(env); + } + Ok(scheduled) +} + +/// Applies a rotation that has reached threshold and cleared its timelock, +/// swapping in the new signer set and threshold. Any current signer may +/// trigger it — executing an already-ratified decision needs no fresh +/// consensus, exactly like whichever approval crosses an upgrade's +/// threshold triggers the swap. +/// +/// Fails if there's no pending rotation ([`GovernanceError::NoPendingRotation`]), +/// it's still gathering approvals ([`GovernanceError::RotationNotReady`]), +/// the timelock hasn't elapsed ([`GovernanceError::RotationTimelockActive`]), +/// or the execution window has closed +/// ([`GovernanceError::RotationExpired`], which also clears the stale entry). +/// +/// On success it also clears any pending upgrade: that upgrade's approvals +/// came from the *old* signer set and must not carry into the new one. +pub fn execute_signer_rotation(env: &Env, caller: Address) -> Result<(), GovernanceError> { + require_signer(env, &caller)?; + + let pending: PendingRotation = env + .storage() + .instance() + .get(&GovernanceDataKey::PendingRotation) + .ok_or(GovernanceError::NoPendingRotation)?; + + let now = env.ledger().sequence(); + + if pending.eta_ledger == 0 { + // Never reached threshold. Surface expiry distinctly from "not yet". + if now > pending.proposed_at_ledger + PROPOSAL_TTL_LEDGERS { + env.storage() + .instance() + .remove(&GovernanceDataKey::PendingRotation); + return Err(GovernanceError::RotationExpired); + } + return Err(GovernanceError::RotationNotReady); + } + if now < pending.eta_ledger { + return Err(GovernanceError::RotationTimelockActive); + } + if now > pending.eta_ledger + PROPOSAL_TTL_LEDGERS { + env.storage() + .instance() + .remove(&GovernanceDataKey::PendingRotation); + return Err(GovernanceError::RotationExpired); + } + + // Apply the new set. It was validated at propose time, but the signer + // set is small and re-validating here is cheap insurance against any + // future path that could stage an invalid set. + validate_signer_set(&pending.new_signers, pending.new_threshold)?; + env.storage() + .instance() + .set(&GovernanceDataKey::Signers, &pending.new_signers); + env.storage() + .instance() + .set(&GovernanceDataKey::Threshold, &pending.new_threshold); + + env.storage() + .instance() + .remove(&GovernanceDataKey::PendingRotation); + // Any in-flight upgrade was approved by the old set — drop it. + env.storage() + .instance() + .remove(&GovernanceDataKey::PendingUpgrade); + + RotationExecuted { + new_signer_count: pending.new_signers.len(), + new_threshold: pending.new_threshold, + } + .publish(env); + Ok(()) +} + +pub fn get_pending_rotation(env: &Env) -> Option { + env.storage() + .instance() + .get(&GovernanceDataKey::PendingRotation) +} + #[cfg(test)] mod test; diff --git a/soroban-contracts/contracts/governance-guard/src/test.rs b/soroban-contracts/contracts/governance-guard/src/test.rs index f838539..2df4b4e 100644 --- a/soroban-contracts/contracts/governance-guard/src/test.rs +++ b/soroban-contracts/contracts/governance-guard/src/test.rs @@ -1,11 +1,14 @@ use soroban_sdk::{ - contract, testutils::Address as _, testutils::Ledger, Address, BytesN, Env, Vec, + contract, testutils::Address as _, testutils::Events as _, testutils::Ledger, Address, BytesN, + Env, Vec, }; use crate::{ - approve_upgrade, cancel_upgrade, current_storage_version, get_pending_upgrade, get_signers, - get_threshold, init_governance, mark_migrated, propose_upgrade, require_signer, - GovernanceError, GovernanceInit, PendingUpgrade, PROPOSAL_TTL_LEDGERS, + approve_signer_rotation, approve_upgrade, cancel_upgrade, current_storage_version, + execute_signer_rotation, get_pending_rotation, get_pending_upgrade, get_signers, get_threshold, + init_governance, mark_migrated, propose_signer_rotation, propose_upgrade, require_signer, + GovernanceError, GovernanceInit, PendingRotation, PendingUpgrade, PROPOSAL_TTL_LEDGERS, + ROTATION_TIMELOCK_LEDGERS, }; // This crate has no #[contract] of its own — it's a library other contracts @@ -90,6 +93,45 @@ fn make_signers(env: &Env, n: u32) -> Vec
{ signers } +fn propose_rotation( + env: &Env, + id: &Address, + proposer: Address, + new_signers: Vec
, + new_threshold: u32, +) -> Result { + env.as_contract(id, || { + propose_signer_rotation(env, proposer, new_signers, new_threshold) + }) +} + +fn approve_rotation( + env: &Env, + id: &Address, + approver: Address, + new_signers: Vec
, + new_threshold: u32, +) -> Result { + env.as_contract(id, || { + approve_signer_rotation(env, approver, new_signers, new_threshold) + }) +} + +fn execute_rotation(env: &Env, id: &Address, caller: Address) -> Result<(), GovernanceError> { + env.as_contract(id, || execute_signer_rotation(env, caller)) +} + +fn pending_rotation(env: &Env, id: &Address) -> Option { + env.as_contract(id, || get_pending_rotation(env)) +} + +/// Advance the ledger sequence by `by` ledgers. +fn advance(env: &Env, by: u32) { + env.ledger().with_mut(|l| { + l.sequence_number += by; + }); +} + #[test] fn init_stores_signers_and_threshold() { let env = Env::default(); @@ -388,3 +430,416 @@ fn mark_migrated_rejects_same_or_earlier_version() { // number than what's already recorded. assert_eq!(version(&env, &id), 2); } + +// --------------------------------------------------------------------------- +// Signer rotation +// --------------------------------------------------------------------------- + +#[test] +fn rotation_by_non_signer_fails() { + let env = Env::default(); + env.mock_all_auths(); + let id = new_host(&env); + let signers = make_signers(&env, 3); + init(&env, &id, signers, 2).unwrap(); + + let outsider = Address::generate(&env); + let new_set = make_signers(&env, 2); + assert_eq!( + propose_rotation(&env, &id, outsider, new_set, 1), + Err(GovernanceError::NotASigner) + ); +} + +#[test] +fn rotation_rejects_invalid_new_set() { + let env = Env::default(); + env.mock_all_auths(); + let id = new_host(&env); + let signers = make_signers(&env, 3); + init(&env, &id, signers.clone(), 2).unwrap(); + let proposer = signers.get_unchecked(0); + let new_set = make_signers(&env, 2); + + // threshold 0 + assert_eq!( + propose_rotation(&env, &id, proposer.clone(), new_set.clone(), 0), + Err(GovernanceError::InvalidThreshold) + ); + // threshold > new set size + assert_eq!( + propose_rotation(&env, &id, proposer.clone(), new_set.clone(), 3), + Err(GovernanceError::InvalidThreshold) + ); + // empty set can never satisfy threshold >= 1 + assert_eq!( + propose_rotation(&env, &id, proposer.clone(), Vec::new(&env), 1), + Err(GovernanceError::InvalidThreshold) + ); + // duplicate in new set + let mut dup = Vec::new(&env); + let a = Address::generate(&env); + dup.push_back(a.clone()); + dup.push_back(a); + assert_eq!( + propose_rotation(&env, &id, proposer, dup, 1), + Err(GovernanceError::DuplicateSigner) + ); + + // None of the rejected attempts left a rotation behind. + assert!(pending_rotation(&env, &id).is_none()); +} + +#[test] +fn single_signer_rotation_schedules_then_executes_after_timelock() { + let env = Env::default(); + env.mock_all_auths(); + let id = new_host(&env); + let signers = make_signers(&env, 1); + init(&env, &id, signers.clone(), 1).unwrap(); + + // 1-of-1: the proposer's own approval reaches threshold, so the rotation + // is scheduled immediately — but still timelocked, never applied inline. + let new_set = make_signers(&env, 2); + let scheduled = + propose_rotation(&env, &id, signers.get_unchecked(0), new_set.clone(), 2).unwrap(); + assert!(scheduled); + + let p = pending_rotation(&env, &id).unwrap(); + assert_ne!(p.eta_ledger, 0); + // Signer set is unchanged until execution. + assert_eq!(signers_of(&env, &id), signers); + + // Executing before the timelock elapses is refused. + assert_eq!( + execute_rotation(&env, &id, signers.get_unchecked(0)), + Err(GovernanceError::RotationTimelockActive) + ); + + advance(&env, ROTATION_TIMELOCK_LEDGERS); + execute_rotation(&env, &id, signers.get_unchecked(0)).unwrap(); + + assert_eq!(signers_of(&env, &id), new_set); + assert_eq!(threshold_of(&env, &id), 2); + assert!(pending_rotation(&env, &id).is_none()); +} + +#[test] +fn multi_sig_rotation_full_flow() { + let env = Env::default(); + env.mock_all_auths(); + let id = new_host(&env); + let signers = make_signers(&env, 3); + init(&env, &id, signers.clone(), 2).unwrap(); + let new_set = make_signers(&env, 2); + + // First approval (the proposer) does not reach the 2-of-3 threshold. + let scheduled = + propose_rotation(&env, &id, signers.get_unchecked(0), new_set.clone(), 1).unwrap(); + assert!(!scheduled); + let p = pending_rotation(&env, &id).unwrap(); + assert_eq!(p.approvals.len(), 1); + assert_eq!(p.eta_ledger, 0); + + // Not executable while still gathering approvals. + assert_eq!( + execute_rotation(&env, &id, signers.get_unchecked(0)), + Err(GovernanceError::RotationNotReady) + ); + + // Second distinct approval crosses threshold and schedules it. + let scheduled = + approve_rotation(&env, &id, signers.get_unchecked(1), new_set.clone(), 1).unwrap(); + assert!(scheduled); + let p = pending_rotation(&env, &id).unwrap(); + assert_ne!(p.eta_ledger, 0); + + advance(&env, ROTATION_TIMELOCK_LEDGERS); + // Any current signer can execute the ratified rotation — even one who + // never approved it. + execute_rotation(&env, &id, signers.get_unchecked(2)).unwrap(); + + assert_eq!(signers_of(&env, &id), new_set); + assert_eq!(threshold_of(&env, &id), 1); +} + +#[test] +fn approve_rotation_mismatched_set_is_rejected() { + let env = Env::default(); + env.mock_all_auths(); + let id = new_host(&env); + let signers = make_signers(&env, 3); + init(&env, &id, signers.clone(), 2).unwrap(); + let new_set = make_signers(&env, 2); + propose_rotation(&env, &id, signers.get_unchecked(0), new_set, 1).unwrap(); + + // Approving a *different* set than the one pending must be rejected, so + // an approver can't be redirected onto a set they didn't review. + let other_set = make_signers(&env, 2); + assert_eq!( + approve_rotation(&env, &id, signers.get_unchecked(1), other_set, 1), + Err(GovernanceError::RotationMismatch) + ); +} + +#[test] +fn approve_rotation_wrong_threshold_is_rejected() { + let env = Env::default(); + env.mock_all_auths(); + let id = new_host(&env); + let signers = make_signers(&env, 3); + init(&env, &id, signers.clone(), 2).unwrap(); + let new_set = make_signers(&env, 2); + propose_rotation(&env, &id, signers.get_unchecked(0), new_set.clone(), 1).unwrap(); + + // Same set, different threshold — still a mismatch. + assert_eq!( + approve_rotation(&env, &id, signers.get_unchecked(1), new_set, 2), + Err(GovernanceError::RotationMismatch) + ); +} + +#[test] +fn approve_rotation_by_non_signer_or_twice_fails() { + let env = Env::default(); + env.mock_all_auths(); + let id = new_host(&env); + let signers = make_signers(&env, 3); + init(&env, &id, signers.clone(), 3).unwrap(); + let new_set = make_signers(&env, 2); + propose_rotation(&env, &id, signers.get_unchecked(0), new_set.clone(), 1).unwrap(); + + let outsider = Address::generate(&env); + assert_eq!( + approve_rotation(&env, &id, outsider, new_set.clone(), 1), + Err(GovernanceError::NotASigner) + ); + + // Proposer already approved; approving again is rejected. + assert_eq!( + approve_rotation(&env, &id, signers.get_unchecked(0), new_set, 1), + Err(GovernanceError::AlreadyApproved) + ); +} + +#[test] +fn approving_a_scheduled_rotation_is_rejected() { + let env = Env::default(); + env.mock_all_auths(); + let id = new_host(&env); + let signers = make_signers(&env, 3); + init(&env, &id, signers.clone(), 2).unwrap(); + let new_set = make_signers(&env, 2); + propose_rotation(&env, &id, signers.get_unchecked(0), new_set.clone(), 1).unwrap(); + approve_rotation(&env, &id, signers.get_unchecked(1), new_set.clone(), 1).unwrap(); + + // Threshold already reached (scheduled) — the approval stage is over. + assert_eq!( + approve_rotation(&env, &id, signers.get_unchecked(2), new_set, 1), + Err(GovernanceError::RotationTimelockActive) + ); +} + +#[test] +fn lone_signer_cannot_reset_a_gathering_rotation() { + // Majority-protection: a single signer must not be able to blow away an + // in-progress rotation's approvals by proposing a competing one. If they + // could, they could stop any rotation from ever reaching threshold. + let env = Env::default(); + env.mock_all_auths(); + let id = new_host(&env); + let signers = make_signers(&env, 3); + init(&env, &id, signers.clone(), 2).unwrap(); + let wanted = make_signers(&env, 2); + propose_rotation(&env, &id, signers.get_unchecked(0), wanted.clone(), 1).unwrap(); + + // A different signer trying to start a competing rotation is refused + // while one is live — the original proposal's approvals are untouched. + let competing = make_signers(&env, 2); + assert_eq!( + propose_rotation(&env, &id, signers.get_unchecked(2), competing, 1), + Err(GovernanceError::RotationInProgress) + ); + let p = pending_rotation(&env, &id).unwrap(); + assert_eq!(p.new_signers, wanted); + assert_eq!(p.approvals.len(), 1); +} + +#[test] +fn lone_signer_cannot_veto_a_scheduled_rotation() { + // The other half of majority-protection: once a rotation is scheduled, a + // single signer (e.g. one about to be removed) cannot displace it by + // proposing something else. + let env = Env::default(); + env.mock_all_auths(); + let id = new_host(&env); + let signers = make_signers(&env, 3); + init(&env, &id, signers.clone(), 2).unwrap(); + let new_set = make_signers(&env, 2); + propose_rotation(&env, &id, signers.get_unchecked(0), new_set.clone(), 1).unwrap(); + approve_rotation(&env, &id, signers.get_unchecked(1), new_set.clone(), 1).unwrap(); + + // Scheduled now. A signer proposing a replacement is refused. + let escape = make_signers(&env, 2); + assert_eq!( + propose_rotation(&env, &id, signers.get_unchecked(2), escape, 1), + Err(GovernanceError::RotationInProgress) + ); + + // The original scheduled rotation still stands and executes on schedule. + advance(&env, ROTATION_TIMELOCK_LEDGERS); + execute_rotation(&env, &id, signers.get_unchecked(0)).unwrap(); + assert_eq!(signers_of(&env, &id), new_set); +} + +#[test] +fn gathering_rotation_expires_and_frees_the_slot() { + let env = Env::default(); + env.mock_all_auths(); + let id = new_host(&env); + let signers = make_signers(&env, 3); + init(&env, &id, signers.clone(), 2).unwrap(); + let stale = make_signers(&env, 2); + propose_rotation(&env, &id, signers.get_unchecked(0), stale.clone(), 1).unwrap(); + + advance(&env, PROPOSAL_TTL_LEDGERS + 1); + + // Approving an expired gathering rotation reports expiry and clears it. + assert_eq!( + approve_rotation(&env, &id, signers.get_unchecked(1), stale, 1), + Err(GovernanceError::RotationExpired) + ); + assert!(pending_rotation(&env, &id).is_none()); + + // Slot is free again: a fresh proposal succeeds. + let fresh = make_signers(&env, 2); + let scheduled = + propose_rotation(&env, &id, signers.get_unchecked(0), fresh.clone(), 1).unwrap(); + assert!(!scheduled); + assert_eq!(pending_rotation(&env, &id).unwrap().new_signers, fresh); +} + +#[test] +fn expired_gathering_rotation_can_be_replaced_by_propose() { + let env = Env::default(); + env.mock_all_auths(); + let id = new_host(&env); + let signers = make_signers(&env, 3); + init(&env, &id, signers.clone(), 2).unwrap(); + let stale = make_signers(&env, 2); + propose_rotation(&env, &id, signers.get_unchecked(0), stale, 1).unwrap(); + + advance(&env, PROPOSAL_TTL_LEDGERS + 1); + + // A new propose directly overwrites the expired (non-live) rotation + // without needing anyone to touch the old one first. + let fresh = make_signers(&env, 2); + propose_rotation(&env, &id, signers.get_unchecked(1), fresh.clone(), 1).unwrap(); + let p = pending_rotation(&env, &id).unwrap(); + assert_eq!(p.new_signers, fresh); + assert_eq!(p.approvals.get_unchecked(0), signers.get_unchecked(1)); +} + +#[test] +fn scheduled_rotation_execution_window_expires() { + let env = Env::default(); + env.mock_all_auths(); + let id = new_host(&env); + let signers = make_signers(&env, 1); + init(&env, &id, signers.clone(), 1).unwrap(); + let new_set = make_signers(&env, 2); + propose_rotation(&env, &id, signers.get_unchecked(0), new_set, 2).unwrap(); + + // Past the timelock *and* the execution window that follows it. + advance(&env, ROTATION_TIMELOCK_LEDGERS + PROPOSAL_TTL_LEDGERS + 1); + assert_eq!( + execute_rotation(&env, &id, signers.get_unchecked(0)), + Err(GovernanceError::RotationExpired) + ); + assert!(pending_rotation(&env, &id).is_none()); + // Original signer set is intact — the stale rotation never applied. + assert_eq!(signers_of(&env, &id), signers); +} + +#[test] +fn execute_with_no_pending_rotation_fails() { + let env = Env::default(); + env.mock_all_auths(); + let id = new_host(&env); + let signers = make_signers(&env, 2); + init(&env, &id, signers.clone(), 1).unwrap(); + + assert_eq!( + execute_rotation(&env, &id, signers.get_unchecked(0)), + Err(GovernanceError::NoPendingRotation) + ); +} + +#[test] +fn executing_a_rotation_clears_a_pending_upgrade() { + // An upgrade approved under the old signer set must not survive a + // rotation — its approvals no longer represent the new set. + let env = Env::default(); + env.mock_all_auths(); + let id = new_host(&env); + let signers = make_signers(&env, 3); + init(&env, &id, signers.clone(), 2).unwrap(); + + // Stage a half-approved upgrade (1 of 2). + propose(&env, &id, signers.get_unchecked(0), hash(&env, 1)).unwrap(); + assert!(pending(&env, &id).is_some()); + + // Rotate to a new set and execute. + let new_set = make_signers(&env, 2); + propose_rotation(&env, &id, signers.get_unchecked(0), new_set.clone(), 1).unwrap(); + approve_rotation(&env, &id, signers.get_unchecked(1), new_set, 1).unwrap(); + advance(&env, ROTATION_TIMELOCK_LEDGERS); + execute_rotation(&env, &id, signers.get_unchecked(0)).unwrap(); + + // The stale pending upgrade is gone. + assert!(pending(&env, &id).is_none()); +} + +#[test] +fn after_rotation_only_new_signers_govern() { + let env = Env::default(); + env.mock_all_auths(); + let id = new_host(&env); + let signers = make_signers(&env, 3); + init(&env, &id, signers.clone(), 2).unwrap(); + let new_set = make_signers(&env, 2); + + propose_rotation(&env, &id, signers.get_unchecked(0), new_set.clone(), 1).unwrap(); + approve_rotation(&env, &id, signers.get_unchecked(1), new_set.clone(), 1).unwrap(); + advance(&env, ROTATION_TIMELOCK_LEDGERS); + execute_rotation(&env, &id, signers.get_unchecked(0)).unwrap(); + + // An old signer who is not in the new set can no longer act. + let removed = signers.get_unchecked(2); + assert_eq!( + check_signer(&env, &id, removed), + Err(GovernanceError::NotASigner) + ); + // A new signer can. + assert!(check_signer(&env, &id, new_set.get_unchecked(0)).is_ok()); +} + +#[test] +fn rotation_emits_lifecycle_events() { + let env = Env::default(); + env.mock_all_auths(); + let id = new_host(&env); + let signers = make_signers(&env, 1); + init(&env, &id, signers.clone(), 1).unwrap(); + let new_set = make_signers(&env, 2); + + propose_rotation(&env, &id, signers.get_unchecked(0), new_set, 2).unwrap(); + // A 1-of-1 propose reaches threshold, so it emits both "proposed" and + // "scheduled". + assert!(env.events().all().events().len() >= 2); + + advance(&env, ROTATION_TIMELOCK_LEDGERS); + execute_rotation(&env, &id, signers.get_unchecked(0)).unwrap(); + // Execution emits "executed". + assert!(!env.events().all().events().is_empty()); +} diff --git a/soroban-contracts/contracts/loyalty-emissions/src/lib.rs b/soroban-contracts/contracts/loyalty-emissions/src/lib.rs index 3cef0c7..aa6ee3b 100644 --- a/soroban-contracts/contracts/loyalty-emissions/src/lib.rs +++ b/soroban-contracts/contracts/loyalty-emissions/src/lib.rs @@ -46,7 +46,7 @@ use soroban_sdk::{ }; use guildworkman_governance_guard as governance; -pub use guildworkman_governance_guard::PendingUpgrade; +pub use guildworkman_governance_guard::{PendingRotation, PendingUpgrade}; /// Bump when this contract's storage layout actually changes shape and /// needs a real transformation in `migrate`. There's no such change yet. @@ -144,6 +144,13 @@ pub enum Error { HashMismatch = 21, AlreadyMigrated = 22, NothingToMigrate = 23, + // --- Signer rotation (see guildworkman-governance-guard) --- + NoPendingRotation = 24, + RotationMismatch = 25, + RotationNotReady = 26, + RotationTimelockActive = 27, + RotationExpired = 28, + RotationInProgress = 29, } impl From for Error { @@ -159,6 +166,12 @@ impl From for Error { governance::GovernanceError::ProposalExpired => Error::ProposalExpired, governance::GovernanceError::HashMismatch => Error::HashMismatch, governance::GovernanceError::AlreadyMigrated => Error::AlreadyMigrated, + governance::GovernanceError::NoPendingRotation => Error::NoPendingRotation, + governance::GovernanceError::RotationMismatch => Error::RotationMismatch, + governance::GovernanceError::RotationNotReady => Error::RotationNotReady, + governance::GovernanceError::RotationTimelockActive => Error::RotationTimelockActive, + governance::GovernanceError::RotationExpired => Error::RotationExpired, + governance::GovernanceError::RotationInProgress => Error::RotationInProgress, } } } @@ -235,6 +248,45 @@ impl LoyaltyEmissions { governance::cancel_upgrade(&env, caller).map_err(Into::into) } + // ----- Signer rotation ----- + + /// Opens a timelocked proposal to rotate the governance signer set and + /// threshold, authorized by the current signers at the current + /// threshold. Reaching threshold only *schedules* the rotation; + /// `execute_signer_rotation` applies it after the timelock. Returns + /// `true` if this call reached threshold. + pub fn propose_signer_rotation( + env: Env, + proposer: Address, + new_signers: Vec
, + new_threshold: u32, + ) -> Result { + governance::propose_signer_rotation(&env, proposer, new_signers, new_threshold) + .map_err(Into::into) + } + + /// Approves the pending signer rotation. Returns `true` when this + /// approval reaches threshold and schedules the rotation. + pub fn approve_signer_rotation( + env: Env, + approver: Address, + new_signers: Vec
, + new_threshold: u32, + ) -> Result { + governance::approve_signer_rotation(&env, approver, new_signers, new_threshold) + .map_err(Into::into) + } + + /// Applies a scheduled rotation once its timelock has elapsed. Any + /// current signer may trigger it. + pub fn execute_signer_rotation(env: Env, caller: Address) -> Result<(), Error> { + governance::execute_signer_rotation(&env, caller).map_err(Into::into) + } + + pub fn get_pending_rotation(env: Env) -> Option { + governance::get_pending_rotation(&env) + } + pub fn migrate(env: Env, signer: Address) -> Result<(), Error> { governance::require_signer(&env, &signer)?; if governance::current_storage_version(&env) >= CURRENT_STORAGE_VERSION { diff --git a/soroban-contracts/contracts/loyalty-token/src/lib.rs b/soroban-contracts/contracts/loyalty-token/src/lib.rs index d40c321..d0bbbbd 100644 --- a/soroban-contracts/contracts/loyalty-token/src/lib.rs +++ b/soroban-contracts/contracts/loyalty-token/src/lib.rs @@ -11,7 +11,7 @@ use soroban_sdk::{ }; use guildworkman_governance_guard as governance; -pub use guildworkman_governance_guard::PendingUpgrade; +pub use guildworkman_governance_guard::{PendingRotation, PendingUpgrade}; /// Bump when this contract's storage layout actually changes shape and /// needs a real transformation in `migrate`. There's no such change yet. @@ -62,6 +62,13 @@ pub enum Error { HashMismatch = 15, AlreadyMigrated = 16, NothingToMigrate = 17, + // --- Signer rotation (see guildworkman-governance-guard) --- + NoPendingRotation = 18, + RotationMismatch = 19, + RotationNotReady = 20, + RotationTimelockActive = 21, + RotationExpired = 22, + RotationInProgress = 23, } impl From for Error { @@ -77,6 +84,12 @@ impl From for Error { governance::GovernanceError::ProposalExpired => Error::ProposalExpired, governance::GovernanceError::HashMismatch => Error::HashMismatch, governance::GovernanceError::AlreadyMigrated => Error::AlreadyMigrated, + governance::GovernanceError::NoPendingRotation => Error::NoPendingRotation, + governance::GovernanceError::RotationMismatch => Error::RotationMismatch, + governance::GovernanceError::RotationNotReady => Error::RotationNotReady, + governance::GovernanceError::RotationTimelockActive => Error::RotationTimelockActive, + governance::GovernanceError::RotationExpired => Error::RotationExpired, + governance::GovernanceError::RotationInProgress => Error::RotationInProgress, } } } @@ -156,6 +169,45 @@ impl LoyaltyToken { governance::cancel_upgrade(&env, caller).map_err(Into::into) } + // ----- Signer rotation ----- + + /// Opens a timelocked proposal to rotate the governance signer set and + /// threshold, authorized by the current signers at the current + /// threshold. Reaching threshold only *schedules* the rotation; + /// `execute_signer_rotation` applies it after the timelock. Returns + /// `true` if this call reached threshold. + pub fn propose_signer_rotation( + env: Env, + proposer: Address, + new_signers: Vec
, + new_threshold: u32, + ) -> Result { + governance::propose_signer_rotation(&env, proposer, new_signers, new_threshold) + .map_err(Into::into) + } + + /// Approves the pending signer rotation. Returns `true` when this + /// approval reaches threshold and schedules the rotation. + pub fn approve_signer_rotation( + env: Env, + approver: Address, + new_signers: Vec
, + new_threshold: u32, + ) -> Result { + governance::approve_signer_rotation(&env, approver, new_signers, new_threshold) + .map_err(Into::into) + } + + /// Applies a scheduled rotation once its timelock has elapsed. Any + /// current signer may trigger it. + pub fn execute_signer_rotation(env: Env, caller: Address) -> Result<(), Error> { + governance::execute_signer_rotation(&env, caller).map_err(Into::into) + } + + pub fn get_pending_rotation(env: Env) -> Option { + governance::get_pending_rotation(&env) + } + pub fn migrate(env: Env, signer: Address) -> Result<(), Error> { governance::require_signer(&env, &signer)?; if governance::current_storage_version(&env) >= CURRENT_STORAGE_VERSION { diff --git a/soroban-contracts/contracts/reputation/src/lib.rs b/soroban-contracts/contracts/reputation/src/lib.rs index 9c83043..d3505af 100644 --- a/soroban-contracts/contracts/reputation/src/lib.rs +++ b/soroban-contracts/contracts/reputation/src/lib.rs @@ -8,7 +8,7 @@ use soroban_sdk::{contract, contracterror, contractimpl, contracttype, Address, BytesN, Env, Vec}; use guildworkman_governance_guard as governance; -pub use guildworkman_governance_guard::PendingUpgrade; +pub use guildworkman_governance_guard::{PendingRotation, PendingUpgrade}; /// Bump when this contract's storage layout actually changes shape and /// needs a real transformation in `migrate`. There's no such change yet — @@ -127,6 +127,13 @@ pub enum Error { HashMismatch = 20, AlreadyMigrated = 21, NothingToMigrate = 22, + // --- Signer rotation (see guildworkman-governance-guard) --- + NoPendingRotation = 23, + RotationMismatch = 24, + RotationNotReady = 25, + RotationTimelockActive = 26, + RotationExpired = 27, + RotationInProgress = 28, } impl From for Error { @@ -142,6 +149,12 @@ impl From for Error { governance::GovernanceError::ProposalExpired => Error::ProposalExpired, governance::GovernanceError::HashMismatch => Error::HashMismatch, governance::GovernanceError::AlreadyMigrated => Error::AlreadyMigrated, + governance::GovernanceError::NoPendingRotation => Error::NoPendingRotation, + governance::GovernanceError::RotationMismatch => Error::RotationMismatch, + governance::GovernanceError::RotationNotReady => Error::RotationNotReady, + governance::GovernanceError::RotationTimelockActive => Error::RotationTimelockActive, + governance::GovernanceError::RotationExpired => Error::RotationExpired, + governance::GovernanceError::RotationInProgress => Error::RotationInProgress, } } } @@ -222,6 +235,45 @@ impl ReputationContract { governance::cancel_upgrade(&env, caller).map_err(Into::into) } + // ----- Signer rotation ----- + + /// Opens a proposal to rotate the governance signer set and threshold, + /// authorized by the current signers at the current threshold. The + /// change is timelocked: reaching threshold only *schedules* it — it is + /// applied by `execute_signer_rotation` after the delay. Returns `true` + /// if this call reached threshold (rotation now scheduled). + pub fn propose_signer_rotation( + env: Env, + proposer: Address, + new_signers: Vec
, + new_threshold: u32, + ) -> Result { + governance::propose_signer_rotation(&env, proposer, new_signers, new_threshold) + .map_err(Into::into) + } + + /// Approves the pending signer rotation. Returns `true` when this + /// approval reaches threshold and schedules the rotation. + pub fn approve_signer_rotation( + env: Env, + approver: Address, + new_signers: Vec
, + new_threshold: u32, + ) -> Result { + governance::approve_signer_rotation(&env, approver, new_signers, new_threshold) + .map_err(Into::into) + } + + /// Applies a scheduled rotation once its timelock has elapsed. Any + /// current signer may trigger it. + pub fn execute_signer_rotation(env: Env, caller: Address) -> Result<(), Error> { + governance::execute_signer_rotation(&env, caller).map_err(Into::into) + } + + pub fn get_pending_rotation(env: Env) -> Option { + governance::get_pending_rotation(&env) + } + /// Runs this code version's storage migration, if one is owed. Callable /// by any signer (not the full threshold) — the risky decision, which /// code to trust, was already gated by the upgrade's multi-sig