From 512b4778cf35e26532a924c8f1cb836c1c4301f9 Mon Sep 17 00:00:00 2001 From: JinadJay Date: Tue, 25 Aug 2026 13:16:09 +0100 Subject: [PATCH] feat(contract): add emergency pause to all three contracts (#315) --- contracts/dao-governance-contract/src/lib.rs | 113 +++++++++++++++++++ contracts/escrow-contract/src/lib.rs | 100 ++++++++++++++++ contracts/greenpay-contract/src/lib.rs | 93 +++++++++++++++ 3 files changed, 306 insertions(+) diff --git a/contracts/dao-governance-contract/src/lib.rs b/contracts/dao-governance-contract/src/lib.rs index 128d92ab..6f4db699 100644 --- a/contracts/dao-governance-contract/src/lib.rs +++ b/contracts/dao-governance-contract/src/lib.rs @@ -132,6 +132,7 @@ pub enum DataKey { /// `lock_tokens` and `withdraw`). Used as the denominator for the /// proportional quorum so it never requires iterating over lockers. TotalLocked, + Paused, } // ─── Contract ──────────────────────────────────────────────────────────────── @@ -173,6 +174,7 @@ impl DaoGovernanceContract { env.storage().instance().set(&DataKey::Config, &config); env.storage().instance().set(&DataKey::ProposalCount, &0u64); env.storage().instance().set(&DataKey::TotalLocked, &0i128); + env.storage().instance().set(&DataKey::Paused, &false); env.storage() .instance() .extend_ttl(MIN_VOTING_WINDOW, MAX_LOCK_LEDGERS); @@ -190,6 +192,7 @@ impl DaoGovernanceContract { pub fn lock_tokens(env: Env, voter: Address, amount: i128, lock_duration_ledgers: u32) { voter.require_auth(); + Self::require_not_paused(&env); if amount <= 0 { panic!("amount must be positive"); } @@ -264,6 +267,7 @@ impl DaoGovernanceContract { pub fn extend_lock(env: Env, voter: Address, new_unlock_ledger: u32) { voter.require_auth(); + Self::require_not_paused(&env); let lock_key = DataKey::Lock(voter.clone()); if !env.storage().persistent().has(&lock_key) { panic!("no active lock"); @@ -311,6 +315,7 @@ impl DaoGovernanceContract { pub fn withdraw(env: Env, voter: Address) { voter.require_auth(); + Self::require_not_paused(&env); let lock_key = DataKey::Lock(voter.clone()); if !env.storage().persistent().has(&lock_key) { panic!("no lock found"); @@ -442,6 +447,52 @@ impl DaoGovernanceContract { // Consequently, `dao_admin` has the authority to unilaterally veto a passed // proposal by removing its target from the allowlist before execution occurs. + // ─── Emergency pause ──────────────────────────────────────────────────── + + pub fn pause(env: Env, caller: Address) { + caller.require_auth(); + let config: Config = env + .storage() + .instance() + .get(&DataKey::Config) + .expect("Not initialized"); + if config.dao_admin != caller { + panic!("Only admin can pause"); + } + env.storage().instance().set(&DataKey::Paused, &true); + } + + pub fn unpause(env: Env, caller: Address) { + caller.require_auth(); + let config: Config = env + .storage() + .instance() + .get(&DataKey::Config) + .expect("Not initialized"); + if config.dao_admin != caller { + panic!("Only admin can unpause"); + } + env.storage().instance().set(&DataKey::Paused, &false); + } + + pub fn is_paused(env: Env) -> bool { + env.storage() + .instance() + .get(&DataKey::Paused) + .unwrap_or(false) + } + + fn require_not_paused(env: &Env) { + let paused: bool = env + .storage() + .instance() + .get(&DataKey::Paused) + .unwrap_or(false); + if paused { + panic!("Contract is paused"); + } + } + /// Adds a `(target_contract, function)` pair to the execution allowlist. /// /// # Access Control @@ -530,6 +581,7 @@ impl DaoGovernanceContract { calldata: Bytes, ) -> u64 { proposer.require_auth(); + Self::require_not_paused(&env); let current = env.ledger().sequence(); let power = Self::get_voting_power(env.clone(), proposer.clone(), current); if power <= 0 { @@ -763,6 +815,7 @@ impl DaoGovernanceContract { /// time. If `dao_admin` removed the target entry mid-flight (during discussion, /// voting, or timelock), execution panics with `"target/function not allowlisted"`. pub fn execute_proposal(env: Env, proposal_id: u64) { + Self::require_not_paused(&env); let key = DataKey::Proposal(proposal_id); let proposal: Proposal = env .storage() @@ -2528,4 +2581,64 @@ mod tests { client.execute_proposal(&pid); assert_eq!(client.get_proposal(&pid).stage, ProposalStage::Executed); } + + // ─── Pause / emergency-stop tests ────────────────────────────────────── + + #[test] + fn test_pause_and_unpause() { + let env = Env::default(); + env.mock_all_auths(); + let (_cid, cfg, client) = deploy(&env); + assert!(!client.is_paused()); + client.pause(&cfg.dao_admin); + assert!(client.is_paused()); + client.unpause(&cfg.dao_admin); + assert!(!client.is_paused()); + } + + #[test] + #[should_panic(expected = "Only admin can pause")] + fn test_pause_non_admin_fails() { + let env = Env::default(); + env.mock_all_auths(); + let (_cid, _cfg, client) = deploy(&env); + let rando = Address::generate(&env); + client.pause(&rando); + } + + #[test] + #[should_panic(expected = "Contract is paused")] + fn test_lock_tokens_rejected_when_paused() { + let env = Env::default(); + env.mock_all_auths(); + let (_cid, cfg, client) = deploy(&env); + client.pause(&cfg.dao_admin); + let voter = Address::generate(&env); + let token = StellarAssetClient::new(&env, &cfg.gp_token); + token.mint(&voter, &1_000_000); + client.lock_tokens(&voter, &1_000_000, &MIN_LOCK_LEDGERS); + } + + #[test] + #[should_panic(expected = "Contract is paused")] + fn test_create_proposal_rejected_when_paused() { + let env = Env::default(); + env.mock_all_auths(); + let (_cid, cfg, client) = deploy(&env); + let proposer = Address::generate(&env); + let token = StellarAssetClient::new(&env, &cfg.gp_token); + token.mint(&proposer, &1_000_000); + client.lock_tokens(&proposer, &1_000_000, &MIN_LOCK_LEDGERS); + client.pause(&cfg.dao_admin); + let target = Address::generate(&env); + let function = Symbol::new(&env, "do_thing"); + client.create_proposal( + &proposer, + &soroban_sdk::String::from_str(&env, "Title"), + &soroban_sdk::String::from_str(&env, "Desc"), + &target, + &function, + &soroban_sdk::Bytes::new(&env), + ); + } } diff --git a/contracts/escrow-contract/src/lib.rs b/contracts/escrow-contract/src/lib.rs index f15a8737..f7dbd047 100644 --- a/contracts/escrow-contract/src/lib.rs +++ b/contracts/escrow-contract/src/lib.rs @@ -60,6 +60,7 @@ pub enum DataKey { Admin, Job(String), AllowedToken(Address), + Paused, } #[contract] @@ -73,6 +74,53 @@ impl EscrowContract { panic!("Contract already initialized"); } env.storage().instance().set(&DataKey::Admin, &admin); + env.storage().instance().set(&DataKey::Paused, &false); + } + + /// Emergency pause — blocks all fund-moving operations. + pub fn pause(env: Env, admin: Address) { + admin.require_auth(); + let stored_admin: Address = env + .storage() + .instance() + .get(&DataKey::Admin) + .expect("Not initialized"); + if stored_admin != admin { + panic!("Only admin can pause"); + } + env.storage().instance().set(&DataKey::Paused, &true); + } + + /// Lift the emergency pause. + pub fn unpause(env: Env, admin: Address) { + admin.require_auth(); + let stored_admin: Address = env + .storage() + .instance() + .get(&DataKey::Admin) + .expect("Not initialized"); + if stored_admin != admin { + panic!("Only admin can unpause"); + } + env.storage().instance().set(&DataKey::Paused, &false); + } + + pub fn is_paused(env: Env) -> bool { + env.storage() + .instance() + .get(&DataKey::Paused) + .unwrap_or(false) + } + + fn require_not_paused(env: &Env) { + let paused: bool = env + .storage() + .instance() + .get(&DataKey::Paused) + .unwrap_or(false); + if paused { + panic!("Contract is paused"); + } } /// Allows a specific token to be used for jobs. @@ -121,6 +169,7 @@ impl EscrowContract { expiry_ledger: u32, ) { client.require_auth(); + Self::require_not_paused(&env); if amount <= 0 { panic!("Amount must be positive"); } @@ -159,6 +208,7 @@ impl EscrowContract { /// Client authorizes full release of remaining locked funds to the freelancer. pub fn release_escrow(env: Env, client: Address, job_id: String) { client.require_auth(); + Self::require_not_paused(&env); let mut job: Job = env .storage() .instance() @@ -256,6 +306,7 @@ impl EscrowContract { /// Admin resolves a disputed job: releases remaining funds to freelancer or refunds remaining funds to client. pub fn resolve_dispute(env: Env, admin: Address, job_id: String, release_to_freelancer: bool) { admin.require_auth(); + Self::require_not_paused(&env); let stored_admin: Address = env .storage() .instance() @@ -311,6 +362,7 @@ impl EscrowContract { /// CEI ordering: all state writes happen before both token transfers. pub fn resolve_stale_dispute(env: Env, caller: Address, job_id: String) { caller.require_auth(); + Self::require_not_paused(&env); let mut job: Job = env .storage() @@ -363,6 +415,7 @@ impl EscrowContract { /// a full release or a dispute being raised. pub fn cancel_job(env: Env, client: Address, job_id: String) { client.require_auth(); + Self::require_not_paused(&env); let mut job: Job = env .storage() .instance() @@ -1243,4 +1296,51 @@ mod tests { assert_eq!(job.remaining_amount, 100); assert_eq!(token.balance(&freelancer), 0); } + + // ─── Pause / emergency-stop tests ────────────────────────────────────── + + #[test] + fn test_pause_and_unpause() { + let (_env, client, admin, _c, _f, _t, _j, _a, _e) = setup(); + assert!(!client.is_paused()); + client.pause(&admin); + assert!(client.is_paused()); + client.unpause(&admin); + assert!(!client.is_paused()); + } + + #[test] + #[should_panic(expected = "Only admin can pause")] + fn test_pause_non_admin_fails() { + let (env, client, _admin, _c, _f, _t, _j, _a, _e) = setup(); + let rando = Address::generate(&env); + client.pause(&rando); + } + + #[test] + #[should_panic(expected = "Contract is paused")] + fn test_create_job_rejected_when_paused() { + let (env, client, admin, client_addr, freelancer, token, _j, amount, expiry) = setup(); + let job_id = soroban_sdk::String::from_str(&env, "new-job"); + client.pause(&admin); + client.create_job(&client_addr, &freelancer, &job_id, &token, &amount, &expiry); + } + + #[test] + #[should_panic(expected = "Contract is paused")] + fn test_release_escrow_rejected_when_paused() { + let (_env, client, admin, client_addr, _f, _t, job_id, _a, _e) = setup(); + client.pause(&admin); + client.release_escrow(&client_addr, &job_id); + } + + #[test] + #[should_panic(expected = "Contract is paused")] + fn test_cancel_job_rejected_when_paused() { + let (env, client, admin, client_addr, _f, _t, job_id, _a, _e) = setup(); + // Advance past expiry so cancel is otherwise valid + env.ledger().set_sequence_number(1001); + client.pause(&admin); + client.cancel_job(&client_addr, &job_id); + } } diff --git a/contracts/greenpay-contract/src/lib.rs b/contracts/greenpay-contract/src/lib.rs index e3506f70..0233abdb 100644 --- a/contracts/greenpay-contract/src/lib.rs +++ b/contracts/greenpay-contract/src/lib.rs @@ -163,6 +163,8 @@ pub enum DataKey { // storage as it grows. ImpactVerifier(Address), ImpactAttestation(String), + // Emergency pause flag — when true, fund-moving functions are blocked. + Paused, } // ─── Constants ──────────────────────────────────────────────────────────────── @@ -489,6 +491,53 @@ impl GreenPayContract { env.storage() .instance() .set(&DataKey::GlobalCO2OffsetGrams, &0i128); + env.storage().instance().set(&DataKey::Paused, &false); + } + + // ─── Emergency pause ──────────────────────────────────────────────────── + + pub fn pause(env: Env, admin: Address) { + admin.require_auth(); + let stored_admin: Address = env + .storage() + .instance() + .get(&DataKey::Admin) + .expect("Not initialized"); + if stored_admin != admin { + panic!("Only admin can pause"); + } + env.storage().instance().set(&DataKey::Paused, &true); + } + + pub fn unpause(env: Env, admin: Address) { + admin.require_auth(); + let stored_admin: Address = env + .storage() + .instance() + .get(&DataKey::Admin) + .expect("Not initialized"); + if stored_admin != admin { + panic!("Only admin can unpause"); + } + env.storage().instance().set(&DataKey::Paused, &false); + } + + pub fn is_paused(env: Env) -> bool { + env.storage() + .instance() + .get(&DataKey::Paused) + .unwrap_or(false) + } + + fn require_not_paused(env: &Env) { + let paused: bool = env + .storage() + .instance() + .get(&DataKey::Paused) + .unwrap_or(false); + if paused { + panic!("Contract is paused"); + } } // ─── Token allowlist ────────────────────────────────────────────────────── @@ -599,6 +648,7 @@ impl GreenPayContract { msg_hash: u32, ) { donor.require_auth(); + Self::require_not_paused(&env); if amount <= 0 { panic!("Donation amount must be positive"); } @@ -1203,6 +1253,7 @@ impl GreenPayContract { /// [`MIN_VOTING_WINDOW_LEDGERS`, `MAX_VOTING_WINDOW_LEDGERS`]. pub fn create_proposal(env: Env, admin: Address, project_id: String, duration_ledgers: u32) { admin.require_auth(); + Self::require_not_paused(&env); if env.storage().instance().has(&DataKey::DaoContract) { panic!("DAO governance is active; legacy proposals are retired"); } @@ -1270,6 +1321,7 @@ impl GreenPayContract { /// enforced to prevent double-counting. pub fn vote_verify_project(env: Env, voter: Address, project_id: String, approve: bool) { voter.require_auth(); + Self::require_not_paused(&env); if env.storage().instance().has(&DataKey::DaoContract) { panic!("DAO governance is active; legacy voting is retired"); } @@ -1418,6 +1470,7 @@ mod tests { assert_eq!(client.get_project_count(), 0); assert_eq!(client.get_donation_count(), 0); assert_eq!(client.get_global_total(), 0); + assert!(!client.is_paused()); } #[test] @@ -1473,6 +1526,46 @@ mod tests { client.initialize(&admin); } + // ─── Pause / emergency-stop tests ────────────────────────────────────── + + #[test] + fn test_pause_and_unpause() { + let (_env, _cid, client, admin, _pid) = setup(); + assert!(!client.is_paused()); + client.pause(&admin); + assert!(client.is_paused()); + client.unpause(&admin); + assert!(!client.is_paused()); + } + + #[test] + #[should_panic(expected = "Only admin can pause")] + fn test_pause_non_admin_fails() { + let (env, _cid, client, _admin, _pid) = setup(); + let rando = Address::generate(&env); + client.pause(&rando); + } + + #[test] + #[should_panic(expected = "Only admin can unpause")] + fn test_unpause_non_admin_fails() { + let (env, _cid, client, _admin, _pid) = setup(); + let rando = Address::generate(&env); + client.unpause(&rando); + } + + #[test] + #[should_panic(expected = "Contract is paused")] + fn test_donate_rejected_when_paused() { + let (env, _cid, client, admin, pid) = setup(); + let donor = Address::generate(&env); + let token = env + .register_stellar_asset_contract_v2(Address::generate(&env)) + .address(); + client.pause(&admin); + client.donate(&token, &donor, &pid, &1000, &1u32); + } + #[test] fn test_donor_badge_none_below_threshold() { let env = Env::default();