diff --git a/campaign/src/admin.rs b/campaign/src/admin.rs new file mode 100644 index 00000000..b36bb875 --- /dev/null +++ b/campaign/src/admin.rs @@ -0,0 +1,255 @@ +//! Issue #92 – Timelock + multi-sig admin governance. +//! +//! Privileged operations (`upgrade`, `freeze`, `unfreeze`, `extend_deadline`) +//! previously required only `creator.require_auth()` — a single point of +//! compromise: one stolen key could instantly replace the contract WASM. +//! +//! This module introduces the propose → approve → execute flow modelled on +//! Compound / OpenZeppelin's `TimelockController`: +//! +//! - Any admin signer may **propose** an [`AdminAction`] with an +//! `execute_after` timestamp (the timelock, giving the community time to +//! detect a malicious upgrade before it lands on-chain). Proposing counts +//! as the proposer's approval. +//! - Other signers **approve** the action by id. +//! - Once the quorum is met **and** `execute_after` has passed, any signer +//! may **execute**. The action is deleted before its effect is applied, so +//! it can never be replayed. +//! +//! Quorum: `min(2, signer_count)`. With a multi-sig signer set (≥2 signers) +//! two approvals are required and the **direct** admin entrypoints are +//! disabled ([`Error::MultisigActive`]). With a single signer — the +//! backwards-compatible default of `[creator]` — the flow degrades to a +//! 1-of-1 quorum and the existing direct entrypoints keep working unchanged. + +use soroban_sdk::{Address, Bytes, Env, Map, Vec}; + +use crate::event; +use crate::storage::{ + get_admin_action, get_admin_signers_storage, get_campaign, increment_admin_action_count, + is_frozen, remove_admin_action, set_admin_action, set_admin_signers_storage, set_frozen, +}; +use crate::types::{ActionKind, AdminAction, Error}; + +/// The current admin signer set: the stored set if configured, otherwise the +/// backwards-compatible default `[creator]`. +/// +/// # Panics +/// - `Error::NotInitialized` if the campaign is not initialized +pub fn get_admin_signers(env: &Env) -> Vec
{ + if let Some(signers) = get_admin_signers_storage(env) { + return signers; + } + let campaign = get_campaign(env).unwrap_or_else(|| env.panic_with_error(Error::NotInitialized)); + let mut signers = Vec::new(env); + signers.push_back(campaign.creator); + signers +} + +/// Approvals required to execute an action: 2 with a multi-sig set, 1 with +/// the single-signer (legacy) configuration. +fn required_approvals(signer_count: u32) -> u32 { + if signer_count >= 2 { + 2 + } else { + 1 + } +} + +/// Panic with `Error::NotAdminSigner` unless `who` is in the signer set. +fn assert_signer(env: &Env, signers: &Vec, who: &Address) { + if !signers.contains(who) { + env.panic_with_error(Error::NotAdminSigner); + } +} + +/// Guard for the legacy direct entrypoints (`upgrade`, `freeze`, `unfreeze`, +/// `extend_deadline`): allowed only while a single signer is configured +/// (1-of-1 quorum). Returns that signer so the caller can `require_auth()` it. +/// +/// # Panics +/// - `Error::MultisigActive` if ≥2 signers are configured +pub fn require_direct_admin(env: &Env) -> Address { + let signers = get_admin_signers(env); + if signers.len() >= 2 { + env.panic_with_error(Error::MultisigActive); + } + signers.get(0).unwrap() +} + +/// Validate that `payload` matches the encoding its `kind` demands. +fn validate_payload(env: &Env, kind: &ActionKind, payload: &Bytes) { + let valid = match kind { + ActionKind::Upgrade => payload.len() == 32, + ActionKind::Freeze | ActionKind::Unfreeze => payload.is_empty(), + ActionKind::ExtendDeadline => payload.len() == 8, + }; + if !valid { + env.panic_with_error(Error::InvalidActionPayload); + } +} + +/// Propose an admin action. The proposer must be an admin signer and is +/// recorded as the first approval. Returns the new action's id. +/// +/// # Panics +/// - `Error::NotAdminSigner` if `proposer` is not in the signer set +/// - `Error::InvalidActionPayload` if the payload doesn't match the kind +/// - `Error::InvalidExecuteAfter` if `execute_after` is in the past +pub fn propose_admin_action( + env: &Env, + proposer: Address, + kind: ActionKind, + payload: Bytes, + execute_after: u64, +) -> u64 { + proposer.require_auth(); + + let signers = get_admin_signers(env); + assert_signer(env, &signers, &proposer); + validate_payload(env, &kind, &payload); + + if execute_after < env.ledger().timestamp() { + env.panic_with_error(Error::InvalidExecuteAfter); + } + + let mut voters: Map = Map::new(env); + voters.set(proposer.clone(), true); + + let action = AdminAction { + kind, + payload, + execute_after, + voters, + }; + + let action_id = increment_admin_action_count(env); + set_admin_action(env, action_id, &action); + + event::admin_action_proposed(env, action_id, &proposer, execute_after); + action_id +} + +/// Approve a pending admin action. Each signer may approve once. +/// +/// # Panics +/// - `Error::NotAdminSigner` if `approver` is not in the signer set +/// - `Error::ActionNotFound` if no action exists under `action_id` +/// - `Error::AlreadyApproved` if this signer already approved +pub fn approve_admin_action(env: &Env, approver: Address, action_id: u64) { + approver.require_auth(); + + let signers = get_admin_signers(env); + assert_signer(env, &signers, &approver); + + let mut action = get_admin_action(env, action_id) + .unwrap_or_else(|| env.panic_with_error(Error::ActionNotFound)); + + if action.voters.contains_key(approver.clone()) { + env.panic_with_error(Error::AlreadyApproved); + } + + action.voters.set(approver.clone(), true); + set_admin_action(env, action_id, &action); + + event::admin_action_approved(env, action_id, &approver, action.voters.len()); +} + +/// Execute an approved admin action once its timelock has elapsed. +/// +/// The action is **deleted before** its effect is applied, so execution can +/// never replay and a re-entrant call finds no action. +/// +/// # Panics +/// - `Error::NotAdminSigner` if `executor` is not in the signer set +/// - `Error::ActionNotFound` if no action exists under `action_id` +/// - `Error::TimelockNotElapsed` if `execute_after` has not passed +/// - `Error::InsufficientApprovals` if approvals < quorum +/// - `Error::ContractFrozen` for an `Upgrade` or `ExtendDeadline` action +/// while the contract is frozen (matching the direct entrypoints) +pub fn execute_admin_action(env: &Env, executor: Address, action_id: u64) { + executor.require_auth(); + + let signers = get_admin_signers(env); + assert_signer(env, &signers, &executor); + + let action = get_admin_action(env, action_id) + .unwrap_or_else(|| env.panic_with_error(Error::ActionNotFound)); + + if env.ledger().timestamp() < action.execute_after { + env.panic_with_error(Error::TimelockNotElapsed); + } + + if action.voters.len() < required_approvals(signers.len()) { + env.panic_with_error(Error::InsufficientApprovals); + } + + // Delete before applying: no replay, and a re-entrant call finds nothing. + remove_admin_action(env, action_id); + + let timestamp = env.ledger().timestamp(); + match action.kind { + ActionKind::Upgrade => { + // Freeze invariant matches the direct upgrade() entrypoint. + if is_frozen(env) { + env.panic_with_error(Error::ContractFrozen); + } + let mut hash = [0u8; 32]; + action.payload.copy_into_slice(&mut hash); + let wasm_hash = soroban_sdk::BytesN::from_array(env, &hash); + env.deployer() + .update_current_contract_wasm(wasm_hash.clone()); + event::contract_upgraded(env, &executor, wasm_hash, timestamp); + } + ActionKind::Freeze => { + set_frozen(env, true); + event::contract_frozen(env, &executor, timestamp); + } + ActionKind::Unfreeze => { + set_frozen(env, false); + event::contract_unfrozen(env, &executor, timestamp); + } + ActionKind::ExtendDeadline => { + let mut buf = [0u8; 8]; + action.payload.copy_into_slice(&mut buf); + let new_end_time = u64::from_be_bytes(buf); + // Shares the direct entrypoint's validation (status, bounds, + // freeze check) without re-authing the creator. + crate::contract::apply_extend_deadline(env, new_end_time, &executor); + } + } + + event::admin_action_executed(env, action_id, &executor); +} + +/// Replace the admin signer set. +/// +/// Requires authorization from **every current signer** — rotating a +/// multi-sig set is itself a multi-sig operation, so a single compromised +/// key can neither expand nor collapse the quorum. With the default +/// single-signer set this degrades to the familiar creator-only auth. +/// +/// # Panics +/// - `Error::InvalidSigners` if `new_signers` is empty or has duplicates +pub fn set_admin_signers(env: &Env, new_signers: Vec) { + if new_signers.is_empty() { + env.panic_with_error(Error::InvalidSigners); + } + // Reject duplicates: each signer must count once toward the quorum. + for i in 0..new_signers.len() { + let a = new_signers.get(i).unwrap(); + for j in (i + 1)..new_signers.len() { + if a == new_signers.get(j).unwrap() { + env.panic_with_error(Error::InvalidSigners); + } + } + } + + let current = get_admin_signers(env); + for signer in current.iter() { + signer.require_auth(); + } + + set_admin_signers_storage(env, &new_signers); + event::admin_signers_updated(env, new_signers.len()); +} diff --git a/campaign/src/contract.rs b/campaign/src/contract.rs index cb7e195a..b6fc7f57 100644 --- a/campaign/src/contract.rs +++ b/campaign/src/contract.rs @@ -8,7 +8,7 @@ use crate::storage::{get_campaign, is_frozen, set_campaign}; use crate::types::{CampaignStatus, Error}; use crate::validation::validate_campaign_transition; use crate::MAX_DEADLINE_GAP_SECONDS; -use soroban_sdk::{panic_with_error, Env}; +use soroban_sdk::{panic_with_error, Address, Env}; /// Issue #212 – End the campaign early (before deadline). /// @@ -88,11 +88,22 @@ pub fn cancel_campaign(env: &Env) { /// - `Error::InvalidEndTime` if `new_end_time` is more than ten years out /// - `Error::InvalidCampaignTransition` if campaign is not Active or GoalReached pub fn extend_deadline(env: &Env, new_end_time: u64) { + // Issue #92 – the direct call is the legacy 1-of-1 path: it auths the + // sole admin signer and is rejected once a multi-sig set is configured. + let admin = crate::admin::require_direct_admin(env); + admin.require_auth(); + + apply_extend_deadline(env, new_end_time, &admin); +} + +/// Issue #92 – Validation + mutation of `extend_deadline`, shared by the +/// direct entrypoint (above, after auth) and the timelock + multi-sig +/// `execute_admin_action` path (which has already established authority and +/// must not re-auth the creator). +pub(crate) fn apply_extend_deadline(env: &Env, new_end_time: u64, actor: &Address) { let mut campaign = get_campaign(env).unwrap_or_else(|| panic_with_error!(env, Error::NotInitialized)); - campaign.creator.require_auth(); - // Freeze invariant: all write operations are rejected while frozen (see freeze()). if is_frozen(env) { panic_with_error!(env, Error::ContractFrozen); @@ -113,7 +124,7 @@ pub fn extend_deadline(env: &Env, new_end_time: u64) { campaign.end_time = new_end_time; set_campaign(env, &campaign); - event::deadline_extended(env, &campaign.creator, old_deadline, new_end_time); + event::deadline_extended(env, actor, old_deadline, new_end_time); } /// Issue #235 — Get campaign status with computed fields. diff --git a/campaign/src/event.rs b/campaign/src/event.rs index b33b88d4..2902b02f 100644 --- a/campaign/src/event.rs +++ b/campaign/src/event.rs @@ -112,3 +112,34 @@ pub fn asset_unblocked(env: &Env, admin: &Address, asset: &Address, timestamp: u env.events() .publish(("campaign", "asset_unblocked"), (admin, asset, timestamp)); } + +// ─── Issue #92 – timelock + multi-sig admin governance ─────────────────────── + +/// Issue #92 – Emitted when an admin action is proposed. The `execute_after` +/// timestamp is the community's window to detect a malicious proposal. +pub fn admin_action_proposed(env: &Env, action_id: u64, proposer: &Address, execute_after: u64) { + env.events().publish( + ("campaign", "admin_action_proposed"), + (action_id, proposer, execute_after), + ); +} + +/// Issue #92 – Emitted when a signer approves a pending admin action. +pub fn admin_action_approved(env: &Env, action_id: u64, approver: &Address, approvals: u32) { + env.events().publish( + ("campaign", "admin_action_approved"), + (action_id, approver, approvals), + ); +} + +/// Issue #92 – Emitted when an admin action is executed. +pub fn admin_action_executed(env: &Env, action_id: u64, executor: &Address) { + env.events() + .publish(("campaign", "admin_action_executed"), (action_id, executor)); +} + +/// Issue #92 – Emitted when the admin signer set is replaced. +pub fn admin_signers_updated(env: &Env, signer_count: u32) { + env.events() + .publish(("campaign", "admin_signers_updated"), signer_count); +} diff --git a/campaign/src/lib.rs b/campaign/src/lib.rs index 396593be..6156c9e8 100644 --- a/campaign/src/lib.rs +++ b/campaign/src/lib.rs @@ -25,6 +25,7 @@ // the warning keeps CI clean without changing the published event topics. #![allow(deprecated)] +pub mod admin; pub mod asset_auth; pub mod backend; pub mod contract; @@ -39,7 +40,7 @@ pub mod types; pub mod validation; pub mod views; -use soroban_sdk::{contract, contractimpl, Address, BytesN, Env, Vec}; +use soroban_sdk::{contract, contractimpl, Address, Bytes, BytesN, Env, Vec}; use storage::{ acquire_lock, block_asset, bump_all_persistent, get_cached_report_storage, get_campaign, get_donor, get_donor_asset_donation, get_milestone, increment_donor_asset_donation, @@ -51,9 +52,9 @@ use storage::{ }; use types::{ - AssetInfo, CampaignData, CampaignInitializedEvent, CampaignReport, CampaignStatus, - CampaignStatusResponse, DashboardMetrics, DonorRecord, Error, MilestoneData, PlatformSummary, - StellarAsset, + ActionKind, AdminAction, AssetInfo, CampaignData, CampaignInitializedEvent, CampaignReport, + CampaignStatus, CampaignStatusResponse, DashboardMetrics, DonorRecord, Error, MilestoneData, + PlatformSummary, StellarAsset, }; use reports::{ @@ -651,10 +652,10 @@ impl CampaignContract { /// - `Error::NotInitialized` if campaign not yet initialized /// - `Error::ContractFrozen` if the contract is currently frozen pub fn upgrade(env: Env, new_wasm_hash: BytesN<32>) { - let campaign = - get_campaign(&env).unwrap_or_else(|| panic_with_error(&env, Error::NotInitialized)); - - campaign.creator.require_auth(); + // Issue #92 – direct call = legacy 1-of-1 path; auths the sole admin + // signer and panics `MultisigActive` once a multi-sig set exists. + let admin = admin::require_direct_admin(&env); + admin.require_auth(); // Freeze check — consistent with donate(), claim_refund(), and release_milestone() if is_frozen(&env) { @@ -666,7 +667,7 @@ impl CampaignContract { .update_current_contract_wasm(new_wasm_hash.clone()); let timestamp = env.ledger().timestamp(); - event::contract_upgraded(&env, &campaign.creator, new_wasm_hash, timestamp); + event::contract_upgraded(&env, &admin, new_wasm_hash, timestamp); } /// Issue #246 – Freeze the contract, blocking all mutating operations. @@ -678,15 +679,14 @@ impl CampaignContract { /// - `Error::Unauthorized` if not called by the creator /// - `Error::NotInitialized` if campaign not yet initialized pub fn freeze(env: Env) { - let campaign = - get_campaign(&env).unwrap_or_else(|| panic_with_error(&env, Error::NotInitialized)); - - campaign.creator.require_auth(); + // Issue #92 – direct call = legacy 1-of-1 path (see upgrade()). + let admin = admin::require_direct_admin(&env); + admin.require_auth(); set_frozen(&env, true); let timestamp = env.ledger().timestamp(); - event::contract_frozen(&env, &campaign.creator, timestamp); + event::contract_frozen(&env, &admin, timestamp); } /// Issue #120 – Public TTL maintenance entrypoint. @@ -714,15 +714,91 @@ impl CampaignContract { /// - `Error::Unauthorized` if not called by the creator /// - `Error::NotInitialized` if campaign not yet initialized pub fn unfreeze(env: Env) { - let campaign = - get_campaign(&env).unwrap_or_else(|| panic_with_error(&env, Error::NotInitialized)); - - campaign.creator.require_auth(); + // Issue #92 – direct call = legacy 1-of-1 path (see upgrade()). + let admin = admin::require_direct_admin(&env); + admin.require_auth(); set_frozen(&env, false); let timestamp = env.ledger().timestamp(); - event::contract_unfrozen(&env, &campaign.creator, timestamp); + event::contract_unfrozen(&env, &admin, timestamp); + } + + // ─── Issue #92 – timelock + multi-sig admin governance ────────────────── + + /// Issue #92 – Propose an admin action (upgrade / freeze / unfreeze / + /// extend-deadline) for the timelock + multi-sig flow. The proposer must + /// be an admin signer; proposing counts as the first approval. Returns + /// the action id. + /// + /// # Panics + /// - `Error::NotAdminSigner` if `proposer` is not an admin signer + /// - `Error::InvalidActionPayload` if the payload doesn't match the kind + /// - `Error::InvalidExecuteAfter` if `execute_after` is in the past + pub fn propose_admin_action( + env: Env, + proposer: Address, + kind: ActionKind, + payload: Bytes, + execute_after: u64, + ) -> u64 { + admin::propose_admin_action(&env, proposer, kind, payload, execute_after) + } + + /// Issue #92 – Approve a pending admin action. Each signer approves once. + /// + /// # Panics + /// - `Error::NotAdminSigner` if `approver` is not an admin signer + /// - `Error::ActionNotFound` if the action doesn't exist + /// - `Error::AlreadyApproved` if this signer already approved + pub fn approve_admin_action(env: Env, approver: Address, action_id: u64) { + admin::approve_admin_action(&env, approver, action_id) + } + + /// Issue #92 – Execute an approved admin action once the timelock has + /// elapsed and the quorum (2 approvals with a multi-sig set, 1 with the + /// legacy single signer) is met. The action is deleted before its effect + /// applies, so it can never replay. + /// + /// # Panics + /// - `Error::NotAdminSigner` if `executor` is not an admin signer + /// - `Error::ActionNotFound` if the action doesn't exist + /// - `Error::TimelockNotElapsed` if `execute_after` hasn't passed + /// - `Error::InsufficientApprovals` if approvals < quorum + pub fn execute_admin_action(env: Env, executor: Address, action_id: u64) { + admin::execute_admin_action(&env, executor, action_id) + } + + /// Issue #92 – Replace the admin signer set. Requires auth from every + /// current signer, so rotating a multi-sig is itself multi-sig; with the + /// default `[creator]` set this is the familiar creator-only auth. + /// + /// # Panics + /// - `Error::InvalidSigners` if the new set is empty or has duplicates + pub fn set_admin_signers(env: Env, new_signers: Vec) { + admin::set_admin_signers(&env, new_signers) + } + + /// Issue #92 – Current admin signer set (stored set, or `[creator]`). + /// No auth required (read-only view). + pub fn get_admin_signers(env: Env) -> Vec { + admin::get_admin_signers(&env) + } + + /// Issue #92 – Fetch a pending admin action by id. + /// No auth required (read-only view). + /// + /// # Panics + /// - `Error::ActionNotFound` if the action doesn't exist (or was executed) + pub fn get_admin_action(env: Env, action_id: u64) -> AdminAction { + storage::get_admin_action(&env, action_id) + .unwrap_or_else(|| panic_with_error(&env, Error::ActionNotFound)) + } + + /// Issue #92 – Number of admin actions ever proposed. + /// No auth required (read-only view). + pub fn get_admin_action_count(env: Env) -> u64 { + storage::get_admin_action_count(&env) } /// Issue #175 – assert the current invoker is the campaign creator. @@ -801,6 +877,7 @@ fn panic_with_error(env: &Env, error: Error) -> ! { #[cfg(test)] mod test { + pub mod admin_action_tests; pub mod bump_storage_tests; pub mod claim_refund_tests; pub mod get_campaign_status_tests; diff --git a/campaign/src/storage.rs b/campaign/src/storage.rs index 22766a6b..7310efa7 100644 --- a/campaign/src/storage.rs +++ b/campaign/src/storage.rs @@ -1,7 +1,8 @@ // src/storage.rs use crate::types::{ - CampaignData, CampaignReport, DataKey, DonorRecord, Error, MilestoneData, MilestoneStatus, + AdminAction, CampaignData, CampaignReport, DataKey, DonorRecord, Error, MilestoneData, + MilestoneStatus, }; use soroban_sdk::{panic_with_error, Address, Env, Vec}; @@ -535,6 +536,15 @@ pub fn bump_all_persistent(env: &Env, milestone_count: u32) { if env.storage().persistent().has(&report_key) { bump_persistent(env, &report_key); } + // Issue #92 – admin governance keys. + let count_key = DataKey::AdminActionCount; + if env.storage().persistent().has(&count_key) { + bump_persistent(env, &count_key); + } + let signers_key = DataKey::AdminSigners; + if env.storage().persistent().has(&signers_key) { + bump_persistent(env, &signers_key); + } // Legacy per-index entries (pre-#118 layouts, not yet migrated). for i in 0..milestone_count { let key = DataKey::MilestoneData(i); @@ -543,3 +553,56 @@ pub fn bump_all_persistent(env: &Env, milestone_count: u32) { } } } + +// ─── Issue #92 – timelock + multi-sig admin actions ────────────────────────── + +/// Store an admin action proposal under its id. +pub fn set_admin_action(env: &Env, action_id: u64, action: &AdminAction) { + let key = DataKey::AdminAction(action_id); + env.storage().persistent().set(&key, action); + bump_persistent(env, &key); +} + +/// Fetch an admin action proposal, if it exists (executed actions are deleted). +pub fn get_admin_action(env: &Env, action_id: u64) -> Option