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 diff --git a/contracts/src/lib.rs b/contracts/src/lib.rs index 7ca444a..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, }; @@ -29,7 +31,7 @@ pub enum DataKey { ContributionAmount, Members, Contributions(Address), - HasReceivedPayout(Address), + NextPayoutIndex, HasContributedThisCycle(Address), CycleMemberCount, User(Address), @@ -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() @@ -106,9 +113,11 @@ 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 +140,18 @@ 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 +177,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 @@ -200,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"); } @@ -216,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 @@ -238,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() @@ -269,10 +284,14 @@ 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) { - let group_type: GroupType = env.storage().instance().get(&DataKey::GroupType).unwrap_or(GroupType::Rotational); + /// 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"); } @@ -282,19 +301,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() @@ -316,19 +334,27 @@ 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(); @@ -365,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"); } @@ -373,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, @@ -392,27 +421,22 @@ 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() { - 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,7 +447,6 @@ impl KoloSavingsContract { ); } - // 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); } @@ -431,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); @@ -452,14 +489,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, + } } } 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)); +}