Skip to content
Closed
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
2 changes: 2 additions & 0 deletions contracts/sorosave/src/errors.rs
Original file line number Diff line number Diff line change
Expand Up @@ -22,4 +22,6 @@ pub enum ContractError {
InsufficientMembers = 16,
RoundNotComplete = 17,
GroupCompleted = 18,
ProtocolPaused = 19,
ProtocolNotPaused = 20,
}
64 changes: 64 additions & 0 deletions contracts/sorosave/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -24,6 +32,7 @@ impl SoroSaveContract {
panic!("already initialized");
}
storage::set_admin(&env, &admin);
storage::set_protocol_paused(&env, false);
}

// ─── Group Lifecycle ────────────────────────────────────────────
Expand All @@ -38,6 +47,7 @@ impl SoroSaveContract {
cycle_length: u64,
max_members: u32,
) -> Result<u64, ContractError> {
require_protocol_active(&env)?;
group::create_group(
&env,
admin,
Expand All @@ -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)
}

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

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

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

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

Expand All @@ -151,6 +213,7 @@ impl SoroSaveContract {
admin: Address,
group_id: u64,
) -> Result<(), ContractError> {
require_protocol_active(&env)?;
admin::emergency_withdraw(&env, admin, group_id)
}

Expand All @@ -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)
}
}
Expand Down
14 changes: 14 additions & 0 deletions contracts/sorosave/src/storage.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
112 changes: 112 additions & 0 deletions contracts/sorosave/src/test.rs
Original file line number Diff line number Diff line change
@@ -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};
Expand Down Expand Up @@ -38,6 +41,13 @@ fn create_test_group(
)
}

fn assert_panics<F, R>(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();
Expand Down Expand Up @@ -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);
}
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