diff --git a/contracts/sorosave/src/contribution.rs b/contracts/sorosave/src/contribution.rs index 4285c9f..681acae 100644 --- a/contracts/sorosave/src/contribution.rs +++ b/contracts/sorosave/src/contribution.rs @@ -1,6 +1,7 @@ use soroban_sdk::{Address, Env}; use crate::errors::ContractError; +use crate::r#yield as yield_source; use crate::storage; use crate::types::{GroupStatus, RoundInfo}; @@ -52,6 +53,7 @@ pub fn contribute(env: &Env, member: Address, group_id: u64) -> Result<(), Contr // Check if all members have contributed if round_info.contributions.len() == group.members.len() { round_info.is_complete = true; + yield_source::deposit_idle_funds(env, &group, &round_info)?; } storage::set_round(env, group_id, &round_info); diff --git a/contracts/sorosave/src/group.rs b/contracts/sorosave/src/group.rs index 5033347..fad65f7 100644 --- a/contracts/sorosave/src/group.rs +++ b/contracts/sorosave/src/group.rs @@ -36,6 +36,7 @@ pub fn create_group( contribution_amount, cycle_length, max_members, + yield_rate_bps: 0, members, payout_order: Vec::new(env), current_round: 0, diff --git a/contracts/sorosave/src/lib.rs b/contracts/sorosave/src/lib.rs index 454a6ca..11957f0 100644 --- a/contracts/sorosave/src/lib.rs +++ b/contracts/sorosave/src/lib.rs @@ -9,6 +9,7 @@ mod group; mod payout; mod storage; mod types; +mod r#yield; pub use errors::ContractError; pub use types::*; @@ -100,6 +101,34 @@ impl SoroSaveContract { contribution::has_contributed(&env, member, group_id, round) } + /// Configure the fixed yield rate used by the current yield-source integration. + pub fn set_yield_rate( + env: Env, + admin: Address, + group_id: u64, + yield_rate_bps: u32, + ) -> Result<(), ContractError> { + admin.require_auth(); + + let mut group = storage::get_group(&env, group_id).ok_or(ContractError::GroupNotFound)?; + if admin != group.admin { + return Err(ContractError::Unauthorized); + } + if yield_rate_bps > 10_000 { + return Err(ContractError::InvalidAmount); + } + + group.yield_rate_bps = yield_rate_bps; + storage::set_group(&env, &group); + + env.events().publish( + (crate::symbol_short!("yieldcfg"),), + (group_id, yield_rate_bps), + ); + + Ok(()) + } + // ─── Payouts ──────────────────────────────────────────────────── /// Distribute the pot to the current round's recipient. Anyone can call this diff --git a/contracts/sorosave/src/payout.rs b/contracts/sorosave/src/payout.rs index 76ed389..037289e 100644 --- a/contracts/sorosave/src/payout.rs +++ b/contracts/sorosave/src/payout.rs @@ -1,6 +1,7 @@ use soroban_sdk::{Address, Env, Map, Vec}; use crate::errors::ContractError; +use crate::r#yield as yield_source; use crate::storage; use crate::types::{GroupStatus, RoundInfo}; @@ -18,21 +19,19 @@ pub fn distribute_payout(env: &Env, group_id: u64) -> Result<(), ContractError> return Err(ContractError::RoundNotComplete); } + let payout_amount = yield_source::withdraw_for_payout(env, &group, &round_info)?; + // Transfer the pot to the round's recipient 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, + &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..ef7319d 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, RoundInfo, SavingsGroup, YieldPosition}; const INSTANCE_TTL_THRESHOLD: u32 = 100; const INSTANCE_TTL_EXTEND: u32 = 500; @@ -122,6 +122,28 @@ pub fn remove_dispute(env: &Env, group_id: u64) { env.storage().persistent().remove(&key); } +// --- Yield Positions --- + +pub fn get_yield_position(env: &Env, group_id: u64, round: u32) -> Option { + let key = DataKey::YieldPosition(group_id, round); + let result = env.storage().persistent().get(&key); + if result.is_some() { + extend_persistent_ttl(env, &key); + } + result +} + +pub fn set_yield_position(env: &Env, group_id: u64, round: u32, position: &YieldPosition) { + let key = DataKey::YieldPosition(group_id, round); + env.storage().persistent().set(&key, position); + extend_persistent_ttl(env, &key); +} + +pub fn remove_yield_position(env: &Env, group_id: u64, round: u32) { + let key = DataKey::YieldPosition(group_id, round); + env.storage().persistent().remove(&key); +} + // --- TTL Management --- fn extend_instance_ttl(env: &Env) { diff --git a/contracts/sorosave/src/test.rs b/contracts/sorosave/src/test.rs index f1ac1ef..49aaa84 100644 --- a/contracts/sorosave/src/test.rs +++ b/contracts/sorosave/src/test.rs @@ -48,6 +48,7 @@ fn test_create_group() { assert_eq!(group.admin, admin); assert_eq!(group.contribution_amount, 1_000_000); assert_eq!(group.max_members, 5); + assert_eq!(group.yield_rate_bps, 0); assert_eq!(group.status, GroupStatus::Forming); assert_eq!(group.members.len(), 1); } @@ -222,3 +223,39 @@ fn test_set_group_admin() { let group = client.get_group(&group_id); assert_eq!(group.admin, new_admin); } + +#[test] +fn test_yield_added_to_payout_when_configured() { + let (env, admin, client, _token) = setup_env(); + let mint_admin = Address::generate(&env); + let token_id = env.register_stellar_asset_contract_v2(mint_admin.clone()); + 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, "Yield Test"), + &token, + &1_000_000, + &86400, + &5, + ); + client.join_group(&member1, &group_id); + client.set_yield_rate(&admin, &group_id, &1_000); + client.start_group(&admin, &group_id); + + client.contribute(&admin, &group_id); + client.contribute(&member1, &group_id); + + token_sac.mint(&client.address, &200_000); + + let admin_balance_before = soroban_sdk::token::Client::new(&env, &token).balance(&admin); + client.distribute_payout(&group_id); + let admin_balance_after = soroban_sdk::token::Client::new(&env, &token).balance(&admin); + + assert_eq!(admin_balance_after - admin_balance_before, 2_200_000); +} diff --git a/contracts/sorosave/src/types.rs b/contracts/sorosave/src/types.rs index f741099..bcb7448 100644 --- a/contracts/sorosave/src/types.rs +++ b/contracts/sorosave/src/types.rs @@ -22,6 +22,7 @@ pub struct SavingsGroup { pub contribution_amount: i128, pub cycle_length: u64, pub max_members: u32, + pub yield_rate_bps: u32, pub members: Vec
, pub payout_order: Vec
, pub current_round: u32, @@ -51,6 +52,15 @@ pub struct Dispute { pub raised_at: u64, } +/// Tracks idle funds deposited into a yield source for a round. +#[contracttype] +#[derive(Clone, Debug)] +pub struct YieldPosition { + pub principal: i128, + pub earned_yield: i128, + pub deposited_at: u64, +} + /// Storage keys for all contract data. #[contracttype] #[derive(Clone)] @@ -61,4 +71,5 @@ pub enum DataKey { Round(u64, u32), MemberGroups(Address), Dispute(u64), + YieldPosition(u64, u32), } diff --git a/contracts/sorosave/src/yield.rs b/contracts/sorosave/src/yield.rs new file mode 100644 index 0000000..3e03f75 --- /dev/null +++ b/contracts/sorosave/src/yield.rs @@ -0,0 +1,90 @@ +use soroban_sdk::Env; + +use crate::errors::ContractError; +use crate::storage; +use crate::types::{RoundInfo, SavingsGroup, YieldPosition}; + +pub trait YieldSource { + fn deposit( + env: &Env, + group_id: u64, + round: u32, + amount: i128, + rate_bps: u32, + ) -> Result<(), ContractError>; + + fn withdraw(env: &Env, group_id: u64, round: u32) -> Result; +} + +pub struct FixedRateYieldSource; + +impl YieldSource for FixedRateYieldSource { + fn deposit( + env: &Env, + group_id: u64, + round: u32, + amount: i128, + rate_bps: u32, + ) -> Result<(), ContractError> { + if amount <= 0 || rate_bps == 0 { + return Ok(()); + } + + let position = YieldPosition { + principal: amount, + earned_yield: amount * rate_bps as i128 / 10_000, + deposited_at: env.ledger().timestamp(), + }; + storage::set_yield_position(env, group_id, round, &position); + + env.events().publish( + (crate::symbol_short!("yielddep"),), + (group_id, round, amount), + ); + + Ok(()) + } + + fn withdraw(env: &Env, group_id: u64, round: u32) -> Result { + let Some(position) = storage::get_yield_position(env, group_id, round) else { + return Ok(0); + }; + + storage::remove_yield_position(env, group_id, round); + let amount = position.principal + position.earned_yield; + + env.events().publish( + (crate::symbol_short!("yieldwd"),), + (group_id, round, amount), + ); + + Ok(amount) + } +} + +pub fn deposit_idle_funds( + env: &Env, + group: &SavingsGroup, + round_info: &RoundInfo, +) -> Result<(), ContractError> { + FixedRateYieldSource::deposit( + env, + group.id, + round_info.round_number, + round_info.total_contributed, + group.yield_rate_bps, + ) +} + +pub fn withdraw_for_payout( + env: &Env, + group: &SavingsGroup, + round_info: &RoundInfo, +) -> Result { + let yielded_amount = FixedRateYieldSource::withdraw(env, group.id, round_info.round_number)?; + if yielded_amount > 0 { + Ok(yielded_amount) + } else { + Ok(round_info.total_contributed) + } +}