From ac86e8e2bb2aecd98d444e33d8be51127a9588bb Mon Sep 17 00:00:00 2001 From: Tu Pham Date: Mon, 25 May 2026 17:22:11 +0700 Subject: [PATCH] feat: add global protocol pause --- contracts/sorosave/src/admin.rs | 30 +++++++ contracts/sorosave/src/errors.rs | 1 + contracts/sorosave/src/lib.rs | 27 +++++++ contracts/sorosave/src/storage.rs | 27 +++++++ contracts/sorosave/src/test.rs | 125 +++++++++++++++++++++++++++++- contracts/sorosave/src/types.rs | 1 + 6 files changed, 210 insertions(+), 1 deletion(-) diff --git a/contracts/sorosave/src/admin.rs b/contracts/sorosave/src/admin.rs index 049b6ce..b43a0a0 100644 --- a/contracts/sorosave/src/admin.rs +++ b/contracts/sorosave/src/admin.rs @@ -4,6 +4,36 @@ use crate::errors::ContractError; use crate::storage; use crate::types::{Dispute, GroupStatus}; +pub fn pause_protocol(env: &Env, admin: Address) -> Result<(), ContractError> { + admin.require_auth(); + + if admin != storage::get_admin(env) { + return Err(ContractError::Unauthorized); + } + + storage::set_protocol_paused(env, true); + + env.events() + .publish((crate::symbol_short!("proto_ps"),), admin); + + Ok(()) +} + +pub fn unpause_protocol(env: &Env, admin: Address) -> Result<(), ContractError> { + admin.require_auth(); + + if admin != storage::get_admin(env) { + return Err(ContractError::Unauthorized); + } + + storage::set_protocol_paused(env, false); + + env.events() + .publish((crate::symbol_short!("proto_up"),), admin); + + Ok(()) +} + pub fn pause_group(env: &Env, admin: Address, group_id: u64) -> Result<(), ContractError> { admin.require_auth(); diff --git a/contracts/sorosave/src/errors.rs b/contracts/sorosave/src/errors.rs index a2b9d9d..d7b41a1 100644 --- a/contracts/sorosave/src/errors.rs +++ b/contracts/sorosave/src/errors.rs @@ -22,4 +22,5 @@ pub enum ContractError { InsufficientMembers = 16, RoundNotComplete = 17, GroupCompleted = 18, + ProtocolPaused = 19, } diff --git a/contracts/sorosave/src/lib.rs b/contracts/sorosave/src/lib.rs index 454a6ca..3b21f07 100644 --- a/contracts/sorosave/src/lib.rs +++ b/contracts/sorosave/src/lib.rs @@ -38,6 +38,7 @@ impl SoroSaveContract { cycle_length: u64, max_members: u32, ) -> Result { + storage::ensure_protocol_active(&env)?; group::create_group( &env, admin, @@ -51,16 +52,19 @@ impl SoroSaveContract { /// Join an existing group that is still forming. pub fn join_group(env: Env, member: Address, group_id: u64) -> Result<(), ContractError> { + storage::ensure_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> { + storage::ensure_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> { + storage::ensure_protocol_active(&env)?; group::start_group(&env, admin, group_id) } @@ -78,6 +82,7 @@ impl SoroSaveContract { /// Contribute to the current round of a group. pub fn contribute(env: Env, member: Address, group_id: u64) -> Result<(), ContractError> { + storage::ensure_protocol_active(&env)?; contribution::contribute(&env, member, group_id) } @@ -105,6 +110,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> { + storage::ensure_protocol_active(&env)?; payout::distribute_payout(&env, group_id) } @@ -120,13 +126,30 @@ impl SoroSaveContract { // ─── Admin / Governance ───────────────────────────────────────── + /// Pause all protocol state-changing operations. + pub fn pause_protocol(env: Env, admin: Address) -> Result<(), ContractError> { + admin::pause_protocol(&env, admin) + } + + /// Resume all protocol state-changing operations. + pub fn unpause_protocol(env: Env, admin: Address) -> Result<(), ContractError> { + admin::unpause_protocol(&env, admin) + } + + /// Return whether the protocol-wide pause switch is active. + 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> { + storage::ensure_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> { + storage::ensure_protocol_active(&env)?; admin::resume_group(&env, admin, group_id) } @@ -137,11 +160,13 @@ impl SoroSaveContract { group_id: u64, reason: String, ) -> Result<(), ContractError> { + storage::ensure_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> { + storage::ensure_protocol_active(&env)?; admin::resolve_dispute(&env, admin, group_id) } @@ -151,6 +176,7 @@ impl SoroSaveContract { admin: Address, group_id: u64, ) -> Result<(), ContractError> { + storage::ensure_protocol_active(&env)?; admin::emergency_withdraw(&env, admin, group_id) } @@ -161,6 +187,7 @@ impl SoroSaveContract { group_id: u64, new_admin: Address, ) -> Result<(), ContractError> { + storage::ensure_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..da01cdf 100644 --- a/contracts/sorosave/src/storage.rs +++ b/contracts/sorosave/src/storage.rs @@ -1,5 +1,6 @@ use soroban_sdk::{Address, Env, Vec}; +use crate::errors::ContractError; use crate::types::{DataKey, Dispute, RoundInfo, SavingsGroup}; const INSTANCE_TTL_THRESHOLD: u32 = 100; @@ -22,6 +23,32 @@ pub fn has_admin(env: &Env) -> bool { env.storage().instance().has(&DataKey::Admin) } +// --- Protocol Pause --- + +pub fn is_protocol_paused(env: &Env) -> bool { + let paused = env + .storage() + .instance() + .get(&DataKey::ProtocolPaused) + .unwrap_or(false); + extend_instance_ttl(env); + paused +} + +pub fn set_protocol_paused(env: &Env, paused: bool) { + env.storage() + .instance() + .set(&DataKey::ProtocolPaused, &paused); + extend_instance_ttl(env); +} + +pub fn ensure_protocol_active(env: &Env) -> Result<(), ContractError> { + if is_protocol_paused(env) { + return Err(ContractError::ProtocolPaused); + } + Ok(()) +} + // --- 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..a692a5f 100644 --- a/contracts/sorosave/src/test.rs +++ b/contracts/sorosave/src/test.rs @@ -1,7 +1,7 @@ use soroban_sdk::{testutils::Address as _, token::StellarAssetClient, Address, Env, String}; use crate::types::GroupStatus; -use crate::{SoroSaveContract, SoroSaveContractClient}; +use crate::{ContractError, SoroSaveContract, SoroSaveContractClient}; fn setup_env() -> (Env, Address, SoroSaveContractClient<'static>, Address) { let env = Env::default(); @@ -222,3 +222,126 @@ fn test_set_group_admin() { let group = client.get_group(&group_id); assert_eq!(group.admin, new_admin); } + +#[test] +fn test_protocol_pause_requires_protocol_admin() { + let (env, admin, client, _token) = setup_env(); + let member = Address::generate(&env); + + assert!(!client.is_protocol_paused()); + assert_eq!( + client.try_pause_protocol(&member), + Err(Ok(ContractError::Unauthorized)) + ); + + client.pause_protocol(&admin); + assert!(client.is_protocol_paused()); + + assert_eq!( + client.try_unpause_protocol(&member), + Err(Ok(ContractError::Unauthorized)) + ); + + client.unpause_protocol(&admin); + assert!(!client.is_protocol_paused()); +} + +#[test] +fn test_protocol_pause_blocks_group_lifecycle_mutations() { + let (env, admin, client, token) = setup_env(); + let group_id = create_test_group(&env, &client, &admin, &token); + let member1 = Address::generate(&env); + client.join_group(&member1, &group_id); + + client.pause_protocol(&admin); + + let member2 = Address::generate(&env); + assert_eq!( + client.try_create_group( + &admin, + &String::from_str(&env, "Paused Create"), + &token, + &1_000_000, + &86400, + &5, + ), + Err(Ok(ContractError::ProtocolPaused)) + ); + assert_eq!( + client.try_join_group(&member2, &group_id), + Err(Ok(ContractError::ProtocolPaused)) + ); + assert_eq!( + client.try_leave_group(&member1, &group_id), + Err(Ok(ContractError::ProtocolPaused)) + ); + assert_eq!( + client.try_start_group(&admin, &group_id), + Err(Ok(ContractError::ProtocolPaused)) + ); + + client.unpause_protocol(&admin); + client.start_group(&admin, &group_id); + assert_eq!(client.get_group(&group_id).status, GroupStatus::Active); +} + +#[test] +fn test_protocol_pause_blocks_active_group_mutations_until_unpaused() { + let env = Env::default(); + env.mock_all_auths(); + + let admin = Address::generate(&env); + let contract_id = env.register(SoroSaveContract, (&admin,)); + let client = SoroSaveContractClient::new(&env, &contract_id); + + let token_admin = Address::generate(&env); + let token_id = env.register_stellar_asset_contract_v2(token_admin); + let token = token_id.address(); + let token_sac = StellarAssetClient::new(&env, &token); + + let member1 = Address::generate(&env); + token_sac.mint(&admin, &10_000_000); + token_sac.mint(&member1, &10_000_000); + + let group_id = client.create_group( + &admin, + &String::from_str(&env, "Paused Active Group"), + &token, + &1_000_000, + &86400, + &5, + ); + client.join_group(&member1, &group_id); + client.start_group(&admin, &group_id); + + client.pause_protocol(&admin); + + assert_eq!( + client.try_contribute(&member1, &group_id), + Err(Ok(ContractError::ProtocolPaused)) + ); + assert_eq!( + client.try_distribute_payout(&group_id), + Err(Ok(ContractError::ProtocolPaused)) + ); + assert_eq!( + client.try_pause_group(&admin, &group_id), + Err(Ok(ContractError::ProtocolPaused)) + ); + assert_eq!( + client.try_raise_dispute( + &member1, + &group_id, + &String::from_str(&env, "Blocked while paused"), + ), + Err(Ok(ContractError::ProtocolPaused)) + ); + assert_eq!( + client.try_set_group_admin(&admin, &group_id, &member1), + Err(Ok(ContractError::ProtocolPaused)) + ); + + client.unpause_protocol(&admin); + client.contribute(&member1, &group_id); + assert!(client.has_contributed(&member1, &group_id, &1)); +} 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),