From 55aacabd7c6c22adb1f164c5dc2f1f6b9e2a6e93 Mon Sep 17 00:00:00 2001 From: Tu Pham Date: Mon, 25 May 2026 16:38:51 +0700 Subject: [PATCH] feat: remove members after missed contributions --- contracts/sorosave/src/contribution.rs | 161 ++++++++++++++++++++++++- contracts/sorosave/src/errors.rs | 1 + contracts/sorosave/src/group.rs | 28 +++++ contracts/sorosave/src/lib.rs | 29 +++++ contracts/sorosave/src/payout.rs | 1 + contracts/sorosave/src/storage.rs | 22 ++++ contracts/sorosave/src/test.rs | 116 +++++++++++++++++- contracts/sorosave/src/types.rs | 3 + 8 files changed, 354 insertions(+), 7 deletions(-) diff --git a/contracts/sorosave/src/contribution.rs b/contracts/sorosave/src/contribution.rs index 4285c9f..b6d2727 100644 --- a/contracts/sorosave/src/contribution.rs +++ b/contracts/sorosave/src/contribution.rs @@ -1,8 +1,8 @@ -use soroban_sdk::{Address, Env}; +use soroban_sdk::{Address, Env, Vec}; use crate::errors::ContractError; use crate::storage; -use crate::types::{GroupStatus, RoundInfo}; +use crate::types::{GroupStatus, RoundInfo, SavingsGroup}; pub fn contribute(env: &Env, member: Address, group_id: u64) -> Result<(), ContractError> { member.require_auth(); @@ -36,6 +36,9 @@ pub fn contribute(env: &Env, member: Address, group_id: u64) -> Result<(), Contr if round_info.contributions.contains_key(member.clone()) { return Err(ContractError::AlreadyContributed); } + if round_info.misses.contains_key(member.clone()) { + return Err(ContractError::AlreadyContributed); + } // Transfer tokens from member to this contract let token_client = soroban_sdk::token::Client::new(env, &group.token); @@ -48,11 +51,9 @@ 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; + storage::clear_consecutive_misses(env, group_id, &member); - // Check if all members have contributed - if round_info.contributions.len() == group.members.len() { - round_info.is_complete = true; - } + refresh_round_completion(&group, &mut round_info); storage::set_round(env, group_id, &round_info); @@ -78,3 +79,151 @@ pub fn has_contributed( storage::get_round(env, group_id, round).ok_or(ContractError::RoundNotActive)?; Ok(round_info.contributions.contains_key(member)) } + +pub fn get_consecutive_misses( + env: &Env, + group_id: u64, + member: Address, +) -> Result { + let group = storage::get_group(env, group_id).ok_or(ContractError::GroupNotFound)?; + ensure_member(&group, &member)?; + Ok(storage::get_consecutive_misses(env, group_id, &member)) +} + +pub fn record_missed_contribution( + env: &Env, + admin: Address, + group_id: u64, + member: Address, +) -> Result<(), ContractError> { + admin.require_auth(); + + let mut group = storage::get_group(env, group_id).ok_or(ContractError::GroupNotFound)?; + + if admin != group.admin && admin != storage::get_admin(env) { + return Err(ContractError::Unauthorized); + } + + if group.status != GroupStatus::Active { + return Err(ContractError::GroupNotActive); + } + + ensure_member(&group, &member)?; + + let mut round_info = storage::get_round(env, group_id, group.current_round) + .ok_or(ContractError::RoundNotActive)?; + + if env.ledger().timestamp() < round_info.deadline { + return Err(ContractError::DeadlineNotReached); + } + + if round_info.contributions.contains_key(member.clone()) { + return Err(ContractError::AlreadyContributed); + } + if round_info.misses.contains_key(member.clone()) { + return Err(ContractError::AlreadyContributed); + } + + let misses = storage::get_consecutive_misses(env, group_id, &member) + 1; + storage::set_consecutive_misses(env, group_id, &member, misses); + round_info.misses.set(member.clone(), true); + + env.events().publish( + (crate::symbol_short!("miss_rec"),), + (group_id, member.clone(), misses), + ); + + if misses >= group.max_consecutive_misses { + remove_defaulted_member(env, &mut group, &mut round_info, &member)?; + round_info.misses.remove(member.clone()); + storage::clear_consecutive_misses(env, group_id, &member); + storage::remove_member_group(env, &member, group_id); + + env.events() + .publish((crate::symbol_short!("mbr_rmvd"),), (group_id, member)); + } + + refresh_round_completion(&group, &mut round_info); + + storage::set_round(env, group_id, &round_info); + storage::set_group(env, &group); + + Ok(()) +} + +fn ensure_member(group: &SavingsGroup, member: &Address) -> Result<(), ContractError> { + for m in group.members.iter() { + if m == *member { + return Ok(()); + } + } + Err(ContractError::NotMember) +} + +fn refresh_round_completion(group: &SavingsGroup, round_info: &mut RoundInfo) { + if round_info.contributions.len() + round_info.misses.len() >= group.members.len() { + round_info.is_complete = true; + } +} + +fn remove_defaulted_member( + env: &Env, + group: &mut SavingsGroup, + round_info: &mut RoundInfo, + member: &Address, +) -> Result<(), ContractError> { + let mut remaining_members = Vec::new(env); + for m in group.members.iter() { + if m != *member { + remaining_members.push_back(m); + } + } + + if remaining_members.is_empty() { + group.members = remaining_members; + group.status = GroupStatus::Completed; + round_info.is_complete = true; + return Ok(()); + } + + let replacement = choose_replacement_for_slot(&group.payout_order, &remaining_members, member); + let mut redistributed_order = Vec::new(env); + for recipient in group.payout_order.iter() { + if recipient == *member { + redistributed_order.push_back(replacement.clone()); + } else { + redistributed_order.push_back(recipient); + } + } + + if round_info.recipient == *member { + round_info.recipient = replacement; + } + + group.members = remaining_members; + group.payout_order = redistributed_order; + + Ok(()) +} + +fn choose_replacement_for_slot( + payout_order: &Vec
, + remaining_members: &Vec
, + removed_member: &Address, +) -> Address { + let mut removed_index = 0; + for i in 0..payout_order.len() { + if payout_order.get(i).unwrap() == *removed_member { + removed_index = i; + break; + } + } + + let replacement_index = if removed_index < remaining_members.len() { + removed_index + } else { + remaining_members.len() - 1 + }; + + remaining_members.get(replacement_index).unwrap() +} diff --git a/contracts/sorosave/src/errors.rs b/contracts/sorosave/src/errors.rs index a2b9d9d..d1dd3d4 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, + DeadlineNotReached = 19, } diff --git a/contracts/sorosave/src/group.rs b/contracts/sorosave/src/group.rs index 5033347..3be38d2 100644 --- a/contracts/sorosave/src/group.rs +++ b/contracts/sorosave/src/group.rs @@ -42,6 +42,7 @@ pub fn create_group( total_rounds: 0, status: GroupStatus::Forming, created_at: env.ledger().timestamp(), + max_consecutive_misses: 3, }; storage::set_group(env, &group); @@ -150,6 +151,7 @@ pub fn start_group(env: &Env, admin: Address, group_id: u64) -> Result<(), Contr round_number: 1, recipient: first_recipient, contributions: Map::new(env), + misses: Map::new(env), total_contributed: 0, is_complete: false, deadline: env.ledger().timestamp() + group.cycle_length, @@ -171,3 +173,29 @@ pub fn get_group(env: &Env, group_id: u64) -> Result Vec { storage::get_member_groups(env, &member) } + +pub fn set_max_consecutive_misses( + env: &Env, + admin: Address, + group_id: u64, + max_misses: u32, +) -> Result<(), ContractError> { + admin.require_auth(); + + if max_misses == 0 { + return Err(ContractError::InvalidAmount); + } + + let mut group = storage::get_group(env, group_id).ok_or(ContractError::GroupNotFound)?; + if admin != group.admin && admin != storage::get_admin(env) { + return Err(ContractError::Unauthorized); + } + + group.max_consecutive_misses = max_misses; + storage::set_group(env, &group); + + env.events() + .publish((crate::symbol_short!("miss_cfg"),), (group_id, max_misses)); + + Ok(()) +} diff --git a/contracts/sorosave/src/lib.rs b/contracts/sorosave/src/lib.rs index 454a6ca..ba55166 100644 --- a/contracts/sorosave/src/lib.rs +++ b/contracts/sorosave/src/lib.rs @@ -64,6 +64,16 @@ impl SoroSaveContract { group::start_group(&env, admin, group_id) } + /// Configure how many consecutive missed contributions remove a member. + pub fn set_max_consecutive_misses( + env: Env, + admin: Address, + group_id: u64, + max_misses: u32, + ) -> Result<(), ContractError> { + group::set_max_consecutive_misses(&env, admin, group_id, max_misses) + } + /// Get group details. pub fn get_group(env: Env, group_id: u64) -> Result { group::get_group(&env, group_id) @@ -100,6 +110,25 @@ impl SoroSaveContract { contribution::has_contributed(&env, member, group_id, round) } + /// Get a member's current consecutive missed contribution count. + pub fn get_consecutive_misses( + env: Env, + group_id: u64, + member: Address, + ) -> Result { + contribution::get_consecutive_misses(&env, group_id, member) + } + + /// Record a missed contribution after the active round deadline. + pub fn record_missed_contribution( + env: Env, + admin: Address, + group_id: u64, + member: Address, + ) -> Result<(), ContractError> { + contribution::record_missed_contribution(&env, admin, group_id, member) + } + // ─── 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..ee9900c 100644 --- a/contracts/sorosave/src/payout.rs +++ b/contracts/sorosave/src/payout.rs @@ -50,6 +50,7 @@ pub fn distribute_payout(env: &Env, group_id: u64) -> Result<(), ContractError> round_number: group.current_round, recipient: next_recipient, contributions: Map::new(env), + misses: Map::new(env), total_contributed: 0, is_complete: false, deadline: env.ledger().timestamp() + group.cycle_length, diff --git a/contracts/sorosave/src/storage.rs b/contracts/sorosave/src/storage.rs index 3f24bc8..075dcdd 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); } +// --- Consecutive missed contributions --- + +pub fn get_consecutive_misses(env: &Env, group_id: u64, member: &Address) -> u32 { + let key = DataKey::MemberMisses(group_id, member.clone()); + let result = env.storage().persistent().get(&key).unwrap_or(0); + if result > 0 { + extend_persistent_ttl(env, &key); + } + result +} + +pub fn set_consecutive_misses(env: &Env, group_id: u64, member: &Address, misses: u32) { + let key = DataKey::MemberMisses(group_id, member.clone()); + env.storage().persistent().set(&key, &misses); + extend_persistent_ttl(env, &key); +} + +pub fn clear_consecutive_misses(env: &Env, group_id: u64, member: &Address) { + let key = DataKey::MemberMisses(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..41cfa8b 100644 --- a/contracts/sorosave/src/test.rs +++ b/contracts/sorosave/src/test.rs @@ -1,4 +1,8 @@ -use soroban_sdk::{testutils::Address as _, token::StellarAssetClient, Address, Env, String}; +use soroban_sdk::{ + testutils::{Address as _, Ledger}, + token::{Client as TokenClient, StellarAssetClient}, + Address, Env, String, +}; use crate::types::GroupStatus; use crate::{SoroSaveContract, SoroSaveContractClient}; @@ -222,3 +226,113 @@ fn test_set_group_admin() { let group = client.get_group(&group_id); assert_eq!(group.admin, new_admin); } + +#[test] +fn test_set_max_consecutive_misses() { + let (env, admin, client, token) = setup_env(); + let group_id = create_test_group(&env, &client, &admin, &token); + + assert_eq!(client.get_group(&group_id).max_consecutive_misses, 3); + + client.set_max_consecutive_misses(&admin, &group_id, &1); + assert_eq!(client.get_group(&group_id).max_consecutive_misses, 1); +} + +#[test] +fn test_missed_contribution_removes_member_and_redistributes_slot() { + let env = Env::default(); + env.mock_all_auths(); + + let admin = 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 = token_id.address(); + let token_sac = StellarAssetClient::new(&env, &token); + + let member1 = Address::generate(&env); + let member2 = Address::generate(&env); + token_sac.mint(&admin, &10_000_000); + token_sac.mint(&member1, &10_000_000); + token_sac.mint(&member2, &10_000_000); + + let group_id = client.create_group( + &admin, + &String::from_str(&env, "Miss Removal"), + &token, + &1_000_000, + &1, + &3, + ); + client.join_group(&member1, &group_id); + client.join_group(&member2, &group_id); + client.set_max_consecutive_misses(&admin, &group_id, &1); + client.start_group(&admin, &group_id); + + env.ledger().with_mut(|ledger| ledger.timestamp = 2); + client.record_missed_contribution(&admin, &group_id, &member1); + + let group = client.get_group(&group_id); + assert_eq!(group.members.len(), 2); + for member in group.members.iter() { + assert_ne!(member, member1); + } + for recipient in group.payout_order.iter() { + assert_ne!(recipient, member1); + } + assert_eq!(client.get_member_groups(&member1).len(), 0); + + client.contribute(&admin, &group_id); + client.contribute(&member2, &group_id); + assert!(client.get_round_status(&group_id, &1).is_complete); + + client.distribute_payout(&group_id); + assert_eq!(client.get_current_recipient(&group_id), member2); +} + +#[test] +fn test_successful_contribution_resets_misses() { + let env = Env::default(); + env.mock_all_auths(); + + let admin = 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 = 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, "Miss Reset"), + &token, + &1_000_000, + &1, + &2, + ); + client.join_group(&member1, &group_id); + client.set_max_consecutive_misses(&admin, &group_id, &2); + client.start_group(&admin, &group_id); + + env.ledger().with_mut(|ledger| ledger.timestamp = 2); + client.record_missed_contribution(&admin, &group_id, &member1); + assert_eq!(client.get_consecutive_misses(&group_id, &member1), 1); + + client.contribute(&admin, &group_id); + assert!(client.get_round_status(&group_id, &1).is_complete); + client.distribute_payout(&group_id); + + client.contribute(&member1, &group_id); + assert_eq!(client.get_consecutive_misses(&group_id, &member1), 0); + + let token_client = TokenClient::new(&env, &token); + assert_eq!(token_client.balance(&member1), 9_000_000); +} diff --git a/contracts/sorosave/src/types.rs b/contracts/sorosave/src/types.rs index f741099..8d566d4 100644 --- a/contracts/sorosave/src/types.rs +++ b/contracts/sorosave/src/types.rs @@ -28,6 +28,7 @@ pub struct SavingsGroup { pub total_rounds: u32, pub status: GroupStatus, pub created_at: u64, + pub max_consecutive_misses: u32, } /// Tracks contributions and payout status for a single round. @@ -37,6 +38,7 @@ pub struct RoundInfo { pub round_number: u32, pub recipient: Address, pub contributions: Map, + pub misses: Map, pub total_contributed: i128, pub is_complete: bool, pub deadline: u64, @@ -60,5 +62,6 @@ pub enum DataKey { Group(u64), Round(u64, u32), MemberGroups(Address), + MemberMisses(u64, Address), Dispute(u64), }