Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 30 additions & 0 deletions contracts/sorosave/src/admin.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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();

Expand Down
1 change: 1 addition & 0 deletions contracts/sorosave/src/errors.rs
Original file line number Diff line number Diff line change
Expand Up @@ -22,4 +22,5 @@ pub enum ContractError {
InsufficientMembers = 16,
RoundNotComplete = 17,
GroupCompleted = 18,
ProtocolPaused = 19,
}
27 changes: 27 additions & 0 deletions contracts/sorosave/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ impl SoroSaveContract {
cycle_length: u64,
max_members: u32,
) -> Result<u64, ContractError> {
storage::ensure_protocol_active(&env)?;
group::create_group(
&env,
admin,
Expand All @@ -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)
}

Expand All @@ -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)
}

Expand Down Expand Up @@ -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)
}

Expand All @@ -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)
}

Expand All @@ -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)
}

Expand All @@ -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)
}

Expand All @@ -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)
}
}
Expand Down
27 changes: 27 additions & 0 deletions contracts/sorosave/src/storage.rs
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -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 {
Expand Down
125 changes: 124 additions & 1 deletion contracts/sorosave/src/test.rs
Original file line number Diff line number Diff line change
@@ -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();
Expand Down Expand Up @@ -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));
}
1 change: 1 addition & 0 deletions contracts/sorosave/src/types.rs
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ pub struct Dispute {
#[derive(Clone)]
pub enum DataKey {
Admin,
ProtocolPaused,
GroupCounter,
Group(u64),
Round(u64, u32),
Expand Down