From ccfc0258fb685efbcc92fbf86d94ee1015379d9c Mon Sep 17 00:00:00 2001 From: Scott Date: Wed, 11 Mar 2026 16:50:33 -0500 Subject: [PATCH] Add protocol fee configuration --- contracts/sorosave/src/admin.rs | 42 ++++++++++++++++ contracts/sorosave/src/errors.rs | 1 + contracts/sorosave/src/lib.rs | 30 +++++++++++ contracts/sorosave/src/payout.rs | 22 ++++++++- contracts/sorosave/src/storage.rs | 11 ++++- contracts/sorosave/src/test.rs | 82 ++++++++++++++++++++++++++++++- contracts/sorosave/src/types.rs | 9 ++++ 7 files changed, 192 insertions(+), 5 deletions(-) diff --git a/contracts/sorosave/src/admin.rs b/contracts/sorosave/src/admin.rs index 049b6ce..b24562e 100644 --- a/contracts/sorosave/src/admin.rs +++ b/contracts/sorosave/src/admin.rs @@ -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(()) +} diff --git a/contracts/sorosave/src/errors.rs b/contracts/sorosave/src/errors.rs index a2b9d9d..fa36386 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, + InvalidFeeBps = 19, } diff --git a/contracts/sorosave/src/lib.rs b/contracts/sorosave/src/lib.rs index 454a6ca..84b089e 100644 --- a/contracts/sorosave/src/lib.rs +++ b/contracts/sorosave/src/lib.rs @@ -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 ──────────────────────────────────────────── @@ -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. @@ -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)] diff --git a/contracts/sorosave/src/payout.rs b/contracts/sorosave/src/payout.rs index 76ed389..dd45cf0 100644 --- a/contracts/sorosave/src/payout.rs +++ b/contracts/sorosave/src/payout.rs @@ -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); @@ -20,10 +21,27 @@ 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( @@ -31,7 +49,7 @@ pub fn distribute_payout(env: &Env, group_id: u64) -> Result<(), ContractError> ( group_id, round_info.recipient.clone(), - round_info.total_contributed, + recipient_amount, ), ); diff --git a/contracts/sorosave/src/storage.rs b/contracts/sorosave/src/storage.rs index 3f24bc8..874b584 100644 --- a/contracts/sorosave/src/storage.rs +++ b/contracts/sorosave/src/storage.rs @@ -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; @@ -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 { diff --git a/contracts/sorosave/src/test.rs b/contracts/sorosave/src/test.rs index f1ac1ef..b8b9918 100644 --- a/contracts/sorosave/src/test.rs +++ b/contracts/sorosave/src/test.rs @@ -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) { @@ -38,6 +45,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 +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); +} diff --git a/contracts/sorosave/src/types.rs b/contracts/sorosave/src/types.rs index f741099..05260de 100644 --- a/contracts/sorosave/src/types.rs +++ b/contracts/sorosave/src/types.rs @@ -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),