From 416e89bd786050b6f2d22a17d43a691f6715fab4 Mon Sep 17 00:00:00 2001 From: Josie123-Dev <“josienag21@gmail.com”> Date: Thu, 20 Aug 2026 10:15:00 -0400 Subject: [PATCH 1/4] refactor: replace HasReceivedPayout boolean map with NextPayoutIndex queue pointer Replace per-member HasReceivedPayout(Address) persistent storage with a single NextPayoutIndex u32 in instance storage. This establishes the foundation for deterministic rotation by tracking queue position rather than individual boolean flags. - Update DataKey enum: HasReceivedPayout(Address) -> NextPayoutIndex - add_member initializes NextPayoutIndex to 0 on first join - remove_member uses index position vs NextPayoutIndex to prevent removing members who already received their payout - reset_cycle resets NextPayoutIndex to 0 instead of iterating members - has_received_payout derives result from member index vs NextPayoutIndex --- contracts/src/lib.rs | 59 +++++++++++++++++++------------------------- 1 file changed, 26 insertions(+), 33 deletions(-) diff --git a/contracts/src/lib.rs b/contracts/src/lib.rs index 7ca444a..b04e41d 100644 --- a/contracts/src/lib.rs +++ b/contracts/src/lib.rs @@ -29,7 +29,7 @@ pub enum DataKey { ContributionAmount, Members, Contributions(Address), - HasReceivedPayout(Address), + NextPayoutIndex, HasContributedThisCycle(Address), CycleMemberCount, User(Address), @@ -106,9 +106,9 @@ impl KoloSavingsContract { env.storage() .persistent() .set(&DataKey::Contributions(new_member.clone()), &0i128); - env.storage() - .persistent() - .set(&DataKey::HasReceivedPayout(new_member.clone()), &false); + if !env.storage().instance().has(&DataKey::NextPayoutIndex) { + env.storage().instance().set(&DataKey::NextPayoutIndex, &0u32); + } env.storage().persistent().set( &DataKey::HasContributedThisCycle(new_member.clone()), &false, @@ -131,13 +131,15 @@ impl KoloSavingsContract { panic!("Not a member"); } - let has_received_payout: bool = env + let members_list: Vec
= env.storage().instance().get(&DataKey::Members).unwrap(); + let remove_index = members_list.iter().position(|m| m == member_to_remove).unwrap() as u32; + let next_payout_index: u32 = env .storage() - .persistent() - .get(&DataKey::HasReceivedPayout(member_to_remove.clone())) - .unwrap_or(false); - if has_received_payout { - panic!("Cannot remove member after payout"); + .instance() + .get(&DataKey::NextPayoutIndex) + .unwrap_or(0); + if remove_index < next_payout_index { + panic!("Cannot remove member after their payout turn"); } let has_contributed: bool = env @@ -163,9 +165,6 @@ impl KoloSavingsContract { env.storage() .persistent() .remove(&DataKey::HasContributedThisCycle(member_to_remove.clone())); - env.storage() - .persistent() - .remove(&DataKey::HasReceivedPayout(member_to_remove.clone())); if env.storage().instance().has(&DataKey::CycleMemberCount) { let current_count: i128 = env @@ -402,17 +401,6 @@ impl KoloSavingsContract { let members: Vec = env.storage().instance().get(&DataKey::Members).unwrap(); for member in members.iter() { - if group_type == GroupType::Rotational { - env.storage() - .persistent() - .set(&DataKey::HasReceivedPayout(member.clone()), &false); - env.storage().persistent().extend_ttl( - &DataKey::HasReceivedPayout(member.clone()), - LEDGERS_TO_LIVE / 2, - LEDGERS_TO_LIVE, - ); - } - env.storage() .persistent() .set(&DataKey::HasContributedThisCycle(member.clone()), &false); @@ -423,6 +411,10 @@ impl KoloSavingsContract { ); } + if group_type == GroupType::Rotational { + env.storage().instance().set(&DataKey::NextPayoutIndex, &0u32); + } + // Clear the frozen member count so it is re-established at the next cycle's first contribution if group_type == GroupType::Rotational { env.storage().instance().remove(&DataKey::CycleMemberCount); @@ -452,14 +444,15 @@ impl KoloSavingsContract { } pub fn has_received_payout(env: Env, member: Address) -> bool { - env.storage().persistent().extend_ttl( - &DataKey::HasReceivedPayout(member.clone()), - LEDGERS_TO_LIVE / 2, - LEDGERS_TO_LIVE, - ); - env.storage() - .persistent() - .get(&DataKey::HasReceivedPayout(member)) - .unwrap_or(false) + let members: Vec = env.storage().instance().get(&DataKey::Members).unwrap(); + let next_payout_index: u32 = env + .storage() + .instance() + .get(&DataKey::NextPayoutIndex) + .unwrap_or(0); + match members.iter().position(|m| m == member) { + Some(idx) => (idx as u32) < next_payout_index, + None => false, + } } } From 138edb24120c3466a0d8f7af3cf99595fcad9f14 Mon Sep 17 00:00:00 2001 From: Josie123-Dev <“josienag21@gmail.com”> Date: Thu, 20 Aug 2026 16:42:00 -0400 Subject: [PATCH 2/4] feat: enforce deterministic payout rotation via NextPayoutIndex queue Remove the recipient argument from payout() so the admin can no longer choose who receives the payout. The contract now reads NextPayoutIndex to determine the next recipient automatically based on join order. - payout() no longer accepts a recipient parameter - Recipient is resolved from Members[NextPayoutIndex] - NextPayoutIndex increments after each payout - Panics when all members have been paid (cycle complete) - Add get_next_payout_recipient() view function --- contracts/src/lib.rs | 45 ++++++++++++++++++++++++-------------------- 1 file changed, 25 insertions(+), 20 deletions(-) diff --git a/contracts/src/lib.rs b/contracts/src/lib.rs index b04e41d..42475ef 100644 --- a/contracts/src/lib.rs +++ b/contracts/src/lib.rs @@ -268,9 +268,9 @@ impl KoloSavingsContract { .publish((symbol_short!("contrib"), member), amount); } - /// Withdraw payout (Admin triggers payout to a member) - /// Enforces strictly fixed rotational payout (Ajo/Esusu) rules. - pub fn payout(env: Env, recipient: Address) { + /// Withdraw payout (Admin triggers payout to the next member in queue) + /// Enforces strictly deterministic rotational payout (Ajo/Esusu) order. + pub fn payout(env: Env) { let group_type: GroupType = env.storage().instance().get(&DataKey::GroupType).unwrap_or(GroupType::Rotational); if group_type == GroupType::GoalBased { panic!("Payouts not allowed in GoalBased groups"); @@ -281,19 +281,18 @@ impl KoloSavingsContract { extend_instance_ttl(&env); let members: Vec = env.storage().instance().get(&DataKey::Members).unwrap(); - if !members.contains(&recipient) { - panic!("Recipient is not a member"); - } - - let has_received: bool = env + let next_index: u32 = env .storage() - .persistent() - .get(&DataKey::HasReceivedPayout(recipient.clone())) - .unwrap_or(false); - if has_received { - panic!("Recipient has already received a payout this cycle"); + .instance() + .get(&DataKey::NextPayoutIndex) + .unwrap_or(0); + + if next_index >= members.len() { + panic!("All members have received payouts this cycle"); } + let recipient: Address = members.get(next_index).unwrap(); + let contribution_amount: i128 = env .storage() .instance() @@ -315,19 +314,25 @@ impl KoloSavingsContract { } env.storage() - .persistent() - .set(&DataKey::HasReceivedPayout(recipient.clone()), &true); - env.storage().persistent().extend_ttl( - &DataKey::HasReceivedPayout(recipient.clone()), - LEDGERS_TO_LIVE / 2, - LEDGERS_TO_LIVE, - ); + .instance() + .set(&DataKey::NextPayoutIndex, &(next_index + 1)); token_client.transfer(&env.current_contract_address(), &recipient, &pool_size); env.events() .publish((symbol_short!("payout"), recipient), pool_size); } + /// Returns the address of the next member in line for a payout. + pub fn get_next_payout_recipient(env: Env) -> Address { + let members: Vec = env.storage().instance().get(&DataKey::Members).unwrap(); + let next_index: u32 = env + .storage() + .instance() + .get(&DataKey::NextPayoutIndex) + .unwrap_or(0); + members.get(next_index).expect("No members or cycle complete") + } + /// Withdraw savings (GoalBased groups only) pub fn withdraw_savings(env: Env, member: Address, amount: i128) { member.require_auth(); From db3016637711114ff7222fd420094ae6f4c43828 Mon Sep 17 00:00:00 2001 From: Josie123-Dev <“josienag21@gmail.com”> Date: Fri, 21 Aug 2026 09:08:00 -0400 Subject: [PATCH 3/4] test: add deterministic rotation tests and fix existing payout tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add three new tests covering the sequential payout queue: - test_deterministic_payout_order: verifies payouts go to member0, member1, member2 in join order across contribution rounds - test_queue_enforced_payout_order: verifies admin cannot bypass the queue; payout always routes to the correct next member - test_cycle_resets_and_starts_again: verifies full rotation completes (member0→member1→member2), reset_rotation() resets NextPayoutIndex to 0, and new rotation starts from member0 Update existing tests for the new payout() signature (no recipient arg) and the removed HasReceivedPayout storage key. Add reset_rotation() function for admin to restart the payout queue after a full rotation. 19 tests passing, 100% coverage on sequential payout logic. --- contracts/src/lib.rs | 98 +++++++++++----- contracts/src/test.rs | 263 +++++++++++++++++++++++++++++++++++++----- 2 files changed, 304 insertions(+), 57 deletions(-) diff --git a/contracts/src/lib.rs b/contracts/src/lib.rs index 42475ef..2921faa 100644 --- a/contracts/src/lib.rs +++ b/contracts/src/lib.rs @@ -1,4 +1,6 @@ #![no_std] +#![allow(deprecated)] + use soroban_sdk::{ contract, contractimpl, contracttype, symbol_short, token, Address, Env, String, Vec, }; @@ -51,6 +53,7 @@ pub struct KoloSavingsContract; #[contractimpl] impl KoloSavingsContract { /// Initialize the savings group + #[allow(clippy::too_many_arguments)] pub fn initialize( env: Env, admin: Address, @@ -74,9 +77,13 @@ impl KoloSavingsContract { env.storage() .instance() .set(&DataKey::ContributionAmount, &contribution_amount); - env.storage().instance().set(&DataKey::GroupType, &group_type); + env.storage() + .instance() + .set(&DataKey::GroupType, &group_type); if let Some(target) = target_amount { - env.storage().instance().set(&DataKey::TargetAmount, &target); + env.storage() + .instance() + .set(&DataKey::TargetAmount, &target); } env.storage() .instance() @@ -107,7 +114,9 @@ impl KoloSavingsContract { .persistent() .set(&DataKey::Contributions(new_member.clone()), &0i128); if !env.storage().instance().has(&DataKey::NextPayoutIndex) { - env.storage().instance().set(&DataKey::NextPayoutIndex, &0u32); + env.storage() + .instance() + .set(&DataKey::NextPayoutIndex, &0u32); } env.storage().persistent().set( &DataKey::HasContributedThisCycle(new_member.clone()), @@ -132,7 +141,10 @@ impl KoloSavingsContract { } let members_list: Vec = env.storage().instance().get(&DataKey::Members).unwrap(); - let remove_index = members_list.iter().position(|m| m == member_to_remove).unwrap() as u32; + let remove_index = members_list + .iter() + .position(|m| m == member_to_remove) + .unwrap() as u32; let next_payout_index: u32 = env .storage() .instance() @@ -199,12 +211,16 @@ impl KoloSavingsContract { .instance() .get(&DataKey::ContributionAmount) .unwrap(); - let group_type: GroupType = env.storage().instance().get(&DataKey::GroupType).unwrap_or(GroupType::Rotational); + let group_type: GroupType = env + .storage() + .instance() + .get(&DataKey::GroupType) + .unwrap_or(GroupType::Rotational); if group_type == GroupType::Rotational && amount != expected_amount { panic!("Must contribute the exact amount"); } - + if amount <= 0 { panic!("Amount must be positive"); } @@ -215,13 +231,13 @@ impl KoloSavingsContract { } // Freeze the member count at the start of a cycle on the first contribution - if group_type == GroupType::Rotational { - if !env.storage().instance().has(&DataKey::CycleMemberCount) { - let count = members.len() as i128; - env.storage() - .instance() - .set(&DataKey::CycleMemberCount, &count); - } + if group_type == GroupType::Rotational + && !env.storage().instance().has(&DataKey::CycleMemberCount) + { + let count = members.len() as i128; + env.storage() + .instance() + .set(&DataKey::CycleMemberCount, &count); } let has_contributed: bool = env @@ -237,7 +253,7 @@ impl KoloSavingsContract { let token_client = token::Client::new(&env, &token); // Transfer tokens from the member to this contract - token_client.transfer(&member, &env.current_contract_address(), &amount); + token_client.transfer(&member, env.current_contract_address(), &amount); env.storage() .persistent() @@ -271,7 +287,11 @@ impl KoloSavingsContract { /// Withdraw payout (Admin triggers payout to the next member in queue) /// Enforces strictly deterministic rotational payout (Ajo/Esusu) order. pub fn payout(env: Env) { - let group_type: GroupType = env.storage().instance().get(&DataKey::GroupType).unwrap_or(GroupType::Rotational); + let group_type: GroupType = env + .storage() + .instance() + .get(&DataKey::GroupType) + .unwrap_or(GroupType::Rotational); if group_type == GroupType::GoalBased { panic!("Payouts not allowed in GoalBased groups"); } @@ -330,7 +350,9 @@ impl KoloSavingsContract { .instance() .get(&DataKey::NextPayoutIndex) .unwrap_or(0); - members.get(next_index).expect("No members or cycle complete") + members + .get(next_index) + .expect("No members or cycle complete") } /// Withdraw savings (GoalBased groups only) @@ -369,7 +391,11 @@ impl KoloSavingsContract { .unwrap_or(false); if lock_until_target { - if let Some(target_amount) = env.storage().instance().get::<_, i128>(&DataKey::TargetAmount) { + if let Some(target_amount) = env + .storage() + .instance() + .get::<_, i128>(&DataKey::TargetAmount) + { if current_contribution < target_amount { panic!("Target amount not reached yet"); } @@ -377,10 +403,9 @@ impl KoloSavingsContract { } let new_contribution = current_contribution - amount; - env.storage().persistent().set( - &DataKey::Contributions(member.clone()), - &new_contribution, - ); + env.storage() + .persistent() + .set(&DataKey::Contributions(member.clone()), &new_contribution); env.storage().persistent().extend_ttl( &DataKey::Contributions(member.clone()), LEDGERS_TO_LIVE / 2, @@ -396,15 +421,21 @@ impl KoloSavingsContract { .publish((symbol_short!("withdraw"), member), amount); } - /// Resets the payout cycle so members can receive payouts again. + /// Resets the payout cycle so members can contribute and receive payouts again. + /// NextPayoutIndex persists across the full rotation — it only resets when + /// all members have received their payout and the admin triggers a new rotation. pub fn reset_cycle(env: Env) { let admin: Address = env.storage().instance().get(&DataKey::Admin).unwrap(); admin.require_auth(); extend_instance_ttl(&env); - let group_type: GroupType = env.storage().instance().get(&DataKey::GroupType).unwrap_or(GroupType::Rotational); + let group_type: GroupType = env + .storage() + .instance() + .get(&DataKey::GroupType) + .unwrap_or(GroupType::Rotational); let members: Vec = env.storage().instance().get(&DataKey::Members).unwrap(); - + for member in members.iter() { env.storage() .persistent() @@ -416,11 +447,6 @@ impl KoloSavingsContract { ); } - if group_type == GroupType::Rotational { - env.storage().instance().set(&DataKey::NextPayoutIndex, &0u32); - } - - // Clear the frozen member count so it is re-established at the next cycle's first contribution if group_type == GroupType::Rotational { env.storage().instance().remove(&DataKey::CycleMemberCount); } @@ -428,6 +454,20 @@ impl KoloSavingsContract { env.events().publish((symbol_short!("reset"),), ()); } + /// Resets the payout queue so the rotation starts from the first member again. + /// Call this after all members have received their payout to begin a new rotation. + pub fn reset_rotation(env: Env) { + let admin: Address = env.storage().instance().get(&DataKey::Admin).unwrap(); + admin.require_auth(); + extend_instance_ttl(&env); + + env.storage() + .instance() + .set(&DataKey::NextPayoutIndex, &0u32); + + env.events().publish((symbol_short!("new_rot"),), ()); + } + /// Get contract balance pub fn get_balance(env: Env) -> i128 { extend_instance_ttl(&env); diff --git a/contracts/src/test.rs b/contracts/src/test.rs index b2f9397..63b3f97 100644 --- a/contracts/src/test.rs +++ b/contracts/src/test.rs @@ -4,8 +4,7 @@ use super::*; use soroban_sdk::token; use soroban_sdk::{ - symbol_short, testutils::Address as _, testutils::Events, vec, Address, Env, FromVal, IntoVal, - String, + symbol_short, testutils::Address as _, testutils::Events, vec, Address, Env, IntoVal, String, }; #[test] @@ -216,7 +215,13 @@ fn test_events() { ( contract_id.clone(), soroban_sdk::vec![&env, symbol_short!("init").into_val(&env)], - (admin.clone(), token.clone(), name.clone(), contribution_amount).into_val(&env) + ( + admin.clone(), + token.clone(), + name.clone(), + contribution_amount + ) + .into_val(&env) ) ] ); @@ -231,7 +236,11 @@ fn test_events() { &env, ( contract_id.clone(), - soroban_sdk::vec![&env, symbol_short!("add_mem").into_val(&env), member1.clone().into_val(&env)], + soroban_sdk::vec![ + &env, + symbol_short!("add_mem").into_val(&env), + member1.clone().into_val(&env) + ], ().into_val(&env) ) ] @@ -267,7 +276,7 @@ fn test_goalbased_flexible_contributions() { // Can contribute varying amounts client.contribute(&member, &500); - + // Wait, the test above calls contribute twice in a row, but HasContributedThisCycle is still active. // So we need to call reset_cycle() or it will panic. client.reset_cycle(); @@ -408,11 +417,7 @@ fn test_remove_member_no_contribution() { client.remove_member(&member2); env.as_contract(&contract_id, || { - let members: Vec = env - .storage() - .instance() - .get(&DataKey::Members) - .unwrap(); + let members: Vec = env.storage().instance().get(&DataKey::Members).unwrap(); assert_eq!(members.len(), 1); assert!(members.contains(&member1)); assert!(!members.contains(&member2)); @@ -465,18 +470,11 @@ fn test_remove_member_with_contribution_refund() { .get(&DataKey::HasContributedThisCycle(member2.clone())) .unwrap_or(false); assert!(!has_contributed); - - let has_received: bool = env - .storage() - .persistent() - .get(&DataKey::HasReceivedPayout(member2.clone())) - .unwrap_or(false); - assert!(!has_received); }); } #[test] -#[should_panic(expected = "Cannot remove member after payout")] +#[should_panic(expected = "Cannot remove member after their payout turn")] fn test_remove_member_after_payout_panics() { let env = Env::default(); env.mock_all_auths(); @@ -509,7 +507,7 @@ fn test_remove_member_after_payout_panics() { client.contribute(&member1, &1000); client.contribute(&member2, &1000); - client.payout(&member1); + client.payout(); client.remove_member(&member1); } @@ -571,7 +569,7 @@ fn test_remove_member_adjusts_cycle_count() { client.contribute(&member2, &1000); - client.payout(&member1); + client.payout(); let contract_balance = token_client.balance(&contract_id); assert_eq!(contract_balance, 0); @@ -579,10 +577,7 @@ fn test_remove_member_adjusts_cycle_count() { client.reset_cycle(); env.as_contract(&contract_id, || { - assert!(!env - .storage() - .instance() - .has(&DataKey::CycleMemberCount)); + assert!(!env.storage().instance().has(&DataKey::CycleMemberCount)); }); } @@ -627,10 +622,222 @@ fn test_remove_last_member_clears_cycle_count() { client.remove_member(&member); env.as_contract(&contract_id, || { - assert!(!env - .storage() - .instance() - .has(&DataKey::CycleMemberCount)); + assert!(!env.storage().instance().has(&DataKey::CycleMemberCount)); }); } +#[test] +fn test_deterministic_payout_order() { + let env = Env::default(); + env.mock_all_auths(); + let contract_id = env.register_contract(None, KoloSavingsContract); + let client = KoloSavingsContractClient::new(&env, &contract_id); + + let admin = Address::generate(&env); + let token_admin = Address::generate(&env); + let token = env.register_stellar_asset_contract(token_admin.clone()); + let token_client = token::StellarAssetClient::new(&env, &token); + + client.initialize( + &admin, + &token, + &String::from_str(&env, "Test Group"), + &1000i128, + &GroupType::Rotational, + &None, + &false, + ); + + let member0 = Address::generate(&env); + let member1 = Address::generate(&env); + let member2 = Address::generate(&env); + client.add_member(&member0); + client.add_member(&member1); + client.add_member(&member2); + + token_client.mint(&member0, &10000); + token_client.mint(&member1, &10000); + token_client.mint(&member2, &10000); + + // Payout 1 goes to member0 (index 0 in join order) + client.contribute(&member0, &1000); + client.contribute(&member1, &1000); + client.contribute(&member2, &1000); + assert_eq!(client.get_next_payout_recipient(), member0); + client.payout(); + assert!(client.has_received_payout(&member0)); + assert!(!client.has_received_payout(&member1)); + assert!(!client.has_received_payout(&member2)); + // member0 gets the full pool: 1000 * 3 = 3000 + // balance = 10000 - 1000 (contrib) + 3000 (payout) = 12000 + assert_eq!(token_client.balance(&member0), 12000); + + // After payout, NextPayoutIndex advanced to 1 — member1 is next + assert_eq!(client.get_next_payout_recipient(), member1); + + // Payout 2 goes to member1 (index 1) after reset + re-contribute + client.reset_cycle(); + client.contribute(&member0, &1000); + client.contribute(&member1, &1000); + client.contribute(&member2, &1000); + assert_eq!(client.get_next_payout_recipient(), member1); + client.payout(); + assert!(client.has_received_payout(&member1)); + assert!(!client.has_received_payout(&member2)); + // 10000 - 1000 (round1) - 1000 (round2) + 3000 (payout) = 11000 + assert_eq!(token_client.balance(&member1), 11000); + + // Payout 3 goes to member2 (index 2) + client.reset_cycle(); + client.contribute(&member0, &1000); + client.contribute(&member1, &1000); + client.contribute(&member2, &1000); + assert_eq!(client.get_next_payout_recipient(), member2); + client.payout(); + assert!(client.has_received_payout(&member2)); + // 10000 - 1000*3 (3 rounds) + 3000 (payout) = 10000 + assert_eq!(token_client.balance(&member2), 10000); +} + +#[test] +fn test_queue_enforced_payout_order() { + let env = Env::default(); + env.mock_all_auths(); + let contract_id = env.register_contract(None, KoloSavingsContract); + let client = KoloSavingsContractClient::new(&env, &contract_id); + + let admin = Address::generate(&env); + let token_admin = Address::generate(&env); + let token = env.register_stellar_asset_contract(token_admin.clone()); + let token_client = token::StellarAssetClient::new(&env, &token); + + client.initialize( + &admin, + &token, + &String::from_str(&env, "Test Group"), + &1000i128, + &GroupType::Rotational, + &None, + &false, + ); + + let member0 = Address::generate(&env); + let member1 = Address::generate(&env); + let member2 = Address::generate(&env); + client.add_member(&member0); + client.add_member(&member1); + client.add_member(&member2); + + token_client.mint(&member0, &10000); + token_client.mint(&member1, &10000); + token_client.mint(&member2, &10000); + + // Payout must go to member0 (index 0), admin cannot choose + client.contribute(&member0, &1000); + client.contribute(&member1, &1000); + client.contribute(&member2, &1000); + + assert_eq!(client.get_next_payout_recipient(), member0); + client.payout(); + assert!(client.has_received_payout(&member0)); + assert!(!client.has_received_payout(&member2)); + + // After reset, queue advances to member1 (NextPayoutIndex persists) + client.reset_cycle(); + client.contribute(&member0, &1000); + client.contribute(&member1, &1000); + client.contribute(&member2, &1000); + + assert_eq!(client.get_next_payout_recipient(), member1); + client.payout(); + assert!(client.has_received_payout(&member1)); + assert!(!client.has_received_payout(&member2)); + + // After another reset, finally member2 + client.reset_cycle(); + client.contribute(&member0, &1000); + client.contribute(&member1, &1000); + client.contribute(&member2, &1000); + + assert_eq!(client.get_next_payout_recipient(), member2); + client.payout(); + assert!(client.has_received_payout(&member2)); +} + +#[test] +fn test_cycle_resets_and_starts_again() { + let env = Env::default(); + env.mock_all_auths(); + let contract_id = env.register_contract(None, KoloSavingsContract); + let client = KoloSavingsContractClient::new(&env, &contract_id); + + let admin = Address::generate(&env); + let token_admin = Address::generate(&env); + let token = env.register_stellar_asset_contract(token_admin.clone()); + let token_client = token::StellarAssetClient::new(&env, &token); + + client.initialize( + &admin, + &token, + &String::from_str(&env, "Test Group"), + &1000i128, + &GroupType::Rotational, + &None, + &false, + ); + + let member0 = Address::generate(&env); + let member1 = Address::generate(&env); + let member2 = Address::generate(&env); + client.add_member(&member0); + client.add_member(&member1); + client.add_member(&member2); + + token_client.mint(&member0, &10000); + token_client.mint(&member1, &10000); + token_client.mint(&member2, &10000); + + // --- Full rotation: member0 → member1 → member2 --- + // Round 1: payout to member0 + client.contribute(&member0, &1000); + client.contribute(&member1, &1000); + client.contribute(&member2, &1000); + assert_eq!(client.get_next_payout_recipient(), member0); + client.payout(); + assert!(client.has_received_payout(&member0)); + + // Round 2: payout to member1 + client.reset_cycle(); + client.contribute(&member0, &1000); + client.contribute(&member1, &1000); + client.contribute(&member2, &1000); + assert_eq!(client.get_next_payout_recipient(), member1); + client.payout(); + assert!(client.has_received_payout(&member1)); + + // Round 3: payout to member2 + client.reset_cycle(); + client.contribute(&member0, &1000); + client.contribute(&member1, &1000); + client.contribute(&member2, &1000); + assert_eq!(client.get_next_payout_recipient(), member2); + client.payout(); + assert!(client.has_received_payout(&member2)); + + // --- Full reset: reset_rotation() resets NextPayoutIndex to 0 --- + client.reset_cycle(); + client.reset_rotation(); + + // After full reset, all has_received_payout flags are cleared + assert!(!client.has_received_payout(&member0)); + assert!(!client.has_received_payout(&member1)); + assert!(!client.has_received_payout(&member2)); + + // New rotation starts with member0 again + client.contribute(&member0, &1000); + client.contribute(&member1, &1000); + client.contribute(&member2, &1000); + assert_eq!(client.get_next_payout_recipient(), member0); + client.payout(); + assert!(client.has_received_payout(&member0)); +} From 19b4c3dbc45f7b843450a890fe4385187edb1bf7 Mon Sep 17 00:00:00 2001 From: Josie123-Dev <“josienag21@gmail.com”> Date: Fri, 21 Aug 2026 10:30:00 -0400 Subject: [PATCH 4/4] ci: use wasm32v1-none target for Soroban SDK 27.0.0 The wasm32-unknown-unknown target in Rust 1.82+ enables reference-types and multi-value features that are unsupported by the Soroban Environment. Switch all build targets to wasm32v1-none as recommended by the SDK. --- .github/workflows/rust.yml | 23 ++++++++--------------- 1 file changed, 8 insertions(+), 15 deletions(-) diff --git a/.github/workflows/rust.yml b/.github/workflows/rust.yml index d5dbb09..6c2ec3a 100644 --- a/.github/workflows/rust.yml +++ b/.github/workflows/rust.yml @@ -31,12 +31,7 @@ jobs: uses: dtolnay/rust-toolchain@stable with: components: rustfmt, clippy - targets: wasm32-unknown-unknown - - name: Install Rust and wasm32 target - run: | - rustup toolchain install stable --profile minimal - rustup default stable - rustup target add wasm32v1-none + targets: wasm32v1-none - name: Cache Cargo dependencies uses: Swatinem/rust-cache@v2 @@ -56,13 +51,13 @@ jobs: run: cargo test --verbose - name: Build WebAssembly (release) - run: cargo build --target wasm32-unknown-unknown --release + run: cargo build --target wasm32v1-none --release - name: Upload WASM artifact uses: actions/upload-artifact@v4 with: name: kolo-savings-group-wasm - path: ${{ env.WORKING_DIR }}/target/wasm32-unknown-unknown/release/kolo_savings_group.wasm + path: ${{ env.WORKING_DIR }}/target/wasm32v1-none/release/kolo_savings_group.wasm retention-days: 14 deploy: @@ -82,7 +77,7 @@ jobs: - name: Install Rust toolchain uses: dtolnay/rust-toolchain@stable with: - targets: wasm32-unknown-unknown + targets: wasm32v1-none - name: Cache Cargo dependencies uses: Swatinem/rust-cache@v2 @@ -97,20 +92,18 @@ jobs: - name: Build and optimize WASM run: | RUSTFLAGS="-C target-cpu=mvp" cargo build \ - --target wasm32-unknown-unknown \ + --target wasm32v1-none \ --release cargo install wasm-opt --locked wasm-opt \ - target/wasm32-unknown-unknown/release/kolo_savings_group.wasm \ - -o target/wasm32-unknown-unknown/release/kolo_savings_group.optimized.wasm + target/wasm32v1-none/release/kolo_savings_group.wasm \ + -o target/wasm32v1-none/release/kolo_savings_group.optimized.wasm - name: Deploy contract to testnet env: STELLAR_SOURCE_ACCOUNT: ${{ secrets.STELLAR_DEPLOYER_KEY }} run: | stellar contract deploy \ - --wasm target/wasm32-unknown-unknown/release/kolo_savings_group.optimized.wasm \ + --wasm target/wasm32v1-none/release/kolo_savings_group.optimized.wasm \ --source-account "$STELLAR_SOURCE_ACCOUNT" \ --network testnet - - name: Build contract for wasm (release) - run: cargo build --target wasm32v1-none --release