From dba9aeef35fc2b6e797e4c861965b9d53058d9e3 Mon Sep 17 00:00:00 2001 From: Tu Pham Date: Mon, 25 May 2026 17:29:51 +0700 Subject: [PATCH] feat: add protocol fee configuration --- contracts/sorosave/src/admin.rs | 38 ++++++++++++++ contracts/sorosave/src/lib.rs | 24 +++++++++ contracts/sorosave/src/payout.rs | 34 ++++++++----- contracts/sorosave/src/storage.rs | 29 +++++++++++ contracts/sorosave/src/test.rs | 82 ++++++++++++++++++++++++++++++- contracts/sorosave/src/types.rs | 2 + 6 files changed, 196 insertions(+), 13 deletions(-) diff --git a/contracts/sorosave/src/admin.rs b/contracts/sorosave/src/admin.rs index 049b6ce..9d56c28 100644 --- a/contracts/sorosave/src/admin.rs +++ b/contracts/sorosave/src/admin.rs @@ -4,6 +4,44 @@ use crate::errors::ContractError; use crate::storage; use crate::types::{Dispute, GroupStatus}; +pub fn set_protocol_fee_bps(env: &Env, admin: Address, bps: u32) -> Result<(), ContractError> { + admin.require_auth(); + + if admin != storage::get_admin(env) { + return Err(ContractError::Unauthorized); + } + + if bps > storage::MAX_PROTOCOL_FEE_BPS { + return Err(ContractError::InvalidAmount); + } + + storage::set_protocol_fee_bps(env, bps); + + env.events() + .publish((crate::symbol_short!("fee_set"),), 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); + } + + storage::set_protocol_treasury(env, &treasury); + + env.events() + .publish((crate::symbol_short!("treasury"),), treasury); + + Ok(()) +} + pub fn pause_group(env: &Env, admin: Address, group_id: u64) -> Result<(), ContractError> { admin.require_auth(); diff --git a/contracts/sorosave/src/lib.rs b/contracts/sorosave/src/lib.rs index 454a6ca..7e59116 100644 --- a/contracts/sorosave/src/lib.rs +++ b/contracts/sorosave/src/lib.rs @@ -163,6 +163,30 @@ impl SoroSaveContract { ) -> Result<(), ContractError> { admin::set_group_admin(&env, current_admin, group_id, new_admin) } + + /// Get the protocol fee charged on payouts, in basis points. + pub fn get_protocol_fee_bps(env: Env) -> u32 { + storage::get_protocol_fee_bps(&env) + } + + /// Set the protocol fee charged on payouts, in basis points. + pub fn set_protocol_fee_bps(env: Env, admin: Address, bps: u32) -> Result<(), ContractError> { + admin::set_protocol_fee_bps(&env, admin, bps) + } + + /// Get the protocol treasury address that receives payout fees. + pub fn get_protocol_treasury(env: Env) -> Address { + storage::get_protocol_treasury(&env) + } + + /// Set the protocol treasury address that receives payout 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..a7285ca 100644 --- a/contracts/sorosave/src/payout.rs +++ b/contracts/sorosave/src/payout.rs @@ -18,21 +18,33 @@ pub fn distribute_payout(env: &Env, group_id: u64) -> Result<(), ContractError> return Err(ContractError::RoundNotComplete); } - // Transfer the pot to the round's recipient + // Split the completed pot into protocol fee and recipient payout. let token_client = soroban_sdk::token::Client::new(env, &group.token); - token_client.transfer( - &env.current_contract_address(), - &round_info.recipient, - &round_info.total_contributed, - ); + let contract_addr = env.current_contract_address(); + let fee_bps = storage::get_protocol_fee_bps(env); + let fee_denominator = storage::MAX_PROTOCOL_FEE_BPS as i128; + let fee_bps_i128 = fee_bps as i128; + let fee_amount = (round_info.total_contributed / fee_denominator) * fee_bps_i128 + + (round_info.total_contributed % fee_denominator) * fee_bps_i128 / fee_denominator; + let payout_amount = round_info.total_contributed - fee_amount; + + if fee_amount > 0 { + let treasury = storage::get_protocol_treasury(env); + token_client.transfer(&contract_addr, &treasury, &fee_amount); + + env.events().publish( + (crate::symbol_short!("fee_paid"),), + (group_id, treasury, fee_amount), + ); + } + + if payout_amount > 0 { + token_client.transfer(&contract_addr, &round_info.recipient, &payout_amount); + } env.events().publish( (crate::symbol_short!("payout"),), - ( - group_id, - round_info.recipient.clone(), - round_info.total_contributed, - ), + (group_id, round_info.recipient.clone(), payout_amount), ); // Advance to next round or complete the group diff --git a/contracts/sorosave/src/storage.rs b/contracts/sorosave/src/storage.rs index 3f24bc8..5a9f247 100644 --- a/contracts/sorosave/src/storage.rs +++ b/contracts/sorosave/src/storage.rs @@ -6,6 +6,7 @@ const INSTANCE_TTL_THRESHOLD: u32 = 100; const INSTANCE_TTL_EXTEND: u32 = 500; const PERSISTENT_TTL_THRESHOLD: u32 = 100; const PERSISTENT_TTL_EXTEND: u32 = 1000; +pub const MAX_PROTOCOL_FEE_BPS: u32 = 10_000; // --- Admin --- @@ -22,6 +23,34 @@ pub fn has_admin(env: &Env) -> bool { env.storage().instance().has(&DataKey::Admin) } +// --- Protocol Config --- + +pub fn get_protocol_fee_bps(env: &Env) -> u32 { + env.storage() + .instance() + .get(&DataKey::ProtocolFeeBps) + .unwrap_or(0) +} + +pub fn set_protocol_fee_bps(env: &Env, bps: u32) { + env.storage().instance().set(&DataKey::ProtocolFeeBps, &bps); + extend_instance_ttl(env); +} + +pub fn get_protocol_treasury(env: &Env) -> Address { + env.storage() + .instance() + .get(&DataKey::ProtocolTreasury) + .unwrap_or_else(|| get_admin(env)) +} + +pub fn set_protocol_treasury(env: &Env, treasury: &Address) { + env.storage() + .instance() + .set(&DataKey::ProtocolTreasury, treasury); + 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..13ec859 100644 --- a/contracts/sorosave/src/test.rs +++ b/contracts/sorosave/src/test.rs @@ -1,7 +1,11 @@ -use soroban_sdk::{testutils::Address as _, token::StellarAssetClient, Address, Env, String}; +use soroban_sdk::{ + testutils::Address as _, + token::{Client as TokenClient, 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 +226,77 @@ fn test_set_group_admin() { let group = client.get_group(&group_id); assert_eq!(group.admin, new_admin); } + +#[test] +fn test_protocol_fee_defaults_and_admin_updates() { + let (env, admin, client, _) = setup_env(); + let member = Address::generate(&env); + let treasury = Address::generate(&env); + + assert_eq!(client.get_protocol_fee_bps(), 0); + assert_eq!(client.get_protocol_treasury(), admin); + + assert_eq!( + client.try_set_protocol_fee_bps(&member, &50), + Err(Ok(ContractError::Unauthorized)) + ); + assert_eq!( + client.try_set_protocol_treasury(&member, &treasury), + Err(Ok(ContractError::Unauthorized)) + ); + assert_eq!( + client.try_set_protocol_fee_bps(&admin, &10_001), + Err(Ok(ContractError::InvalidAmount)) + ); + + client.set_protocol_fee_bps(&admin, &50); + client.set_protocol_treasury(&admin, &treasury); + + assert_eq!(client.get_protocol_fee_bps(), 50); + assert_eq!(client.get_protocol_treasury(), treasury); +} + +#[test] +fn test_protocol_fee_deducted_from_payout() { + let env = Env::default(); + env.mock_all_auths(); + + let admin = Address::generate(&env); + let member = Address::generate(&env); + let treasury = 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_sac = StellarAssetClient::new(&env, &token_id.address()); + token_sac.mint(&admin, &10_000_000); + token_sac.mint(&member, &10_000_000); + + client.set_protocol_fee_bps(&admin, &50); + client.set_protocol_treasury(&admin, &treasury); + + let group_id = client.create_group( + &admin, + &String::from_str(&env, "Protocol Fee Test"), + &token_id.address(), + &1_000_000, + &86400, + &5, + ); + client.join_group(&member, &group_id); + client.start_group(&admin, &group_id); + + client.contribute(&admin, &group_id); + client.contribute(&member, &group_id); + client.distribute_payout(&group_id); + + let token_client = TokenClient::new(&env, &token_id.address()); + assert_eq!(token_client.balance(&treasury), 10_000); + assert_eq!(token_client.balance(&admin), 10_990_000); + assert_eq!(token_client.balance(&member), 9_000_000); + + let group = client.get_group(&group_id); + assert_eq!(group.current_round, 2); +} diff --git a/contracts/sorosave/src/types.rs b/contracts/sorosave/src/types.rs index f741099..b9ca9fb 100644 --- a/contracts/sorosave/src/types.rs +++ b/contracts/sorosave/src/types.rs @@ -56,6 +56,8 @@ pub struct Dispute { #[derive(Clone)] pub enum DataKey { Admin, + ProtocolFeeBps, + ProtocolTreasury, GroupCounter, Group(u64), Round(u64, u32),