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
42 changes: 42 additions & 0 deletions contracts/sorosave/src/admin.rs
Original file line number Diff line number Diff line change
Expand Up @@ -173,3 +173,45 @@ pub fn set_group_admin(

Ok(())
}

pub fn set_protocol_fee_bps(env: &Env, admin: Address, fee_bps: u32) -> Result<(), ContractError> {
admin.require_auth();

if admin != storage::get_admin(env) {
return Err(ContractError::Unauthorized);
}

if fee_bps > 10_000 {
return Err(ContractError::InvalidFeeBps);
}

let mut config = storage::get_protocol_config(env);
config.protocol_fee_bps = fee_bps;
storage::set_protocol_config(env, &config);

env.events()
.publish((crate::symbol_short!("fee_set"),), fee_bps);

Ok(())
}

pub fn set_protocol_treasury(
env: &Env,
admin: Address,
treasury: Address,
) -> Result<(), ContractError> {
admin.require_auth();

if admin != storage::get_admin(env) {
return Err(ContractError::Unauthorized);
}

let mut config = storage::get_protocol_config(env);
config.treasury = treasury.clone();
storage::set_protocol_config(env, &config);

env.events()
.publish((crate::symbol_short!("treasury"),), treasury);

Ok(())
}
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,
InvalidFeeBps = 19,
}
30 changes: 30 additions & 0 deletions contracts/sorosave/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,13 @@ impl SoroSaveContract {
panic!("already initialized");
}
storage::set_admin(&env, &admin);
storage::set_protocol_config(
&env,
&ProtocolConfig {
protocol_fee_bps: 0,
treasury: admin,
},
);
}

// ─── Group Lifecycle ────────────────────────────────────────────
Expand Down Expand Up @@ -74,6 +81,11 @@ impl SoroSaveContract {
group::get_member_groups(&env, member)
}

/// Get the current protocol fee configuration.
pub fn get_protocol_config(env: Env) -> ProtocolConfig {
storage::get_protocol_config(&env)
}

// ─── Contributions ──────────────────────────────────────────────

/// Contribute to the current round of a group.
Expand Down Expand Up @@ -163,6 +175,24 @@ impl SoroSaveContract {
) -> Result<(), ContractError> {
admin::set_group_admin(&env, current_admin, group_id, new_admin)
}

/// Update the protocol fee rate in basis points.
pub fn set_protocol_fee_bps(
env: Env,
admin: Address,
fee_bps: u32,
) -> Result<(), ContractError> {
admin::set_protocol_fee_bps(&env, admin, fee_bps)
}

/// Update the treasury address that receives protocol fees.
pub fn set_protocol_treasury(
env: Env,
admin: Address,
treasury: Address,
) -> Result<(), ContractError> {
admin::set_protocol_treasury(&env, admin, treasury)
}
}

#[cfg(test)]
Expand Down
22 changes: 20 additions & 2 deletions contracts/sorosave/src/payout.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ use crate::types::{GroupStatus, RoundInfo};

