diff --git a/contracts/sorosave/src/contribution.rs b/contracts/sorosave/src/contribution.rs index 4285c9f..afdb32d 100644 --- a/contracts/sorosave/src/contribution.rs +++ b/contracts/sorosave/src/contribution.rs @@ -2,7 +2,54 @@ use soroban_sdk::{Address, Env}; use crate::errors::ContractError; use crate::storage; -use crate::types::{GroupStatus, RoundInfo}; +use crate::types::{ContributionType, GroupStatus, RoundInfo, SavingsGroup}; + +pub fn required_contribution_for_member( + env: &Env, + group: &SavingsGroup, + member: &Address, +) -> Result { + let mut is_member = false; + for m in group.members.iter() { + if m == member.clone() { + is_member = true; + break; + } + } + if !is_member { + return Err(ContractError::NotMember); + } + + if group.contribution_type == ContributionType::Fixed { + return Ok(group.contribution_amount); + } + + let base_amount = storage::get_member_base_amount(env, group.id, member) + .ok_or(ContractError::InvalidAmount)?; + if base_amount <= 0 { + return Err(ContractError::InvalidAmount); + } + + let denominator = storage::MAX_PERCENTAGE_BPS as i128; + let bps = group.contribution_percentage_bps as i128; + let required_amount = + (base_amount / denominator) * bps + (base_amount % denominator) * bps / denominator; + + if required_amount <= 0 { + return Err(ContractError::InvalidAmount); + } + + Ok(required_amount) +} + +pub fn get_required_contribution( + env: &Env, + member: Address, + group_id: u64, +) -> Result { + let group = storage::get_group(env, group_id).ok_or(ContractError::GroupNotFound)?; + required_contribution_for_member(env, &group, &member) +} pub fn contribute(env: &Env, member: Address, group_id: u64) -> Result<(), ContractError> { member.require_auth(); @@ -37,17 +84,19 @@ pub fn contribute(env: &Env, member: Address, group_id: u64) -> Result<(), Contr return Err(ContractError::AlreadyContributed); } + let contribution_amount = required_contribution_for_member(env, &group, &member)?; + // Transfer tokens from member to this contract let token_client = soroban_sdk::token::Client::new(env, &group.token); token_client.transfer( &member, &env.current_contract_address(), - &group.contribution_amount, + &contribution_amount, ); // Record contribution round_info.contributions.set(member.clone(), true); - round_info.total_contributed += group.contribution_amount; + round_info.total_contributed += contribution_amount; // Check if all members have contributed if round_info.contributions.len() == group.members.len() { @@ -58,7 +107,7 @@ pub fn contribute(env: &Env, member: Address, group_id: u64) -> Result<(), Contr env.events().publish( (crate::symbol_short!("contrib"),), - (group_id, member, group.contribution_amount), + (group_id, member, contribution_amount), ); Ok(()) diff --git a/contracts/sorosave/src/group.rs b/contracts/sorosave/src/group.rs index 5033347..35b5c57 100644 --- a/contracts/sorosave/src/group.rs +++ b/contracts/sorosave/src/group.rs @@ -1,8 +1,15 @@ use soroban_sdk::{Address, Env, Map, String, Vec}; +use crate::contribution; use crate::errors::ContractError; use crate::storage; -use crate::types::{GroupStatus, RoundInfo, SavingsGroup}; +use crate::types::{ContributionType, GroupStatus, RoundInfo, SavingsGroup}; + +struct ContributionConfig { + amount: i128, + contribution_type: ContributionType, + percentage_bps: u32, +} pub fn create_group( env: &Env, @@ -12,12 +19,72 @@ pub fn create_group( contribution_amount: i128, cycle_length: u64, max_members: u32, +) -> Result { + create_group_with_type( + env, + admin, + name, + token, + ContributionConfig { + amount: contribution_amount, + contribution_type: ContributionType::Fixed, + percentage_bps: 0, + }, + cycle_length, + max_members, + ) +} + +pub fn create_percentage_group( + env: &Env, + admin: Address, + name: String, + token: Address, + contribution_percentage_bps: u32, + cycle_length: u64, + max_members: u32, +) -> Result { + create_group_with_type( + env, + admin, + name, + token, + ContributionConfig { + amount: 0, + contribution_type: ContributionType::Percentage, + percentage_bps: contribution_percentage_bps, + }, + cycle_length, + max_members, + ) +} + +fn create_group_with_type( + env: &Env, + admin: Address, + name: String, + token: Address, + contribution_config: ContributionConfig, + cycle_length: u64, + max_members: u32, ) -> Result { admin.require_auth(); - if contribution_amount <= 0 { - return Err(ContractError::InvalidAmount); + match contribution_config.contribution_type { + ContributionType::Fixed => { + if contribution_config.amount <= 0 { + return Err(ContractError::InvalidAmount); + } + } + ContributionType::Percentage => { + if contribution_config.percentage_bps == 0 + || contribution_config.percentage_bps > storage::MAX_PERCENTAGE_BPS + { + return Err(ContractError::InvalidAmount); + } + } } + if max_members < 2 { return Err(ContractError::InsufficientMembers); } @@ -33,7 +100,9 @@ pub fn create_group( name, admin: admin.clone(), token, - contribution_amount, + contribution_amount: contribution_config.amount, + contribution_type: contribution_config.contribution_type, + contribution_percentage_bps: contribution_config.percentage_bps, cycle_length, max_members, members, @@ -83,6 +152,70 @@ pub fn join_group(env: &Env, member: Address, group_id: u64) -> Result<(), Contr Ok(()) } +pub fn set_member_base_amount( + env: &Env, + member: Address, + group_id: u64, + base_amount: i128, +) -> Result<(), ContractError> { + member.require_auth(); + + if base_amount <= 0 { + return Err(ContractError::InvalidAmount); + } + + let group = storage::get_group(env, group_id).ok_or(ContractError::GroupNotFound)?; + + if group.contribution_type != ContributionType::Percentage { + return Err(ContractError::InvalidAmount); + } + + if group.status != GroupStatus::Forming { + return Err(ContractError::GroupNotForming); + } + + let mut is_member = false; + for m in group.members.iter() { + if m == member { + is_member = true; + break; + } + } + if !is_member { + return Err(ContractError::NotMember); + } + + storage::set_member_base_amount(env, group_id, &member, base_amount); + + env.events().publish( + (crate::symbol_short!("base_amt"),), + (group_id, member, base_amount), + ); + + Ok(()) +} + +pub fn get_member_base_amount( + env: &Env, + member: Address, + group_id: u64, +) -> Result { + let group = storage::get_group(env, group_id).ok_or(ContractError::GroupNotFound)?; + + let mut is_member = false; + for m in group.members.iter() { + if m == member { + is_member = true; + break; + } + } + if !is_member { + return Err(ContractError::NotMember); + } + + Ok(storage::get_member_base_amount(env, group_id, &member).unwrap_or(0)) +} + pub fn leave_group(env: &Env, member: Address, group_id: u64) -> Result<(), ContractError> { member.require_auth(); @@ -114,6 +247,7 @@ pub fn leave_group(env: &Env, member: Address, group_id: u64) -> Result<(), Cont group.members = new_members; storage::set_group(env, &group); storage::remove_member_group(env, &member, group_id); + storage::remove_member_base_amount(env, group_id, &member); env.events() .publish((crate::symbol_short!("grp_leav"),), (group_id, member)); @@ -138,6 +272,12 @@ pub fn start_group(env: &Env, admin: Address, group_id: u64) -> Result<(), Contr return Err(ContractError::InsufficientMembers); } + if group.contribution_type == ContributionType::Percentage { + for member in group.members.iter() { + contribution::required_contribution_for_member(env, &group, &member)?; + } + } + // Set payout order to member join order (can be randomized later) group.payout_order = group.members.clone(); group.total_rounds = group.members.len(); diff --git a/contracts/sorosave/src/lib.rs b/contracts/sorosave/src/lib.rs index 454a6ca..755597b 100644 --- a/contracts/sorosave/src/lib.rs +++ b/contracts/sorosave/src/lib.rs @@ -49,11 +49,52 @@ impl SoroSaveContract { ) } + /// Create a savings group where each member contributes a percentage of + /// their declared base amount. + pub fn create_percentage_group( + env: Env, + admin: Address, + name: String, + token: Address, + contribution_percentage_bps: u32, + cycle_length: u64, + max_members: u32, + ) -> Result { + group::create_percentage_group( + &env, + admin, + name, + token, + contribution_percentage_bps, + cycle_length, + max_members, + ) + } + /// Join an existing group that is still forming. pub fn join_group(env: Env, member: Address, group_id: u64) -> Result<(), ContractError> { group::join_group(&env, member, group_id) } + /// Declare a member's base amount for percentage-based contributions. + pub fn set_member_base_amount( + env: Env, + member: Address, + group_id: u64, + base_amount: i128, + ) -> Result<(), ContractError> { + group::set_member_base_amount(&env, member, group_id, base_amount) + } + + /// Get a member's declared base amount for a group. + pub fn get_member_base_amount( + env: Env, + member: Address, + group_id: u64, + ) -> Result { + group::get_member_base_amount(&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> { group::leave_group(&env, member, group_id) @@ -81,6 +122,15 @@ impl SoroSaveContract { contribution::contribute(&env, member, group_id) } + /// Get the amount a member is required to contribute to a group. + pub fn get_required_contribution( + env: Env, + member: Address, + group_id: u64, + ) -> Result { + contribution::get_required_contribution(&env, member, group_id) + } + /// Get the status of a specific round. pub fn get_round_status( env: Env, diff --git a/contracts/sorosave/src/storage.rs b/contracts/sorosave/src/storage.rs index 3f24bc8..b5bcdeb 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_PERCENTAGE_BPS: u32 = 10_000; // --- Admin --- @@ -103,6 +104,28 @@ pub fn remove_member_group(env: &Env, member: &Address, group_id: u64) { extend_persistent_ttl(env, &key); } +// --- Member Base Amounts --- + +pub fn get_member_base_amount(env: &Env, group_id: u64, member: &Address) -> Option { + let key = DataKey::MemberBaseAmount(group_id, member.clone()); + let result = env.storage().persistent().get(&key); + if result.is_some() { + extend_persistent_ttl(env, &key); + } + result +} + +pub fn set_member_base_amount(env: &Env, group_id: u64, member: &Address, base_amount: i128) { + let key = DataKey::MemberBaseAmount(group_id, member.clone()); + env.storage().persistent().set(&key, &base_amount); + extend_persistent_ttl(env, &key); +} + +pub fn remove_member_base_amount(env: &Env, group_id: u64, member: &Address) { + let key = DataKey::MemberBaseAmount(group_id, member.clone()); + env.storage().persistent().remove(&key); +} + // --- Dispute --- #[allow(dead_code)] diff --git a/contracts/sorosave/src/test.rs b/contracts/sorosave/src/test.rs index f1ac1ef..3c604fb 100644 --- a/contracts/sorosave/src/test.rs +++ b/contracts/sorosave/src/test.rs @@ -1,7 +1,7 @@ use soroban_sdk::{testutils::Address as _, token::StellarAssetClient, Address, Env, String}; -use crate::types::GroupStatus; -use crate::{SoroSaveContract, SoroSaveContractClient}; +use crate::types::{ContributionType, GroupStatus}; +use crate::{ContractError, SoroSaveContract, SoroSaveContractClient}; fn setup_env() -> (Env, Address, SoroSaveContractClient<'static>, Address) { let env = Env::default(); @@ -47,6 +47,8 @@ fn test_create_group() { let group = client.get_group(&group_id); assert_eq!(group.admin, admin); assert_eq!(group.contribution_amount, 1_000_000); + assert_eq!(group.contribution_type, ContributionType::Fixed); + assert_eq!(group.contribution_percentage_bps, 0); assert_eq!(group.max_members, 5); assert_eq!(group.status, GroupStatus::Forming); assert_eq!(group.members.len(), 1); @@ -171,6 +173,105 @@ fn test_member_groups() { assert_eq!(groups.get(1).unwrap(), group2); } +#[test] +fn test_percentage_group_requires_base_amounts() { + let (env, admin, client, token) = setup_env(); + let member1 = Address::generate(&env); + + assert_eq!( + client.try_create_percentage_group( + &admin, + &String::from_str(&env, "Invalid Percentage Group"), + &token, + &0, + &86400, + &5, + ), + Err(Ok(ContractError::InvalidAmount)) + ); + assert_eq!( + client.try_create_percentage_group( + &admin, + &String::from_str(&env, "Too High Percentage Group"), + &token, + &10_001, + &86400, + &5, + ), + Err(Ok(ContractError::InvalidAmount)) + ); + + let group_id = client.create_percentage_group( + &admin, + &String::from_str(&env, "Percentage Group"), + &token, + &1_000, + &86400, + &5, + ); + client.join_group(&member1, &group_id); + client.set_member_base_amount(&admin, &group_id, &10_000_000); + + assert_eq!( + client.try_start_group(&admin, &group_id), + Err(Ok(ContractError::InvalidAmount)) + ); +} + +#[test] +fn test_percentage_contribution_flow() { + let (env, admin, client, _) = setup_env(); + let member1 = Address::generate(&env); + + let mint_admin = Address::generate(&env); + let token_id = env.register_stellar_asset_contract_v2(mint_admin); + let token_sac = StellarAssetClient::new(&env, &token_id.address()); + token_sac.mint(&admin, &10_000_000); + token_sac.mint(&member1, &10_000_000); + + let group_id = client.create_percentage_group( + &admin, + &String::from_str(&env, "Percentage Flow"), + &token_id.address(), + &1_000, + &86400, + &5, + ); + client.join_group(&member1, &group_id); + client.set_member_base_amount(&admin, &group_id, &10_000_000); + client.set_member_base_amount(&member1, &group_id, &20_000_000); + + let group = client.get_group(&group_id); + assert_eq!(group.contribution_type, ContributionType::Percentage); + assert_eq!(group.contribution_amount, 0); + assert_eq!(group.contribution_percentage_bps, 1_000); + assert_eq!( + client.get_member_base_amount(&member1, &group_id), + 20_000_000 + ); + assert_eq!( + client.get_required_contribution(&admin, &group_id), + 1_000_000 + ); + assert_eq!( + client.get_required_contribution(&member1, &group_id), + 2_000_000 + ); + + client.start_group(&admin, &group_id); + client.contribute(&admin, &group_id); + client.contribute(&member1, &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); + + let group = client.get_group(&group_id); + assert_eq!(group.current_round, 2); +} + #[test] fn test_pause_resume_group() { let (env, admin, client, token) = setup_env(); diff --git a/contracts/sorosave/src/types.rs b/contracts/sorosave/src/types.rs index f741099..3722734 100644 --- a/contracts/sorosave/src/types.rs +++ b/contracts/sorosave/src/types.rs @@ -11,6 +11,14 @@ pub enum GroupStatus { Paused, // Admin has paused the group } +/// Contribution model used by a savings group. +#[contracttype] +#[derive(Clone, Debug, PartialEq)] +pub enum ContributionType { + Fixed, + Percentage, +} + /// Core savings group configuration and state. #[contracttype] #[derive(Clone, Debug)] @@ -20,6 +28,8 @@ pub struct SavingsGroup { pub admin: Address, pub token: Address, pub contribution_amount: i128, + pub contribution_type: ContributionType, + pub contribution_percentage_bps: u32, pub cycle_length: u64, pub max_members: u32, pub members: Vec
, @@ -60,5 +70,6 @@ pub enum DataKey { Group(u64), Round(u64, u32), MemberGroups(Address), + MemberBaseAmount(u64, Address), Dispute(u64), }