diff --git a/contracts/sorosave/src/errors.rs b/contracts/sorosave/src/errors.rs index a2b9d9d..d996d97 100644 --- a/contracts/sorosave/src/errors.rs +++ b/contracts/sorosave/src/errors.rs @@ -22,4 +22,6 @@ pub enum ContractError { InsufficientMembers = 16, RoundNotComplete = 17, GroupCompleted = 18, + ProtocolPaused = 19, + ProtocolNotPaused = 20, } diff --git a/contracts/sorosave/src/lib.rs b/contracts/sorosave/src/lib.rs index 454a6ca..9295ca4 100644 --- a/contracts/sorosave/src/lib.rs +++ b/contracts/sorosave/src/lib.rs @@ -16,6 +16,14 @@ pub use types::*; #[contract] pub struct SoroSaveContract; +fn require_protocol_active(env: &Env) -> Result<(), ContractError> { + if storage::is_protocol_paused(env) { + Err(ContractError::ProtocolPaused) + } else { + Ok(()) + } +} + #[contractimpl] impl SoroSaveContract { /// Initialize the protocol with a global admin. @@ -24,6 +32,7 @@ impl SoroSaveContract { panic!("already initialized"); } storage::set_admin(&env, &admin); + storage::set_protocol_paused(&env, false); } // ─── Group Lifecycle ──────────────────────────────────────────── @@ -38,6 +47,7 @@ impl SoroSaveContract { cycle_length: u64, max_members: u32, ) -> Result { + require_protocol_active(&env)?; group::create_group( &env, admin, @@ -51,16 +61,19 @@ impl SoroSaveContract { /// Join an existing group that is still forming. pub fn join_group(env: Env, member: Address, group_id: u64) -> Result<(), ContractError> { + require_protocol_active(&env)?; group::join_group(&env, member, group_id) } /// Leave a group (only allowed while group is still forming). pub fn leave_group(env: Env, member: Address, group_id: u64) -> Result<(), ContractError> { + require_protocol_active(&env)?; group::leave_group(&env, member, group_id) } /// Start the group rounds. Only the group admin can call this. pub fn start_group(env: Env, admin: Address, group_id: u64) -> Result<(), ContractError> { + require_protocol_active(&env)?; group::start_group(&env, admin, group_id) } @@ -78,6 +91,7 @@ impl SoroSaveContract { /// Contribute to the current round of a group. pub fn contribute(env: Env, member: Address, group_id: u64) -> Result<(), ContractError> { + require_protocol_active(&env)?; contribution::contribute(&env, member, group_id) } @@ -105,6 +119,7 @@ impl SoroSaveContract { /// Distribute the pot to the current round's recipient. Anyone can call this /// once all contributions are in. pub fn distribute_payout(env: Env, group_id: u64) -> Result<(), ContractError> { + require_protocol_active(&env)?; payout::distribute_payout(&env, group_id) } @@ -120,13 +135,58 @@ impl SoroSaveContract { // ─── Admin / Governance ───────────────────────────────────────── + /// Pause all protocol mutations. Only the protocol admin can call this. + pub fn pause_protocol(env: Env, admin: Address) -> Result<(), ContractError> { + admin.require_auth(); + + if admin != storage::get_admin(&env) { + return Err(ContractError::Unauthorized); + } + + if storage::is_protocol_paused(&env) { + return Err(ContractError::ProtocolPaused); + } + + storage::set_protocol_paused(&env, true); + env.events() + .publish((symbol_short!("prot_paus"),), admin); + + Ok(()) + } + + /// Resume protocol mutations after a global pause. + pub fn unpause_protocol(env: Env, admin: Address) -> Result<(), ContractError> { + admin.require_auth(); + + if admin != storage::get_admin(&env) { + return Err(ContractError::Unauthorized); + } + + if !storage::is_protocol_paused(&env) { + return Err(ContractError::ProtocolNotPaused); + } + + storage::set_protocol_paused(&env, false); + env.events() + .publish((symbol_short!("prot_resm"),), admin); + + Ok(()) + } + + /// Read the current global protocol pause state. + pub fn is_protocol_paused(env: Env) -> bool { + storage::is_protocol_paused(&env) + } + /// Pause an active group. pub fn pause_group(env: Env, admin: Address, group_id: u64) -> Result<(), ContractError> { + require_protocol_active(&env)?; admin::pause_group(&env, admin, group_id) } /// Resume a paused group. pub fn resume_group(env: Env, admin: Address, group_id: u64) -> Result<(), ContractError> { + require_protocol_active(&env)?; admin::resume_group(&env, admin, group_id) } @@ -137,11 +197,13 @@ impl SoroSaveContract { group_id: u64, reason: String, ) -> Result<(), ContractError> { + require_protocol_active(&env)?; admin::raise_dispute(&env, member, group_id, reason) } /// Resolve a dispute (group admin or protocol admin). pub fn resolve_dispute(env: Env, admin: Address, group_id: u64) -> Result<(), ContractError> { + require_protocol_active(&env)?; admin::resolve_dispute(&env, admin, group_id) } @@ -151,6 +213,7 @@ impl SoroSaveContract { admin: Address, group_id: u64, ) -> Result<(), ContractError> { + require_protocol_active(&env)?; admin::emergency_withdraw(&env, admin, group_id) } @@ -161,6 +224,7 @@ impl SoroSaveContract { group_id: u64, new_admin: Address, ) -> Result<(), ContractError> { + require_protocol_active(&env)?; admin::set_group_admin(&env, current_admin, group_id, new_admin) } } diff --git a/contracts/sorosave/src/storage.rs b/contracts/sorosave/src/storage.rs index 3f24bc8..afcb5e9 100644 --- a/contracts/sorosave/src/storage.rs +++ b/contracts/sorosave/src/storage.rs @@ -22,6 +22,20 @@ pub fn has_admin(env: &Env) -> bool { env.storage().instance().has(&DataKey::Admin) } +pub fn is_protocol_paused(env: &Env) -> bool { + env.storage() + .instance() + .get(&DataKey::ProtocolPaused) + .unwrap_or(false) +} + +pub fn set_protocol_paused(env: &Env, paused: bool) { + env.storage() + .instance() + .set(&DataKey::ProtocolPaused, &paused); + extend_instance_ttl(env); +} + // --- Group Counter --- pub fn get_group_counter(env: &Env) -> u64 { diff --git a/contracts/sorosave/src/test.rs b/contracts/sorosave/src/test.rs index f1ac1ef..e10c1a0 100644 --- a/contracts/sorosave/src/test.rs +++ b/contracts/sorosave/src/test.rs @@ -1,4 +1,7 @@ +extern crate std; + use soroban_sdk::{testutils::Address as _, token::StellarAssetClient, Address, Env, String}; +use self::std::panic::{catch_unwind, AssertUnwindSafe}; use crate::types::GroupStatus; use crate::{SoroSaveContract, SoroSaveContractClient}; @@ -38,6 +41,13 @@ fn create_test_group( ) } +fn assert_panics(f: F) +where + F: FnOnce() -> R, +{ + assert!(catch_unwind(AssertUnwindSafe(f)).is_err()); +} + #[test] fn test_create_group() { let (env, admin, client, token) = setup_env(); @@ -222,3 +232,105 @@ fn test_set_group_admin() { let group = client.get_group(&group_id); assert_eq!(group.admin, new_admin); } + +#[test] +fn test_only_protocol_admin_can_toggle_protocol_pause() { + let (env, admin, client, _token) = setup_env(); + let outsider = Address::generate(&env); + + assert_panics(|| client.pause_protocol(&outsider)); + + client.pause_protocol(&admin); + assert!(client.is_protocol_paused()); + + assert_panics(|| client.unpause_protocol(&outsider)); + + client.unpause_protocol(&admin); + assert!(!client.is_protocol_paused()); +} + +#[test] +fn test_protocol_pause_blocks_state_changes() { + let (env, admin, client, token) = setup_env(); + let member1 = Address::generate(&env); + let member2 = Address::generate(&env); + let replacement_admin = Address::generate(&env); + + let forming_group_id = create_test_group(&env, &client, &admin, &token); + client.join_group(&member1, &forming_group_id); + + let active_group_id = client.create_group( + &admin, + &String::from_str(&env, "Active Group"), + &token, + &1_000_000, + &86400, + &5, + ); + client.join_group(&member1, &active_group_id); + client.start_group(&admin, &active_group_id); + + client.pause_protocol(&admin); + assert!(client.is_protocol_paused()); + + assert_panics(|| { + client.create_group( + &admin, + &String::from_str(&env, "Blocked Group"), + &token, + &1_000_000, + &86400, + &5, + ) + }); + assert_panics(|| client.join_group(&member2, &forming_group_id)); + assert_panics(|| client.leave_group(&member1, &forming_group_id)); + assert_panics(|| client.start_group(&admin, &forming_group_id)); + assert_panics(|| client.contribute(&admin, &active_group_id)); + assert_panics(|| client.distribute_payout(&active_group_id)); + assert_panics(|| client.pause_group(&admin, &active_group_id)); + assert_panics(|| client.resume_group(&admin, &active_group_id)); + assert_panics(|| { + client.raise_dispute( + &member1, + &active_group_id, + &String::from_str(&env, "Protocol pause should block this"), + ) + }); + assert_panics(|| client.resolve_dispute(&admin, &active_group_id)); + assert_panics(|| client.emergency_withdraw(&admin, &active_group_id)); + assert_panics(|| client.set_group_admin(&admin, &active_group_id, &replacement_admin)); +} + +#[test] +fn test_protocol_unpause_restores_state_changes() { + let (env, admin, client, token) = setup_env(); + let member1 = Address::generate(&env); + let member2 = Address::generate(&env); + + let token_client = StellarAssetClient::new(&env, &token); + token_client.mint(&member1, &5_000_000); + token_client.mint(&member2, &5_000_000); + + let group_id = create_test_group(&env, &client, &admin, &token); + client.join_group(&member1, &group_id); + + client.pause_protocol(&admin); + assert_panics(|| client.join_group(&member2, &group_id)); + + client.unpause_protocol(&admin); + assert!(!client.is_protocol_paused()); + + client.join_group(&member2, &group_id); + client.start_group(&admin, &group_id); + client.contribute(&admin, &group_id); + client.contribute(&member1, &group_id); + client.contribute(&member2, &group_id); + + let round = client.get_round_status(&group_id, &1); + assert!(round.is_complete); + assert_eq!(round.total_contributed, 3_000_000); + + client.distribute_payout(&group_id); + assert_eq!(client.get_group(&group_id).current_round, 2); +} diff --git a/contracts/sorosave/src/types.rs b/contracts/sorosave/src/types.rs index f741099..e184e1d 100644 --- a/contracts/sorosave/src/types.rs +++ b/contracts/sorosave/src/types.rs @@ -56,6 +56,7 @@ pub struct Dispute { #[derive(Clone)] pub enum DataKey { Admin, + ProtocolPaused, GroupCounter, Group(u64), Round(u64, u32),