diff --git a/contracts/sorosave/src/contribution.rs b/contracts/sorosave/src/contribution.rs index 4285c9f..0eb4e59 100644 --- a/contracts/sorosave/src/contribution.rs +++ b/contracts/sorosave/src/contribution.rs @@ -2,9 +2,28 @@ use soroban_sdk::{Address, Env}; use crate::errors::ContractError; use crate::storage; -use crate::types::{GroupStatus, RoundInfo}; +use crate::types::{GroupStatus, RoundInfo, SavingsGroup}; + +fn is_token_accepted(group: &SavingsGroup, token: &Address) -> bool { + for accepted_token in group.accepted_tokens.iter() { + if accepted_token == token.clone() { + return true; + } + } + false +} pub fn contribute(env: &Env, member: Address, group_id: u64) -> Result<(), ContractError> { + let group = storage::get_group(env, group_id).ok_or(ContractError::GroupNotFound)?; + contribute_with_token(env, member, group_id, group.token) +} + +pub fn contribute_with_token( + env: &Env, + member: Address, + group_id: u64, + token: Address, +) -> Result<(), ContractError> { member.require_auth(); let group = storage::get_group(env, group_id).ok_or(ContractError::GroupNotFound)?; @@ -25,6 +44,10 @@ pub fn contribute(env: &Env, member: Address, group_id: u64) -> Result<(), Contr return Err(ContractError::NotMember); } + if !is_token_accepted(&group, &token) { + return Err(ContractError::InvalidAmount); + } + let mut round_info = storage::get_round(env, group_id, group.current_round) .ok_or(ContractError::RoundNotActive)?; @@ -38,7 +61,7 @@ pub fn contribute(env: &Env, member: Address, group_id: u64) -> Result<(), Contr } // Transfer tokens from member to this contract - let token_client = soroban_sdk::token::Client::new(env, &group.token); + let token_client = soroban_sdk::token::Client::new(env, &token); token_client.transfer( &member, &env.current_contract_address(), @@ -48,6 +71,14 @@ pub fn contribute(env: &Env, member: Address, group_id: u64) -> Result<(), Contr // Record contribution round_info.contributions.set(member.clone(), true); round_info.total_contributed += group.contribution_amount; + let token_total = round_info + .token_contributions + .get(token.clone()) + .unwrap_or(0) + + group.contribution_amount; + round_info + .token_contributions + .set(token.clone(), token_total); // Check if all members have contributed if round_info.contributions.len() == group.members.len() { @@ -58,7 +89,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, token, group.contribution_amount), ); Ok(()) diff --git a/contracts/sorosave/src/group.rs b/contracts/sorosave/src/group.rs index 5033347..1f850da 100644 --- a/contracts/sorosave/src/group.rs +++ b/contracts/sorosave/src/group.rs @@ -12,6 +12,48 @@ pub fn create_group( contribution_amount: i128, cycle_length: u64, max_members: u32, +) -> Result { + let mut accepted_tokens = Vec::new(env); + accepted_tokens.push_back(token); + create_group_with_tokens( + env, + admin, + name, + accepted_tokens, + contribution_amount, + cycle_length, + max_members, + ) +} + +pub fn create_multi_token_group( + env: &Env, + admin: Address, + name: String, + accepted_tokens: Vec
, + contribution_amount: i128, + cycle_length: u64, + max_members: u32, +) -> Result { + create_group_with_tokens( + env, + admin, + name, + accepted_tokens, + contribution_amount, + cycle_length, + max_members, + ) +} + +fn create_group_with_tokens( + env: &Env, + admin: Address, + name: String, + accepted_tokens: Vec
, + contribution_amount: i128, + cycle_length: u64, + max_members: u32, ) -> Result { admin.require_auth(); @@ -21,6 +63,11 @@ pub fn create_group( if max_members < 2 { return Err(ContractError::InsufficientMembers); } + if accepted_tokens.is_empty() { + return Err(ContractError::InvalidAmount); + } + + let token = accepted_tokens.get(0).unwrap(); let group_id = storage::get_group_counter(env) + 1; storage::set_group_counter(env, group_id); @@ -33,6 +80,7 @@ pub fn create_group( name, admin: admin.clone(), token, + accepted_tokens, contribution_amount, cycle_length, max_members, @@ -150,6 +198,7 @@ pub fn start_group(env: &Env, admin: Address, group_id: u64) -> Result<(), Contr round_number: 1, recipient: first_recipient, contributions: Map::new(env), + token_contributions: Map::new(env), total_contributed: 0, is_complete: false, deadline: env.ledger().timestamp() + group.cycle_length, diff --git a/contracts/sorosave/src/lib.rs b/contracts/sorosave/src/lib.rs index 454a6ca..ff1ade8 100644 --- a/contracts/sorosave/src/lib.rs +++ b/contracts/sorosave/src/lib.rs @@ -49,6 +49,27 @@ impl SoroSaveContract { ) } + /// Create a savings group that accepts any token in the provided allowlist. + pub fn create_multi_token_group( + env: Env, + admin: Address, + name: String, + accepted_tokens: Vec
, + contribution_amount: i128, + cycle_length: u64, + max_members: u32, + ) -> Result { + group::create_multi_token_group( + &env, + admin, + name, + accepted_tokens, + contribution_amount, + 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) @@ -81,6 +102,16 @@ impl SoroSaveContract { contribution::contribute(&env, member, group_id) } + /// Contribute to the current round using an accepted token. + pub fn contribute_with_token( + env: Env, + member: Address, + group_id: u64, + token: Address, + ) -> Result<(), ContractError> { + contribution::contribute_with_token(&env, member, group_id, token) + } + /// Get the status of a specific round. pub fn get_round_status( env: Env, diff --git a/contracts/sorosave/src/payout.rs b/contracts/sorosave/src/payout.rs index 76ed389..2d2e748 100644 --- a/contracts/sorosave/src/payout.rs +++ b/contracts/sorosave/src/payout.rs @@ -18,13 +18,23 @@ pub fn distribute_payout(env: &Env, group_id: u64) -> Result<(), ContractError> return Err(ContractError::RoundNotComplete); } - // 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, - ); + // Transfer each token pot to the round's recipient. + let contract_address = env.current_contract_address(); + if round_info.token_contributions.is_empty() { + let token_client = soroban_sdk::token::Client::new(env, &group.token); + token_client.transfer( + &contract_address, + &round_info.recipient, + &round_info.total_contributed, + ); + } else { + for (token, amount) in round_info.token_contributions.iter() { + if amount > 0 { + let token_client = soroban_sdk::token::Client::new(env, &token); + token_client.transfer(&contract_address, &round_info.recipient, &amount); + } + } + } env.events().publish( (crate::symbol_short!("payout"),), @@ -50,6 +60,7 @@ pub fn distribute_payout(env: &Env, group_id: u64) -> Result<(), ContractError> round_number: group.current_round, recipient: next_recipient, contributions: Map::new(env), + token_contributions: Map::new(env), total_contributed: 0, is_complete: false, deadline: env.ledger().timestamp() + group.cycle_length, diff --git a/contracts/sorosave/src/test.rs b/contracts/sorosave/src/test.rs index f1ac1ef..5d0c24f 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, Vec, +}; 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(); @@ -46,6 +50,9 @@ fn test_create_group() { let group = client.get_group(&group_id); assert_eq!(group.admin, admin); + assert_eq!(group.token, token); + assert_eq!(group.accepted_tokens.len(), 1); + assert_eq!(group.accepted_tokens.get(0).unwrap(), token); assert_eq!(group.contribution_amount, 1_000_000); assert_eq!(group.max_members, 5); assert_eq!(group.status, GroupStatus::Forming); @@ -171,6 +178,117 @@ fn test_member_groups() { assert_eq!(groups.get(1).unwrap(), group2); } +#[test] +fn test_multi_token_group_rejects_empty_allowlist() { + let (env, admin, client, _) = setup_env(); + let accepted_tokens = Vec::new(&env); + + assert_eq!( + client.try_create_multi_token_group( + &admin, + &String::from_str(&env, "Empty Token Group"), + &accepted_tokens, + &1_000_000, + &86400, + &5, + ), + Err(Ok(ContractError::InvalidAmount)) + ); +} + +#[test] +fn test_multi_token_contribution_flow() { + let env = Env::default(); + env.mock_all_auths(); + + let admin = Address::generate(&env); + let member1 = Address::generate(&env); + let contract_id = env.register(SoroSaveContract, (&admin,)); + let client = SoroSaveContractClient::new(&env, &contract_id); + + let token_a_admin = Address::generate(&env); + let token_a_id = env.register_stellar_asset_contract_v2(token_a_admin); + let token_a = token_a_id.address(); + let token_a_sac = StellarAssetClient::new(&env, &token_a); + token_a_sac.mint(&admin, &10_000_000); + + let token_b_admin = Address::generate(&env); + let token_b_id = env.register_stellar_asset_contract_v2(token_b_admin); + let token_b = token_b_id.address(); + let token_b_sac = StellarAssetClient::new(&env, &token_b); + token_b_sac.mint(&member1, &10_000_000); + + let mut accepted_tokens = Vec::new(&env); + accepted_tokens.push_back(token_a.clone()); + accepted_tokens.push_back(token_b.clone()); + + let group_id = client.create_multi_token_group( + &admin, + &String::from_str(&env, "Multi Token Group"), + &accepted_tokens, + &1_000_000, + &86400, + &5, + ); + let group = client.get_group(&group_id); + assert_eq!(group.token, token_a); + assert_eq!(group.accepted_tokens.len(), 2); + + client.join_group(&member1, &group_id); + client.start_group(&admin, &group_id); + + client.contribute_with_token(&admin, &group_id, &token_a); + client.contribute_with_token(&member1, &group_id, &token_b); + + let round = client.get_round_status(&group_id, &1); + assert!(round.is_complete); + assert_eq!(round.total_contributed, 2_000_000); + assert_eq!( + round.token_contributions.get(token_a.clone()).unwrap(), + 1_000_000 + ); + assert_eq!( + round.token_contributions.get(token_b.clone()).unwrap(), + 1_000_000 + ); + + client.distribute_payout(&group_id); + + let token_a_client = TokenClient::new(&env, &token_a); + let token_b_client = TokenClient::new(&env, &token_b); + assert_eq!(token_a_client.balance(&admin), 10_000_000); + assert_eq!(token_b_client.balance(&admin), 1_000_000); + assert_eq!(token_b_client.balance(&member1), 9_000_000); +} + +#[test] +fn test_multi_token_contribution_rejects_unaccepted_token() { + let (env, admin, client, token) = setup_env(); + let member1 = Address::generate(&env); + let unaccepted_token_admin = Address::generate(&env); + let unaccepted_token_id = env.register_stellar_asset_contract_v2(unaccepted_token_admin); + let unaccepted_token = unaccepted_token_id.address(); + + let mut accepted_tokens = Vec::new(&env); + accepted_tokens.push_back(token); + + let group_id = client.create_multi_token_group( + &admin, + &String::from_str(&env, "Rejected Token Group"), + &accepted_tokens, + &1_000_000, + &86400, + &5, + ); + client.join_group(&member1, &group_id); + client.start_group(&admin, &group_id); + + assert_eq!( + client.try_contribute_with_token(&admin, &group_id, &unaccepted_token), + Err(Ok(ContractError::InvalidAmount)) + ); +} + #[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..1d91a5d 100644 --- a/contracts/sorosave/src/types.rs +++ b/contracts/sorosave/src/types.rs @@ -19,6 +19,7 @@ pub struct SavingsGroup { pub name: String, pub admin: Address, pub token: Address, + pub accepted_tokens: Vec
, pub contribution_amount: i128, pub cycle_length: u64, pub max_members: u32, @@ -37,6 +38,7 @@ pub struct RoundInfo { pub round_number: u32, pub recipient: Address, pub contributions: Map, + pub token_contributions: Map, pub total_contributed: i128, pub is_complete: bool, pub deadline: u64,