diff --git a/contracts/sorosave/src/contribution.rs b/contracts/sorosave/src/contribution.rs index 4285c9f..4a56a48 100644 --- a/contracts/sorosave/src/contribution.rs +++ b/contracts/sorosave/src/contribution.rs @@ -2,7 +2,9 @@ use soroban_sdk::{Address, Env}; use crate::errors::ContractError; use crate::storage; -use crate::types::{GroupStatus, RoundInfo}; +use crate::types::{ContributionType, GroupStatus, RoundInfo}; + +const BASIS_POINTS_DENOMINATOR: i128 = 10_000; pub fn contribute(env: &Env, member: Address, group_id: u64) -> Result<(), ContractError> { member.require_auth(); @@ -37,17 +39,30 @@ pub fn contribute(env: &Env, member: Address, group_id: u64) -> Result<(), Contr return Err(ContractError::AlreadyContributed); } + let contribution_amount = match group.contribution_type { + ContributionType::Fixed => group.contribution_amount, + ContributionType::Percentage => { + let base_amount = storage::get_member_base_amount(env, group_id, &member) + .ok_or(ContractError::BaseAmountRequired)?; + let amount = (base_amount * group.contribution_amount) / BASIS_POINTS_DENOMINATOR; + if amount <= 0 { + return Err(ContractError::InvalidAmount); + } + amount + } + }; + // 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 +73,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/errors.rs b/contracts/sorosave/src/errors.rs index a2b9d9d..bff0038 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, + BaseAmountRequired = 19, } diff --git a/contracts/sorosave/src/group.rs b/contracts/sorosave/src/group.rs index 5033347..5720c14 100644 --- a/contracts/sorosave/src/group.rs +++ b/contracts/sorosave/src/group.rs @@ -2,7 +2,9 @@ use soroban_sdk::{Address, Env, Map, String, Vec}; use crate::errors::ContractError; use crate::storage; -use crate::types::{GroupStatus, RoundInfo, SavingsGroup}; +use crate::types::{ContributionType, GroupStatus, RoundInfo, SavingsGroup}; + +const MAX_PERCENTAGE_BPS: i128 = 10_000; pub fn create_group( env: &Env, @@ -12,12 +14,38 @@ pub fn create_group( contribution_amount: i128, cycle_length: u64, max_members: u32, +) -> Result { + create_group_with_type( + env, + admin, + name, + token, + contribution_amount, + ContributionType::Fixed, + cycle_length, + max_members, + ) +} + +pub fn create_group_with_type( + env: &Env, + admin: Address, + name: String, + token: Address, + contribution_amount: i128, + contribution_type: ContributionType, + cycle_length: u64, + max_members: u32, ) -> Result { admin.require_auth(); if contribution_amount <= 0 { return Err(ContractError::InvalidAmount); } + if contribution_type == ContributionType::Percentage && contribution_amount > MAX_PERCENTAGE_BPS + { + return Err(ContractError::InvalidAmount); + } if max_members < 2 { return Err(ContractError::InsufficientMembers); } @@ -33,6 +61,7 @@ pub fn create_group( name, admin: admin.clone(), token, + contribution_type, contribution_amount, cycle_length, max_members, @@ -83,6 +112,69 @@ 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.status != GroupStatus::Forming { + return Err(ContractError::GroupNotForming); + } + if group.contribution_type != ContributionType::Percentage { + return Err(ContractError::InvalidAmount); + } + + let mut is_member = false; + for existing_member in group.members.iter() { + if existing_member == 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_set"),), + (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 existing_member in group.members.iter() { + if existing_member == member { + is_member = true; + break; + } + } + if !is_member { + return Err(ContractError::NotMember); + } + + storage::get_member_base_amount(env, group_id, &member).ok_or(ContractError::BaseAmountRequired) +} + pub fn leave_group(env: &Env, member: Address, group_id: u64) -> Result<(), ContractError> { member.require_auth(); @@ -114,6 +206,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)); @@ -137,6 +230,13 @@ pub fn start_group(env: &Env, admin: Address, group_id: u64) -> Result<(), Contr if group.members.len() < 2 { return Err(ContractError::InsufficientMembers); } + if group.contribution_type == ContributionType::Percentage { + for member in group.members.iter() { + if storage::get_member_base_amount(env, group_id, &member).is_none() { + return Err(ContractError::BaseAmountRequired); + } + } + } // Set payout order to member join order (can be randomized later) group.payout_order = group.members.clone(); diff --git a/contracts/sorosave/src/lib.rs b/contracts/sorosave/src/lib.rs index 454a6ca..b3ef25a 100644 --- a/contracts/sorosave/src/lib.rs +++ b/contracts/sorosave/src/lib.rs @@ -49,11 +49,53 @@ impl SoroSaveContract { ) } + /// Create a new savings group with an explicit contribution model. + pub fn create_group_with_type( + env: Env, + admin: Address, + name: String, + token: Address, + contribution_amount: i128, + contribution_type: ContributionType, + cycle_length: u64, + max_members: u32, + ) -> Result { + group::create_group_with_type( + &env, + admin, + name, + token, + contribution_amount, + contribution_type, + 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) } + /// Set a member's base amount for percentage-based groups while still forming. + 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) diff --git a/contracts/sorosave/src/storage.rs b/contracts/sorosave/src/storage.rs index 3f24bc8..7b9a6bb 100644 --- a/contracts/sorosave/src/storage.rs +++ b/contracts/sorosave/src/storage.rs @@ -103,6 +103,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..88f8924 100644 --- a/contracts/sorosave/src/test.rs +++ b/contracts/sorosave/src/test.rs @@ -1,6 +1,10 @@ -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::types::{ContributionType, GroupStatus}; use crate::{SoroSaveContract, SoroSaveContractClient}; fn setup_env() -> (Env, Address, SoroSaveContractClient<'static>, Address) { @@ -46,6 +50,7 @@ fn test_create_group() { let group = client.get_group(&group_id); assert_eq!(group.admin, admin); + assert_eq!(group.contribution_type, ContributionType::Fixed); assert_eq!(group.contribution_amount, 1_000_000); assert_eq!(group.max_members, 5); assert_eq!(group.status, GroupStatus::Forming); @@ -222,3 +227,49 @@ fn test_set_group_admin() { let group = client.get_group(&group_id); assert_eq!(group.admin, new_admin); } + +#[test] +fn test_percentage_group_dynamic_pot() { + let (env, admin, client, _token) = setup_env(); + + let token_admin = Address::generate(&env); + let token_id = env.register_stellar_asset_contract_v2(token_admin.clone()); + let token_client = StellarAssetClient::new(&env, &token_id.address()); + let token_reader = TokenClient::new(&env, &token_id.address()); + + let member1 = Address::generate(&env); + token_client.mint(&admin, &20_000_000); + token_client.mint(&member1, &20_000_000); + + let group_id = client.create_group_with_type( + &admin, + &String::from_str(&env, "Percentage Group"), + &token_id.address(), + &1_000, // 10.00% in basis points + &ContributionType::Percentage, + &86400, + &5, + ); + + client.join_group(&member1, &group_id); + client.set_member_base_amount(&admin, &group_id, &5_000_000); + client.set_member_base_amount(&member1, &group_id, &10_000_000); + + assert_eq!(client.get_member_base_amount(&admin, &group_id), 5_000_000); + assert_eq!( + client.get_member_base_amount(&member1, &group_id), + 10_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, 1_500_000); + + client.distribute_payout(&group_id); + assert_eq!(token_reader.balance(&admin), 21_000_000); + assert_eq!(token_reader.balance(&member1), 19_000_000); +} diff --git a/contracts/sorosave/src/types.rs b/contracts/sorosave/src/types.rs index f741099..6a5693d 100644 --- a/contracts/sorosave/src/types.rs +++ b/contracts/sorosave/src/types.rs @@ -1,5 +1,13 @@ use soroban_sdk::{contracttype, Address, Map, String, Vec}; +/// Contribution model used by a savings group. +#[contracttype] +#[derive(Clone, Debug, PartialEq)] +pub enum ContributionType { + Fixed, + Percentage, +} + /// Status of a savings group throughout its lifecycle. #[contracttype] #[derive(Clone, Debug, PartialEq)] @@ -19,6 +27,7 @@ pub struct SavingsGroup { pub name: String, pub admin: Address, pub token: Address, + pub contribution_type: ContributionType, pub contribution_amount: i128, pub cycle_length: u64, pub max_members: u32, @@ -60,5 +69,6 @@ pub enum DataKey { Group(u64), Round(u64, u32), MemberGroups(Address), + MemberBaseAmount(u64, Address), Dispute(u64), }