diff --git a/src/access_control.rs b/src/access_control.rs index 3149ec1..417f7cb 100644 --- a/src/access_control.rs +++ b/src/access_control.rs @@ -7,6 +7,11 @@ pub enum Role { Manager, Moderator, Verifier, + /// Emergency guardian: authorized to veto a pending contract WASM + /// upgrade during its timelock window (see `upgrade.rs`). Deliberately + /// separate from `Admin` so upgrade proposals can be checked by a party + /// other than the one proposing them. + Guardian, } #[contracttype] diff --git a/src/events.rs b/src/events.rs index 9e34c58..3b2c4f8 100644 --- a/src/events.rs +++ b/src/events.rs @@ -143,12 +143,7 @@ pub fn emit_proposal_executed(env: &Env, proposal_id: u32, passed: bool) { // `prop_*` governance topics above so off-chain indexers can separate // community proposals from privileged admin transactions. -pub fn emit_multisig_configured( - env: &Env, - admin: Address, - signer_count: u32, - threshold: u32, -) { +pub fn emit_multisig_configured(env: &Env, admin: Address, signer_count: u32, threshold: u32) { env.events().publish( (symbol_short!("ms_cfg"), admin), (signer_count, threshold, env.ledger().timestamp()), @@ -181,22 +176,14 @@ pub fn emit_multisig_approved( ); } -pub fn emit_multisig_executed( - env: &Env, - proposal_id: u32, - executor: Address, -) { +pub fn emit_multisig_executed(env: &Env, proposal_id: u32, executor: Address) { env.events().publish( (symbol_short!("ms_exec"), proposal_id), (executor, env.ledger().timestamp()), ); } -pub fn emit_multisig_cancelled( - env: &Env, - proposal_id: u32, - caller: Address, -) { +pub fn emit_multisig_cancelled(env: &Env, proposal_id: u32, caller: Address) { env.events().publish( (symbol_short!("ms_cancl"), proposal_id), (caller, env.ledger().timestamp()), @@ -370,3 +357,43 @@ pub fn emit_vault_claim(env: &Env, claimant: Address, token: Address, amount: i1 (claimant, amount, env.ledger().timestamp()), ); } + +// ── Upgrade Timelock Events ──────────────────────────────────────────────── + +pub fn emit_upgrade_proposed( + env: &Env, + wasm_hash: soroban_sdk::BytesN<32>, + proposed_by: Address, + ready_at: u64, +) { + env.events().publish( + (symbol_short!("upg_prop"), proposed_by), + (wasm_hash, ready_at, env.ledger().timestamp()), + ); +} + +pub fn emit_upgrade_executed(env: &Env, wasm_hash: soroban_sdk::BytesN<32>, executed_by: Address) { + env.events().publish( + (symbol_short!("upg_exec"), executed_by), + (wasm_hash, env.ledger().timestamp()), + ); +} + +pub fn emit_upgrade_vetoed(env: &Env, wasm_hash: soroban_sdk::BytesN<32>, vetoed_by: Address) { + env.events().publish( + (symbol_short!("upg_veto"), vetoed_by), + (wasm_hash, env.ledger().timestamp()), + ); +} + +pub fn emit_upgrade_timelock_updated( + env: &Env, + old_seconds: u64, + new_seconds: u64, + updated_by: Address, +) { + env.events().publish( + (symbol_short!("upg_tl"), updated_by), + (old_seconds, new_seconds, env.ledger().timestamp()), + ); +} diff --git a/src/lib.rs b/src/lib.rs index 1e404ab..f4a2d7b 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -9,8 +9,9 @@ pub mod multisig; pub mod pausable; pub mod reputation; pub mod storage; -pub mod twap_oracle; pub mod swap_router; +pub mod twap_oracle; +pub mod upgrade; pub mod user_profile; pub mod vault; pub mod zkp_attestation; @@ -24,6 +25,8 @@ mod multisig_test; mod swap_router_test; #[cfg(test)] mod test; +#[cfg(test)] +mod upgrade_test; use soroban_sdk::{contract, contractimpl, contracttype, Address, Env, String, Vec}; @@ -820,12 +823,8 @@ impl TaskManagerContract { description: String, action: multisig::MultisigAction, ) -> u32 { - let proposal_id = multisig::propose( - env.clone(), - proposer.clone(), - description.clone(), - action, - ); + let proposal_id = + multisig::propose(env.clone(), proposer.clone(), description.clone(), action); let threshold = multisig::get_config(&env).threshold; events::emit_multisig_proposed(&env, proposal_id, proposer, description, threshold); @@ -870,10 +869,7 @@ impl TaskManagerContract { events::emit_multisig_cancelled(&env, proposal_id, caller); } - pub fn get_multisig_proposal( - env: Env, - proposal_id: u32, - ) -> Option { + pub fn get_multisig_proposal(env: Env, proposal_id: u32) -> Option { multisig::get_proposal(&env, proposal_id) } @@ -1133,6 +1129,66 @@ impl TaskManagerContract { claimant.require_auth(); vault::claim_payroll(&env, claimant, token, payroll_id, amount, proof); } + + // ======================================================================== + // Upgrade Timelock & Rollback Guard + // ======================================================================== + + /// Read the currently configured upgrade timelock delay, in seconds. + pub fn get_upgrade_timelock(env: Env) -> u64 { + upgrade::get_timelock_seconds(&env) + } + + /// Reconfigure the upgrade timelock delay. Admin-only; rejects values + /// below `upgrade::MIN_TIMELOCK_SECONDS`. + pub fn set_upgrade_timelock(env: Env, admin: Address, seconds: u64) { + let old_seconds = upgrade::get_timelock_seconds(&env); + let new_seconds = upgrade::set_timelock_seconds(env.clone(), admin.clone(), seconds); + events::emit_upgrade_timelock_updated(&env, old_seconds, new_seconds, admin); + } + + /// Propose upgrading the contract to `new_wasm_hash`. Admin-only. Starts + /// the mandatory timelock window; `execute_upgrade` will reject any + /// attempt to apply the upgrade before `ready_at`. + pub fn propose_upgrade( + env: Env, + admin: Address, + new_wasm_hash: soroban_sdk::BytesN<32>, + ) -> upgrade::UpgradeProposal { + let proposal = upgrade::propose_upgrade(env.clone(), admin.clone(), new_wasm_hash.clone()); + events::emit_upgrade_proposed(&env, new_wasm_hash, admin, proposal.ready_at); + proposal + } + + /// Veto the currently pending upgrade proposal. Callable by the admin or + /// any address holding the `Guardian` role during the timelock window. + pub fn veto_upgrade(env: Env, guardian: Address) -> upgrade::UpgradeProposal { + let proposal = upgrade::veto_upgrade(env.clone(), guardian.clone()); + events::emit_upgrade_vetoed(&env, proposal.wasm_hash.clone(), guardian); + proposal + } + + /// Execute the pending upgrade proposal once its timelock has elapsed. + /// Admin-only. Reverts if called early, or if the proposal was already + /// executed or vetoed. + pub fn execute_upgrade(env: Env, admin: Address) -> soroban_sdk::BytesN<32> { + let wasm_hash = upgrade::execute_upgrade(env.clone(), admin.clone()); + events::emit_upgrade_executed(&env, wasm_hash.clone(), admin); + wasm_hash + } + + /// Current state of the (single) pending/most-recently-resolved upgrade + /// proposal, if any has ever been created. + pub fn get_pending_upgrade(env: Env) -> Option { + upgrade::get_pending_upgrade(&env) + } + + /// Append-only log of WASM hashes this contract has actually been + /// upgraded to. Used to identify a hash to roll back to via + /// `propose_upgrade`. + pub fn get_upgrade_history(env: Env) -> Vec { + upgrade::get_upgrade_history(&env) + } } // ============================================================================ @@ -1145,6 +1201,7 @@ fn format_role(env: &Env, role: &access_control::Role) -> String { access_control::Role::Manager => String::from_str(env, "Manager"), access_control::Role::Moderator => String::from_str(env, "Moderator"), access_control::Role::Verifier => String::from_str(env, "Verifier"), + access_control::Role::Guardian => String::from_str(env, "Guardian"), } } diff --git a/src/upgrade.rs b/src/upgrade.rs new file mode 100644 index 0000000..5c4b085 --- /dev/null +++ b/src/upgrade.rs @@ -0,0 +1,322 @@ +//! Timelocked contract-WASM upgrade proposal, veto, and rollback ledger. +//! +//! Every upgrade of this contract's underlying WASM code must pass through a +//! single mandatory pending window before it can take effect, giving users +//! and the emergency guardian time to inspect the proposed code and react. +//! +//! Status workflow: +//! +//! ```text +//! Pending ──(timelock elapses, admin executes)──> Executed +//! │ +//! └──────────(guardian or admin vetoes)───────> Vetoed +//! ``` +//! +//! Only one upgrade proposal is tracked at a time — a second `propose_upgrade` +//! call is rejected while one is still `Pending`. This mirrors how the WASM +//! hash itself works: the contract only ever has one "next" version in +//! flight, so a single-slot design keeps the state machine (and its +//! authorization story) simple without losing any of the acceptance +//! criteria's requirements. +//! +//! ### Rollback semantics +//! +//! This module does not implement a separate "rollback" entry point. Instead, +//! every hash the contract is *actually* upgraded to is appended to an +//! append-only history log (`get_upgrade_history`). To roll back, the admin +//! simply calls `propose_upgrade` again with a previously recorded hash and +//! takes it through the exact same timelock + (optional) guardian-veto flow +//! as any other upgrade. This was chosen deliberately over a distinct +//! `rollback_upgrade` bypass: a rollback is just as capable of reintroducing +//! a bug or being abused as a forward upgrade, so it gets no less scrutiny — +//! the timelock and guardian veto apply equally. The history log's job is +//! purely to make past hashes discoverable so the admin/guardian know what to +//! propose back to; it grants no special authority of its own. +//! +//! ### Guardian role +//! +//! Rather than introducing a parallel role system, the emergency guardian is +//! modeled as a new `access_control::Role::Guardian` value on top of the +//! contract's existing role registry (`grant_role` / `revoke_role` / +//! `has_role` in `access_control.rs`). Guardians are installed the same way +//! any other role is: `grant_role(admin, guardian_address, Role::Guardian)`. +//! `veto_upgrade` accepts either the contract admin (so an operator can +//! self-correct a mistaken proposal without waiting on a third party) or any +//! address holding `Role::Guardian` (the actual emergency-stop path against a +//! malicious or compromised admin-issued proposal). + +use soroban_sdk::{contracttype, Address, BytesN, Env, Vec}; + +use crate::{access_control, DataKey}; + +// ============================================================================ +// Types +// ============================================================================ + +/// Lifecycle state of the (single) pending upgrade proposal slot. +#[contracttype] +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +#[repr(u32)] +pub enum UpgradeStatus { + /// Proposed and timelocked; not yet executable. + Pending = 0, + /// Timelock elapsed and the WASM swap was applied. Terminal. + Executed = 1, + /// Cancelled by the admin or an emergency guardian before execution. Terminal. + Vetoed = 2, +} + +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct UpgradeProposal { + pub wasm_hash: BytesN<32>, + pub proposed_by: Address, + pub proposed_at: u64, + /// Ledger timestamp (seconds) at or after which `execute_upgrade` may + /// succeed. + pub ready_at: u64, + pub status: UpgradeStatus, + pub executed_at: Option, + pub vetoed_by: Option
, +} + +/// A single entry in the append-only log of WASM hashes this contract has +/// actually been upgraded to. Used to support emergency rollback: the admin +/// can look up a previous hash here and re-propose it through the normal +/// timelock flow. +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct UpgradeHistoryEntry { + pub wasm_hash: BytesN<32>, + pub applied_at: u64, + pub applied_by: Address, +} + +#[contracttype] +pub enum UpgradeKey { + /// The single in-flight (or most recently resolved) upgrade proposal. + Pending, + /// Configured timelock delay, in seconds. Falls back to + /// `DEFAULT_TIMELOCK_SECONDS` when unset. + TimelockSeconds, + /// Append-only `Vec` of applied upgrades. + History, +} + +/// Default mandatory pending window: 48 hours. +pub const DEFAULT_TIMELOCK_SECONDS: u64 = 172_800; + +/// Safety floor for the configurable timelock: 1 hour. Prevents the admin +/// from configuring the delay down to (near) zero and defeating the whole +/// point of a mandatory inspection window. +pub const MIN_TIMELOCK_SECONDS: u64 = 3_600; + +/// Upper bound on retained history entries, keeping the log's storage +/// footprint bounded on a long-lived contract. +pub const MAX_HISTORY_LEN: u32 = 50; + +// ============================================================================ +// Authorization helpers +// ============================================================================ + +fn stored_admin(env: &Env) -> Address { + env.storage() + .instance() + .get(&DataKey::Admin) + .unwrap_or_else(|| panic!("not initialized")) +} + +fn require_admin(env: &Env, caller: &Address) { + caller.require_auth(); + if *caller != stored_admin(env) { + panic!("not admin"); + } +} + +/// Gate for `veto_upgrade`: the contract admin, or any address holding +/// `access_control::Role::Guardian`. +fn require_guardian_or_admin(env: &Env, caller: &Address) { + caller.require_auth(); + if *caller == stored_admin(env) { + return; + } + if !access_control::has_role(env.clone(), caller.clone(), access_control::Role::Guardian) { + panic!("not an emergency guardian"); + } +} + +// ============================================================================ +// Timelock configuration +// ============================================================================ + +/// Currently configured timelock delay, in seconds. +pub fn get_timelock_seconds(env: &Env) -> u64 { + env.storage() + .instance() + .get(&UpgradeKey::TimelockSeconds) + .unwrap_or(DEFAULT_TIMELOCK_SECONDS) +} + +/// Reconfigure the timelock delay. Admin-only; rejects values below the +/// `MIN_TIMELOCK_SECONDS` safety floor. +pub fn set_timelock_seconds(env: Env, admin: Address, seconds: u64) -> u64 { + require_admin(&env, &admin); + if seconds < MIN_TIMELOCK_SECONDS { + panic!("timelock below minimum safety floor"); + } + env.storage() + .instance() + .set(&UpgradeKey::TimelockSeconds, &seconds); + seconds +} + +// ============================================================================ +// Proposal lifecycle +// ============================================================================ + +/// Propose upgrading the contract to `new_wasm_hash`. Admin-only. +/// +/// Stores the proposal in the `Pending` state with `ready_at` set to now plus +/// the configured timelock. Rejected while another proposal is still +/// `Pending` — resolve it (execute or veto) first. +pub fn propose_upgrade(env: Env, admin: Address, new_wasm_hash: BytesN<32>) -> UpgradeProposal { + require_admin(&env, &admin); + + if let Some(existing) = get_pending_upgrade(&env) { + if existing.status == UpgradeStatus::Pending { + panic!("an upgrade proposal is already pending"); + } + } + + let now = env.ledger().timestamp(); + let ready_at = now + get_timelock_seconds(&env); + + let proposal = UpgradeProposal { + wasm_hash: new_wasm_hash, + proposed_by: admin, + proposed_at: now, + ready_at, + status: UpgradeStatus::Pending, + executed_at: None, + vetoed_by: None, + }; + + env.storage() + .instance() + .set(&UpgradeKey::Pending, &proposal); + proposal +} + +/// Cancel the currently pending upgrade proposal before its timelock elapses. +/// Callable by the contract admin (self-correction) or any address holding +/// `Role::Guardian` (emergency stop). No effect can be reversed once a +/// proposal is `Executed`. +pub fn veto_upgrade(env: Env, guardian: Address) -> UpgradeProposal { + require_guardian_or_admin(&env, &guardian); + + let mut proposal = get_pending_upgrade(&env).unwrap_or_else(|| panic!("no pending upgrade")); + + if proposal.status != UpgradeStatus::Pending { + panic!("upgrade proposal is not pending"); + } + + proposal.status = UpgradeStatus::Vetoed; + proposal.vetoed_by = Some(guardian); + env.storage() + .instance() + .set(&UpgradeKey::Pending, &proposal); + proposal +} + +/// Execute the pending upgrade proposal, swapping the contract's live WASM. +/// Admin-only; reverts if the timelock has not yet elapsed, or if the +/// proposal was already executed or vetoed. +/// +/// Records the applied hash in the rollback history log before performing +/// the swap, then invokes `env.deployer().update_current_contract_wasm`. +pub fn execute_upgrade(env: Env, caller: Address) -> BytesN<32> { + require_admin(&env, &caller); + + let mut proposal = get_pending_upgrade(&env).unwrap_or_else(|| panic!("no pending upgrade")); + + match proposal.status { + UpgradeStatus::Pending => {} + UpgradeStatus::Executed => panic!("upgrade proposal already executed"), + UpgradeStatus::Vetoed => panic!("upgrade proposal was vetoed"), + } + + let now = env.ledger().timestamp(); + if now < proposal.ready_at { + panic!("timelock has not elapsed"); + } + + // Record the outgoing hash in history before swapping code, so the log + // always reflects hashes that were actually applied on-chain. + push_history(&env, proposal.wasm_hash.clone(), caller.clone(), now); + + env.deployer() + .update_current_contract_wasm(proposal.wasm_hash.clone()); + + proposal.status = UpgradeStatus::Executed; + proposal.executed_at = Some(now); + env.storage() + .instance() + .set(&UpgradeKey::Pending, &proposal); + + proposal.wasm_hash +} + +// ============================================================================ +// History +// ============================================================================ + +fn push_history(env: &Env, wasm_hash: BytesN<32>, applied_by: Address, applied_at: u64) { + let mut history: Vec = env + .storage() + .persistent() + .get(&UpgradeKey::History) + .unwrap_or_else(|| Vec::new(env)); + + history.push_back(UpgradeHistoryEntry { + wasm_hash, + applied_at, + applied_by, + }); + + // Bound storage growth: keep only the most recent MAX_HISTORY_LEN entries. + if history.len() > MAX_HISTORY_LEN { + let drop = history.len() - MAX_HISTORY_LEN; + let mut trimmed = Vec::new(env); + for i in drop..history.len() { + trimmed.push_back(history.get(i).unwrap()); + } + history = trimmed; + } + + env.storage() + .persistent() + .set(&UpgradeKey::History, &history); + crate::storage::extend_persistent_ttl( + env, + &UpgradeKey::History, + 100_000, + crate::storage::DEFAULT_PERSISTENT_TTL, + ); +} + +// ============================================================================ +// Views +// ============================================================================ + +pub fn get_pending_upgrade(env: &Env) -> Option { + env.storage().instance().get(&UpgradeKey::Pending) +} + +/// Append-only log of WASM hashes this contract has actually been upgraded +/// to, oldest first (subject to `MAX_HISTORY_LEN` truncation). +pub fn get_upgrade_history(env: &Env) -> Vec { + env.storage() + .persistent() + .get(&UpgradeKey::History) + .unwrap_or_else(|| Vec::new(env)) +} diff --git a/src/upgrade_test.rs b/src/upgrade_test.rs new file mode 100644 index 0000000..7dde947 --- /dev/null +++ b/src/upgrade_test.rs @@ -0,0 +1,296 @@ +#![cfg(test)] +#![allow(deprecated)] + +use crate::access_control::Role; +use crate::upgrade::{UpgradeStatus, DEFAULT_TIMELOCK_SECONDS, MIN_TIMELOCK_SECONDS}; +use crate::{TaskManagerContract, TaskManagerContractClient}; +use soroban_sdk::testutils::{Address as _, Ledger}; +use soroban_sdk::{Address, BytesN, Env}; + +// ── Shared setup ─────────────────────────────────────────────────────────── + +#[allow(dead_code)] +struct Ctx { + client: TaskManagerContractClient<'static>, + admin: Address, +} + +fn setup(env: &Env) -> Ctx { + let contract_id = env.register_contract(None, TaskManagerContract); + let client = TaskManagerContractClient::new(env, &contract_id); + + let admin = Address::generate(env); + let token_admin = Address::generate(env); + let token = env.register_stellar_asset_contract(token_admin); + let fee_recipient = Address::generate(env); + + client.initialize(&admin, &100u32, &token, &fee_recipient); + + Ctx { client, admin } +} + +/// A WASM hash that has actually been uploaded to the test host, so +/// `execute_upgrade`'s call into `update_current_contract_wasm` succeeds +/// instead of tripping the host's "wasm does not exist" check. The Soroban +/// test host specifically allows a zero-length WASM blob for this purpose. +fn uploaded_wasm_hash(env: &Env) -> BytesN<32> { + let empty_wasm: &[u8] = &[]; + env.deployer().upload_contract_wasm(empty_wasm) +} + +/// An arbitrary hash that was never uploaded. Fine for tests that only +/// exercise the proposal state machine and must panic before ever reaching +/// the actual WASM swap (premature execution, unauthorized callers, etc). +fn placeholder_wasm_hash(env: &Env) -> BytesN<32> { + BytesN::from_array(env, &[7u8; 32]) +} + +// ── propose_upgrade ───────────────────────────────────────────────────────── + +#[test] +fn test_propose_upgrade_sets_pending_state_with_timelock() { + let env = Env::default(); + env.mock_all_auths(); + let ctx = setup(&env); + + let hash = placeholder_wasm_hash(&env); + let proposal = ctx.client.propose_upgrade(&ctx.admin, &hash); + + assert_eq!(proposal.status, UpgradeStatus::Pending); + assert_eq!(proposal.wasm_hash, hash); + assert_eq!(proposal.proposed_by, ctx.admin); + assert_eq!( + proposal.ready_at, + proposal.proposed_at + DEFAULT_TIMELOCK_SECONDS + ); + + let pending = ctx.client.get_pending_upgrade().unwrap(); + assert_eq!(pending, proposal); +} + +#[test] +fn test_propose_upgrade_rejects_non_admin() { + let env = Env::default(); + env.mock_all_auths(); + let ctx = setup(&env); + + let outsider = Address::generate(&env); + let hash = placeholder_wasm_hash(&env); + + let res = ctx.client.try_propose_upgrade(&outsider, &hash); + assert!(res.is_err(), "non-admin propose should be rejected"); +} + +#[test] +fn test_propose_upgrade_rejects_second_pending_proposal() { + let env = Env::default(); + env.mock_all_auths(); + let ctx = setup(&env); + + ctx.client + .propose_upgrade(&ctx.admin, &placeholder_wasm_hash(&env)); + + let second_hash = BytesN::from_array(&env, &[9u8; 32]); + let res = ctx.client.try_propose_upgrade(&ctx.admin, &second_hash); + assert!( + res.is_err(), + "a second pending proposal should be rejected while one is already pending" + ); +} + +// ── execute_upgrade ────────────────────────────────────────────────────────── + +#[test] +fn test_execute_upgrade_rejects_before_timelock_elapses() { + let env = Env::default(); + env.mock_all_auths(); + let ctx = setup(&env); + + ctx.client + .propose_upgrade(&ctx.admin, &placeholder_wasm_hash(&env)); + + // Immediately after proposing — must be rejected. + let res = ctx.client.try_execute_upgrade(&ctx.admin); + assert!(res.is_err(), "premature execution should be rejected"); + + // One second short of the timelock — still rejected. + env.ledger() + .with_mut(|l| l.timestamp += DEFAULT_TIMELOCK_SECONDS - 1); + let res = ctx.client.try_execute_upgrade(&ctx.admin); + assert!( + res.is_err(), + "execution one second before ready_at should still be rejected" + ); +} + +#[test] +fn test_execute_upgrade_succeeds_after_timelock_elapses() { + let env = Env::default(); + env.mock_all_auths(); + let ctx = setup(&env); + + let hash = uploaded_wasm_hash(&env); + let proposal = ctx.client.propose_upgrade(&ctx.admin, &hash); + + env.ledger().with_mut(|l| l.timestamp = proposal.ready_at); + + let applied_hash = ctx.client.execute_upgrade(&ctx.admin); + assert_eq!(applied_hash, hash); + + let pending = ctx.client.get_pending_upgrade().unwrap(); + assert_eq!(pending.status, UpgradeStatus::Executed); + assert!(pending.executed_at.is_some()); + + // Historical hash log records the applied upgrade for future rollback. + let history = ctx.client.get_upgrade_history(); + assert_eq!(history.len(), 1); + let entry = history.get(0).unwrap(); + assert_eq!(entry.wasm_hash, hash); + assert_eq!(entry.applied_by, ctx.admin); +} + +#[test] +fn test_execute_upgrade_rejects_non_admin() { + let env = Env::default(); + env.mock_all_auths(); + let ctx = setup(&env); + + let hash = uploaded_wasm_hash(&env); + let proposal = ctx.client.propose_upgrade(&ctx.admin, &hash); + env.ledger().with_mut(|l| l.timestamp = proposal.ready_at); + + let outsider = Address::generate(&env); + let res = ctx.client.try_execute_upgrade(&outsider); + assert!(res.is_err(), "non-admin execute should be rejected"); +} + +#[test] +fn test_execute_upgrade_rejects_when_already_executed() { + let env = Env::default(); + env.mock_all_auths(); + let ctx = setup(&env); + + let hash = uploaded_wasm_hash(&env); + let proposal = ctx.client.propose_upgrade(&ctx.admin, &hash); + env.ledger().with_mut(|l| l.timestamp = proposal.ready_at); + ctx.client.execute_upgrade(&ctx.admin); + + let res = ctx.client.try_execute_upgrade(&ctx.admin); + assert!(res.is_err(), "double execution should be rejected"); +} + +// ── veto_upgrade ───────────────────────────────────────────────────────────── + +#[test] +fn test_veto_upgrade_by_guardian_blocks_execution() { + let env = Env::default(); + env.mock_all_auths(); + let ctx = setup(&env); + + let guardian = Address::generate(&env); + ctx.client + .grant_role(&ctx.admin, &guardian, &Role::Guardian); + + let hash = placeholder_wasm_hash(&env); + let proposal = ctx.client.propose_upgrade(&ctx.admin, &hash); + + let vetoed = ctx.client.veto_upgrade(&guardian); + assert_eq!(vetoed.status, UpgradeStatus::Vetoed); + assert_eq!(vetoed.vetoed_by, Some(guardian)); + + // Even once the timelock would have elapsed, a vetoed proposal can never + // be executed. + env.ledger().with_mut(|l| l.timestamp = proposal.ready_at); + let res = ctx.client.try_execute_upgrade(&ctx.admin); + assert!(res.is_err(), "execution after veto should be rejected"); +} + +#[test] +fn test_veto_upgrade_allows_admin_self_correction() { + let env = Env::default(); + env.mock_all_auths(); + let ctx = setup(&env); + + ctx.client + .propose_upgrade(&ctx.admin, &placeholder_wasm_hash(&env)); + + let vetoed = ctx.client.veto_upgrade(&ctx.admin); + assert_eq!(vetoed.status, UpgradeStatus::Vetoed); + assert_eq!(vetoed.vetoed_by, Some(ctx.admin.clone())); +} + +#[test] +fn test_veto_upgrade_rejects_unauthorized_caller() { + let env = Env::default(); + env.mock_all_auths(); + let ctx = setup(&env); + + ctx.client + .propose_upgrade(&ctx.admin, &placeholder_wasm_hash(&env)); + + let outsider = Address::generate(&env); + let res = ctx.client.try_veto_upgrade(&outsider); + assert!( + res.is_err(), + "veto by a non-guardian, non-admin caller should be rejected" + ); +} + +#[test] +fn test_veto_upgrade_rejects_when_no_pending_proposal() { + let env = Env::default(); + env.mock_all_auths(); + let ctx = setup(&env); + + let res = ctx.client.try_veto_upgrade(&ctx.admin); + assert!( + res.is_err(), + "veto without a pending proposal should be rejected" + ); +} + +// ── Timelock configuration ─────────────────────────────────────────────────── + +#[test] +fn test_set_upgrade_timelock_changes_future_proposals() { + let env = Env::default(); + env.mock_all_auths(); + let ctx = setup(&env); + + let new_timelock: u64 = 7 * 24 * 3600; // 7 days + ctx.client.set_upgrade_timelock(&ctx.admin, &new_timelock); + assert_eq!(ctx.client.get_upgrade_timelock(), new_timelock); + + let proposal = ctx + .client + .propose_upgrade(&ctx.admin, &placeholder_wasm_hash(&env)); + assert_eq!(proposal.ready_at, proposal.proposed_at + new_timelock); +} + +#[test] +fn test_set_upgrade_timelock_rejects_below_safety_floor() { + let env = Env::default(); + env.mock_all_auths(); + let ctx = setup(&env); + + let res = ctx + .client + .try_set_upgrade_timelock(&ctx.admin, &(MIN_TIMELOCK_SECONDS - 1)); + assert!( + res.is_err(), + "timelock below the safety floor should be rejected" + ); +} + +#[test] +fn test_set_upgrade_timelock_rejects_non_admin() { + let env = Env::default(); + env.mock_all_auths(); + let ctx = setup(&env); + + let outsider = Address::generate(&env); + let res = ctx + .client + .try_set_upgrade_timelock(&outsider, &(7 * 24 * 3600)); + assert!(res.is_err(), "non-admin timelock change should be rejected"); +}