pub fn distribute_payout(env: &Env, group_id: u64) -> Result<(), ContractError> {
let mut group = storage::get_group(env, group_id).ok_or(ContractError::GroupNotFound)?;
let protocol_config = storage::get_protocol_config(env);

if group.status != GroupStatus::Active {
return Err(ContractError::GroupNotActive);
Expand All @@ -20,18 +21,35 @@ pub fn distribute_payout(env: &Env, group_id: u64) -> Result<(), ContractError>

// Transfer the pot to the round's recipient
let token_client = soroban_sdk::token::Client::new(env, &group.token);
let fee_amount =
round_info.total_contributed * protocol_config.protocol_fee_bps as i128 / 10_000;
let recipient_amount = round_info.total_contributed - fee_amount;

if fee_amount > 0 {
token_client.transfer(
&env.current_contract_address(),
&protocol_config.treasury,
&fee_amount,
);

env.events().publish(
(crate::symbol_short!("prot_fee"),),
(group_id, protocol_config.treasury.clone(), fee_amount),
);
}

token_client.transfer(
&env.current_contract_address(),
&round_info.recipient,
&round_info.total_contributed,
&recipient_amount,
);

env.events().publish(
(crate::symbol_short!("payout"),),
(
group_id,
round_info.recipient.clone(),
round_info.total_contributed,
recipient_amount,
),
);

Expand Down
11 changes: 10 additions & 1 deletion contracts/sorosave/src/storage.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
use soroban_sdk::{Address, Env, Vec};

use crate::types::{DataKey, Dispute, RoundInfo, SavingsGroup};
use crate::types::{DataKey, Dispute, ProtocolConfig, RoundInfo, SavingsGroup};

const INSTANCE_TTL_THRESHOLD: u32 = 100;
const INSTANCE_TTL_EXTEND: u32 = 500;
Expand All @@ -22,6 +22,15 @@ pub fn has_admin(env: &Env) -> bool {
env.storage().instance().has(&DataKey::Admin)
}

pub fn get_protocol_config(env: &Env) -> ProtocolConfig {
env.storage().instance().get(&DataKey::ProtocolConfig).unwrap()
}

pub fn set_protocol_config(env: &Env, config: &ProtocolConfig) {
env.storage().instance().set(&DataKey::ProtocolConfig, config);
extend_instance_ttl(env);
}

// --- Group Counter ---

pub fn get_group_counter(env: &Env) -> u64 {
Expand Down
82 changes: 80 additions & 2 deletions contracts/sorosave/src/test.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,13 @@
use soroban_sdk::{testutils::Address as _, token::StellarAssetClient, Address, Env, String};
extern crate std;

use crate::types::GroupStatus;
use soroban_sdk::{
testutils::Address as _,
token::{Client as TokenClient, StellarAssetClient},
Address, Env, String,
};
use self::std::panic::{catch_unwind, AssertUnwindSafe};

use crate::types::{GroupStatus, ProtocolConfig};
use crate::{SoroSaveContract, SoroSaveContractClient};

fn setup_env() -> (Env, Address, SoroSaveContractClient<'static>, Address) {
Expand Down Expand Up @@ -38,6 +45,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 +236,67 @@ fn test_set_group_admin() {
let group = client.get_group(&group_id);
assert_eq!(group.admin, new_admin);
}

#[test]
fn test_protocol_fee_defaults_to_zero_and_admin_treasury() {
let (_env, admin, client, _token) = setup_env();

assert_eq!(
client.get_protocol_config(),
ProtocolConfig {
protocol_fee_bps: 0,
treasury: admin,
}
);
}

#[test]
fn test_only_admin_can_update_protocol_fee_config() {
let (env, admin, client, _token) = setup_env();
let outsider = Address::generate(&env);
let treasury = Address::generate(&env);

assert_panics(|| client.set_protocol_fee_bps(&outsider, &50));
assert_panics(|| client.set_protocol_treasury(&outsider, &treasury));
assert_panics(|| client.set_protocol_fee_bps(&admin, &10_001));

client.set_protocol_fee_bps(&admin, &50);
client.set_protocol_treasury(&admin, &treasury);

assert_eq!(
client.get_protocol_config(),
ProtocolConfig {
protocol_fee_bps: 50,
treasury,
}
);
}

#[test]
fn test_distribute_payout_deducts_protocol_fee() {
let (env, admin, client, token) = setup_env();
let member1 = Address::generate(&env);
let treasury = Address::generate(&env);
let token_admin = StellarAssetClient::new(&env, &token);
token_admin.mint(&member1, &10_000_000);

client.set_protocol_fee_bps(&admin, &500);
client.set_protocol_treasury(&admin, &treasury);

let token_client = TokenClient::new(&env, &token);
let admin_start = token_client.balance(&admin);
let member1_start = token_client.balance(&member1);
let treasury_start = token_client.balance(&treasury);

let group_id = create_test_group(&env, &client, &admin, &token);
client.join_group(&member1, &group_id);
client.start_group(&admin, &group_id);

client.contribute(&admin, &group_id);
client.contribute(&member1, &group_id);
client.distribute_payout(&group_id);

assert_eq!(token_client.balance(&treasury), treasury_start + 100_000);
assert_eq!(token_client.balance(&admin), admin_start - 1_000_000 + 1_900_000);
assert_eq!(token_client.balance(&member1), member1_start - 1_000_000);
}
9 changes: 9 additions & 0 deletions contracts/sorosave/src/types.rs
Original file line number Diff line number Diff line change
Expand Up @@ -51,11 +51,20 @@ pub struct Dispute {
pub raised_at: u64,
}

/// Protocol-level fee configuration.
#[contracttype]
#[derive(Clone, Debug, PartialEq)]
pub struct ProtocolConfig {
pub protocol_fee_bps: u32,
pub treasury: Address,
}

/// Storage keys for all contract data.
#[contracttype]
#[derive(Clone)]
pub enum DataKey {
Admin,
ProtocolConfig,
GroupCounter,
Group(u64),
Round(u64, u32),
Expand Down