Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
96 changes: 96 additions & 0 deletions stellar/MULTISIG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <CONTRACT_ID> \
--source <SIGNER_IDENTITY> \
-- propose_rotate_signers \
--caller <SIGNER_G...> \
--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 <CONTRACT_ID> \
--source <SIGNER_IDENTITY> \
-- approve_rotate_signers \
--caller <SIGNER_G...>
```

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 <CONTRACT_ID> \
--source <SIGNER_IDENTITY> \
-- execute_rotate_signers \
--caller <SIGNER_G...>
```
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 <CONTRACT_ID> \
--source <SIGNER_IDENTITY> \
-- cancel_rotate_signers \
--caller <SIGNER_G...>
```

### Inspecting state

```bash
stellar contract invoke --network futurenet --id <CONTRACT_ID> --source <ANY_IDENTITY> -- signers
stellar contract invoke --network futurenet --id <CONTRACT_ID> --source <ANY_IDENTITY> -- threshold
stellar contract invoke --network futurenet --id <CONTRACT_ID> --source <ANY_IDENTITY> -- 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

```
Expand Down
223 changes: 223 additions & 0 deletions stellar/stealth-sender/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)]
Expand All @@ -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.
Expand All @@ -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
Expand Down Expand Up @@ -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<Address>,
threshold: u32,
) -> Result<(), SenderError> {
multisig::init(&env, signers, threshold)
}

/// Current governance signer set.
pub fn signers(env: Env) -> Vec<Address> {
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<RotationProposal> {
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<Address>,
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)]
Expand Down Expand Up @@ -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<Address>) {
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)));
}
}
Loading
Loading