From f1120fea6da86c8db0c386aa3f7ddb65de85f93c Mon Sep 17 00:00:00 2001 From: samieazubike Date: Sat, 27 Jun 2026 18:56:25 +0100 Subject: [PATCH 1/2] feat(tests): add full multisig flow test suite with scenarios for proposal lifecycle --- .../contracts/group_treasury/src/test.rs | 167 ++++++++++++++++++ 1 file changed, 167 insertions(+) diff --git a/contracts/contracts/group_treasury/src/test.rs b/contracts/contracts/group_treasury/src/test.rs index 743c4c9..e9476ca 100644 --- a/contracts/contracts/group_treasury/src/test.rs +++ b/contracts/contracts/group_treasury/src/test.rs @@ -594,3 +594,170 @@ fn test_vote_without_auth_panics() { client.approve_withdraw(&member, &0); } + +// ── Full multisig flow suite (#127) ──────────────────────────────────────────── +// +// A dedicated suite walking the multisig withdraw lifecycle end to end: +// propose → approve/reject → execute, plus expiry and pending-filtering. +// +// Four scenarios exercise APIs that already exist on `main` +// (`approve_withdraw` / `reject_withdraw`) and run as real, passing tests; they +// create proposals via the in-file `seed_proposal` stand-in until +// `propose_withdraw` (#122) lands. +// +// The remaining six scenarios depend on contract functions that are not yet +// implemented (`propose_withdraw` #122, an execute entrypoint, an expiry +// finalizer, and `get_pending_proposals`). Calling a not-yet-generated client +// method does not compile even inside an `#[ignore]`d test, so those scenarios +// are documented stubs carrying an explicit `#[ignore = "..."]` reason and the +// exact assertions to wire up once their owning work merges. They are written +// against the ledger-sequence (`set_sequence_number` / `ttl_ledgers`) timing +// model that #122 introduces. +mod multisig_flow { + use super::{seed_proposal, voting_setup}; + use crate::storage::ProposalStatus; + use crate::GroupTreasuryContractClient; + use soroban_sdk::testutils::{Address as _, Ledger as _}; + use soroban_sdk::{Address, Env}; + + // ── Implemented behavior: real, passing tests ────────────────────────────── + + /// Scenario 3 — approvals reaching the threshold flip the proposal to `Passed`. + #[test] + fn multisig_approvals_reach_threshold_passes() { + let env = Env::default(); + let (contract_id, token_id, members) = voting_setup(&env, 2, 2); + let client = GroupTreasuryContractClient::new(&env, &contract_id); + let recipient = Address::generate(&env); + seed_proposal(&env, &contract_id, 0, &recipient, &token_id, 1_000, 10_000); + + client.approve_withdraw(&members.get(0).unwrap(), &0); + assert_eq!(client.get_proposal(&0).status, ProposalStatus::Active); + + client.approve_withdraw(&members.get(1).unwrap(), &0); + let passed = client.get_proposal(&0); + assert_eq!(passed.approvals, 2); + assert_eq!(passed.status, ProposalStatus::Passed); + } + + /// Scenario 4 — a member voting twice on the same proposal panics. + #[test] + #[should_panic(expected = "already voted")] + fn multisig_double_vote_panics() { + let env = Env::default(); + let (contract_id, token_id, members) = voting_setup(&env, 2, 2); + let client = GroupTreasuryContractClient::new(&env, &contract_id); + let recipient = Address::generate(&env); + seed_proposal(&env, &contract_id, 0, &recipient, &token_id, 1_000, 10_000); + + let voter = members.get(0).unwrap(); + client.approve_withdraw(&voter, &0); + client.approve_withdraw(&voter, &0); // second vote must panic + } + + /// Scenario 5 — rejections reaching the blocking minority flip the proposal + /// to `Rejected`. threshold 2 of 3 → blocking minority = 3 - 2 + 1 = 2. + #[test] + fn multisig_rejection_blocks_proposal() { + let env = Env::default(); + let (contract_id, token_id, members) = voting_setup(&env, 2, 3); + let client = GroupTreasuryContractClient::new(&env, &contract_id); + let recipient = Address::generate(&env); + seed_proposal(&env, &contract_id, 0, &recipient, &token_id, 1_000, 10_000); + + client.reject_withdraw(&members.get(0).unwrap(), &0); + assert_eq!(client.get_proposal(&0).status, ProposalStatus::Active); + + client.reject_withdraw(&members.get(1).unwrap(), &0); + let rejected = client.get_proposal(&0); + assert_eq!(rejected.rejections, 2); + assert_eq!(rejected.status, ProposalStatus::Rejected); + } + + /// Scenario 9 — approving after the proposal has expired panics. + /// + /// The live contract checks expiry against `ledger().timestamp()`, so this + /// active test advances time with `set_timestamp`. Once #122 migrates expiry + /// to ledger sequence, switch this to `env.ledger().set_sequence_number(...)`. + #[test] + #[should_panic(expected = "proposal expired")] + fn multisig_approve_after_expiry_panics() { + let env = Env::default(); + let (contract_id, token_id, members) = voting_setup(&env, 2, 2); + let client = GroupTreasuryContractClient::new(&env, &contract_id); + let recipient = Address::generate(&env); + seed_proposal(&env, &contract_id, 0, &recipient, &token_id, 1_000, 100); + + env.ledger().set_timestamp(200); // past expires_at + client.approve_withdraw(&members.get(0).unwrap(), &0); + } + + // ── Pending contract work: documented, ignored stubs ─────────────────────── + // + // Each stub compiles (no calls to not-yet-generated client methods) and + // carries the precise assertions to enable once its dependency merges. + + /// Scenario 1 — a member can submit a withdraw proposal. + #[test] + #[ignore = "blocked on #122: propose_withdraw not yet implemented"] + fn multisig_propose_by_member() { + // TODO(#122): with + // propose_withdraw(env, proposer, to, token, amount, ttl_ledgers) -> u32 + // assert: + // - the returned id equals the stored proposal's id + // - the proposer is auto-recorded as the first approval (approvals == 1) + // - status == Active and + // expires_at == env.ledger().sequence() + ttl_ledgers + // - a ProposalCreatedEvent is emitted + // Drive time with env.ledger().set_sequence_number(...). + } + + /// Scenario 2 — a non-member submitting a proposal panics. + #[test] + #[ignore = "blocked on #122: propose_withdraw not yet implemented"] + fn multisig_propose_by_non_member_panics() { + // TODO(#122): calling propose_withdraw with a non-member proposer must + // panic ("not a member"); insufficient treasury balance must panic with + // "insufficient funds". + } + + /// Scenario 6 — executing an approved (`Passed`) proposal transfers funds and + /// marks it `Executed`. + #[test] + #[ignore = "blocked: group_treasury execute entrypoint not yet implemented (no issue identified)"] + fn multisig_execute_on_approved() { + // TODO: once an execute fn exists, drive a proposal to `Passed` via + // approvals, deposit funds, execute, then assert: + // - recipient balance increased by amount + // - treasury balance decreased by amount + // - proposal status == Executed + // - a WithdrawEvent is emitted + } + + /// Scenario 7 — executing a still-pending (`Active`) proposal panics. + #[test] + #[ignore = "blocked: group_treasury execute entrypoint not yet implemented (no issue identified)"] + fn multisig_execute_on_pending_panics() { + // TODO: executing a proposal that has not reached threshold must panic + // (expected message TBD by the execute implementation, e.g. "not approved"). + } + + /// Scenario 8 — an expired proposal can be finalized to its terminal state. + #[test] + #[ignore = "blocked: expiry finalizer not yet implemented (no issue identified)"] + fn multisig_finalize_expired() { + // TODO: with ledger-sequence expiry, seed/propose a proposal, advance past + // expiry via env.ledger().set_sequence_number(expires_at + 1), then call + // the finalizer and assert the proposal settles (e.g. Rejected when it + // never reached threshold) and cannot be voted on afterwards. + } + + /// Scenario 10 — `get_pending_proposals` returns only still-`Active` proposals. + #[test] + #[ignore = "blocked: get_pending_proposals not yet implemented (no issue identified)"] + fn multisig_get_pending_proposals_filtering() { + // TODO: create several proposals, drive some to Passed/Rejected/Executed, + // then assert get_pending_proposals() returns exactly the Active ones and + // excludes terminal-state proposals. + } +} From 1aaf5e871ff886882662820eaa0e9991c6a9e33e Mon Sep 17 00:00:00 2001 From: codebestia Date: Mon, 29 Jun 2026 12:46:04 +0100 Subject: [PATCH 2/2] fix: fix missing delimeter --- contracts/contracts/group_treasury/src/lib.rs | 10 ++- .../contracts/group_treasury/src/test.rs | 1 + contracts/contracts/proposals/src/lib.rs | 17 ++--- contracts/contracts/proposals/src/storage.rs | 2 - contracts/contracts/proposals/src/test.rs | 68 ++++++++----------- 5 files changed, 39 insertions(+), 59 deletions(-) diff --git a/contracts/contracts/group_treasury/src/lib.rs b/contracts/contracts/group_treasury/src/lib.rs index 5a40120..aded7c1 100644 --- a/contracts/contracts/group_treasury/src/lib.rs +++ b/contracts/contracts/group_treasury/src/lib.rs @@ -398,11 +398,7 @@ impl GroupTreasuryContract { .unwrap_or(0); let mut proposals: Vec = Vec::new(&env); for id in 1..=count { - if let Some(proposal) = env - .storage() - .instance() - .get(&DataKey::Proposal(id)) - { + if let Some(proposal) = env.storage().instance().get(&DataKey::Proposal(id)) { proposals.push_back(proposal); } } @@ -450,7 +446,9 @@ impl GroupTreasuryContract { if proposal.status != ProposalStatus::Active { panic!("proposal is not pending"); } - if proposal.status == ProposalStatus::Expired || env.ledger().timestamp() >= proposal.expires_at { + if proposal.status == ProposalStatus::Expired + || env.ledger().timestamp() >= proposal.expires_at + { panic!("proposal expired"); } if env diff --git a/contracts/contracts/group_treasury/src/test.rs b/contracts/contracts/group_treasury/src/test.rs index b77854b..3d5bd29 100644 --- a/contracts/contracts/group_treasury/src/test.rs +++ b/contracts/contracts/group_treasury/src/test.rs @@ -760,6 +760,7 @@ mod multisig_flow { // then assert get_pending_proposals() returns exactly the Active ones and // excludes terminal-state proposals. } +} // ── propose_withdraw Tests (#122) ───────────────────────────────────────────── #[test] diff --git a/contracts/contracts/proposals/src/lib.rs b/contracts/contracts/proposals/src/lib.rs index 872b6b2..b449d20 100644 --- a/contracts/contracts/proposals/src/lib.rs +++ b/contracts/contracts/proposals/src/lib.rs @@ -24,8 +24,8 @@ mod treasury_interface; use soroban_sdk::{contract, contractimpl, symbol_short, Address, Env, String, Symbol}; pub use storage::{ - DataKey, Proposal, ProposalCreatedEvent, ProposalExecutedEvent, ProposalFinalizedEvent, - ProposalStatus, VoteCastEvent, ProposalExpiredEvent, + DataKey, Proposal, ProposalCreatedEvent, ProposalExecutedEvent, ProposalExpiredEvent, + ProposalFinalizedEvent, ProposalStatus, VoteCastEvent, }; // ── Contract ───────────────────────────────────────────────────────────────── @@ -90,7 +90,6 @@ impl ProposalsContract { amount, }; - env.storage() .instance() .set(&DataKey::Proposal(id), &proposal); @@ -111,7 +110,6 @@ impl ProposalsContract { }, ); - id } @@ -256,12 +254,9 @@ impl ProposalsContract { panic!("proposal not approved"); } - // Verify caller is a treasury member. - let treasury_client = crate::treasury_interface::TreasuryClient::new( - &env, - &proposal.treasury, - ); + let treasury_client = + crate::treasury_interface::TreasuryClient::new(&env, &proposal.treasury); if !treasury_client.is_member(&caller.clone()) { panic!("caller is not a treasury member"); @@ -280,7 +275,6 @@ impl ProposalsContract { &proposal.amount, ); - // Update proposal status. proposal.status = ProposalStatus::Executed; env.storage() @@ -295,11 +289,8 @@ impl ProposalsContract { executor: caller, }, ); - } - - pub fn get_proposal(env: Env, proposal_id: u64) -> Proposal { Self::load_proposal(&env, proposal_id) } diff --git a/contracts/contracts/proposals/src/storage.rs b/contracts/contracts/proposals/src/storage.rs index f0ca25b..94d0ba3 100644 --- a/contracts/contracts/proposals/src/storage.rs +++ b/contracts/contracts/proposals/src/storage.rs @@ -38,7 +38,6 @@ pub struct Proposal { pub amount: i128, } - // ── Events ─────────────────────────────────────────────────────────────────── #[contracttype] @@ -54,7 +53,6 @@ pub struct ProposalCreatedEvent { pub amount: i128, } - #[contracttype] #[derive(Clone)] pub struct VoteCastEvent { diff --git a/contracts/contracts/proposals/src/test.rs b/contracts/contracts/proposals/src/test.rs index 26cdb0c..e29b9e9 100644 --- a/contracts/contracts/proposals/src/test.rs +++ b/contracts/contracts/proposals/src/test.rs @@ -10,7 +10,6 @@ use soroban_sdk::testutils::Address as _; use soroban_sdk::testutils::Ledger; use soroban_sdk::{Env, String}; - mod mock_token { use soroban_sdk::{contract, contractimpl, contracttype, Address, Env}; @@ -44,9 +43,7 @@ mod mock_token { .set(&from_key, &(from_bal - amount)); let to_bal: i128 = env.storage().persistent().get(&to_key).unwrap_or(0); - env.storage() - .persistent() - .set(&to_key, &(to_bal + amount)); + env.storage().persistent().set(&to_key, &(to_bal + amount)); } pub fn balance(env: Env, id: Address) -> i128 { @@ -61,10 +58,10 @@ mod mock_token { use mock_token::MockTokenClient; fn advance_time(env: &Env, seconds: u64) { - env.ledger().set_timestamp(env.ledger().timestamp() + seconds); + env.ledger() + .set_timestamp(env.ledger().timestamp() + seconds); } - fn setup( env: &Env, ) -> ( @@ -73,8 +70,7 @@ fn setup( Address, // alice Address, // bob Address, // carol -group_treasury::GroupTreasuryContractClient<'static>, - + group_treasury::GroupTreasuryContractClient<'static>, Address, // treasury_admin Address, // treasury_member Address, // token_id @@ -101,18 +97,12 @@ group_treasury::GroupTreasuryContractClient<'static>, token.mint(&treasury_member, &1_000_000); let treasury_addr = env.register(group_treasury::GroupTreasuryContract, ()); - let treasury = - group_treasury::GroupTreasuryContractClient::new(env, &treasury_addr); + let treasury = group_treasury::GroupTreasuryContractClient::new(env, &treasury_addr); treasury.initialize(&treasury_admin, &token_id); treasury.add_member(&treasury_member); // Deposit into treasury so `execute_withdraw` has something to withdraw. - token.transfer( - env.clone(), - &treasury_member, - &treasury_addr, - &0, - ); + token.transfer(env.clone(), &treasury_member, &treasury_addr, &0); // easier: call deposit, which also calls TokenClient::transfer from `from` to treasury treasury.deposit(&treasury_member, &token_id, &500); @@ -161,14 +151,10 @@ fn create_then_vote_then_pass_then_execute_happy_path() { setup(&env); let id = create_proposal_in( - &env, - &client, - &alice, - 1_000, + &env, &client, &alice, 1_000, &_m, // dummy treasury address for happy path; execute_withdraw not used here &_m, // dummy token - &alice, - 1, + &alice, 1, ); client.vote(&alice, &id, &true); @@ -203,7 +189,8 @@ fn finalize_with_more_no_votes_rejects() { #[test] fn finalize_with_a_tie_rejects() { let env = Env::default(); - let (client, _proposals_admin, alice, bob, _carol, _treasury, _tadmin, m, token_id) = setup(&env); + let (client, _proposals_admin, alice, bob, _carol, _treasury, _tadmin, m, token_id) = + setup(&env); let id = create_proposal_in(&env, &client, &alice, 500, &m, &token_id, &alice, 1); client.vote(&alice, &id, &true); @@ -216,7 +203,8 @@ fn finalize_with_a_tie_rejects() { #[test] fn finalize_with_zero_votes_rejects() { let env = Env::default(); - let (client, _proposals_admin, alice, _bob, _carol, _treasury, _tadmin, m, token_id) = setup(&env); + let (client, _proposals_admin, alice, _bob, _carol, _treasury, _tadmin, m, token_id) = + setup(&env); let id = create_proposal_in(&env, &client, &alice, 500, &m, &token_id, &alice, 1); advance_time(&env, 501); @@ -228,7 +216,8 @@ fn finalize_with_zero_votes_rejects() { #[should_panic(expected = "cannot finalize before expiry")] fn finalize_before_expiry_panics() { let env = Env::default(); - let (client, _proposals_admin, alice, _bob, _carol, _treasury, _tadmin, m, token_id) = setup(&env); + let (client, _proposals_admin, alice, _bob, _carol, _treasury, _tadmin, m, token_id) = + setup(&env); let id = create_proposal_in(&env, &client, &alice, 1_000, &m, &token_id, &alice, 1); client.finalize_proposal(&id); @@ -238,7 +227,8 @@ fn finalize_before_expiry_panics() { #[should_panic(expected = "proposal already finalized")] fn finalize_twice_panics() { let env = Env::default(); - let (client, _proposals_admin, alice, _bob, _carol, _treasury, _tadmin, m, token_id) = setup(&env); + let (client, _proposals_admin, alice, _bob, _carol, _treasury, _tadmin, m, token_id) = + setup(&env); let id = create_proposal_in(&env, &client, &alice, 500, &m, &token_id, &alice, 1); @@ -251,7 +241,8 @@ fn finalize_twice_panics() { #[should_panic(expected = "proposal is not in Passed state")] fn execute_when_rejected_panics() { let env = Env::default(); - let (client, _proposals_admin, alice, bob, carol, _treasury, _tadmin, m, token_id) = setup(&env); + let (client, _proposals_admin, alice, bob, carol, _treasury, _tadmin, m, token_id) = + setup(&env); let id = create_proposal_in(&env, &client, &alice, 500, &m, &token_id, &alice, 1); client.vote(&alice, &id, &false); @@ -267,7 +258,8 @@ fn execute_when_rejected_panics() { #[should_panic(expected = "proposal is not in Passed state")] fn execute_when_still_active_panics() { let env = Env::default(); - let (client, _proposals_admin, alice, _bob, _carol, _treasury, _tadmin, m, token_id) = setup(&env); + let (client, _proposals_admin, alice, _bob, _carol, _treasury, _tadmin, m, token_id) = + setup(&env); let id = create_proposal_in(&env, &client, &alice, 1_000, &m, &token_id, &alice, 1); client.execute_proposal(&alice, &id); @@ -277,7 +269,8 @@ fn execute_when_still_active_panics() { #[should_panic(expected = "voting window has closed")] fn vote_after_expiry_panics() { let env = Env::default(); - let (client, _proposals_admin, alice, bob, _carol, _treasury, _tadmin, m, token_id) = setup(&env); + let (client, _proposals_admin, alice, bob, _carol, _treasury, _tadmin, m, token_id) = + setup(&env); let id = create_proposal_in(&env, &client, &alice, 500, &m, &token_id, &alice, 1); advance_time(&env, 600); @@ -288,7 +281,8 @@ fn vote_after_expiry_panics() { #[should_panic(expected = "voter has already voted")] fn double_vote_panics() { let env = Env::default(); - let (client, _proposals_admin, alice, _bob, _carol, _treasury, _tadmin, m, token_id) = setup(&env); + let (client, _proposals_admin, alice, _bob, _carol, _treasury, _tadmin, m, token_id) = + setup(&env); let id = create_proposal_in(&env, &client, &alice, 500, &m, &token_id, &alice, 1); client.vote(&alice, &id, &true); @@ -299,7 +293,8 @@ fn double_vote_panics() { #[should_panic(expected = "expires_at must be in the future")] fn create_with_past_expiry_panics() { let env = Env::default(); - let (client, _proposals_admin, alice, _bob, _carol, _treasury, _tadmin, m, token_id) = setup(&env); + let (client, _proposals_admin, alice, _bob, _carol, _treasury, _tadmin, m, token_id) = + setup(&env); let desc = String::from_str(&env, "x"); client.create_proposal( @@ -330,7 +325,7 @@ fn finalize_expired_when_passed_panics() { let (client, _padmin, alice, _bob, _carol, _treasury, _tadmin, m, token_id) = setup(&env); let id = create_proposal_in(&env, &client, &alice, 500, &m, &token_id, &alice, 1); - + advance_time(&env, 501); client.finalize_proposal(&id); // becomes Passed client.finalize_expired_proposal(&id); @@ -342,10 +337,10 @@ fn finalize_expired_success() { let (client, _padmin, alice, _bob, _carol, _treasury, _tadmin, m, token_id) = setup(&env); let id = create_proposal_in(&env, &client, &alice, 500, &m, &token_id, &alice, 1); - + advance_time(&env, 501); client.finalize_expired_proposal(&id); - + let proposal = client.get_proposal(&id); assert_eq!(proposal.status, ProposalStatus::Expired); } @@ -406,7 +401,6 @@ fn execute_withdraw_already_executed_panics() { client.execute_withdraw(&treasury_member, &id); client.execute_withdraw(&treasury_member, &id); - } #[test] @@ -452,8 +446,7 @@ fn execute_withdraw_reduces_balance() { #[should_panic(expected = "caller is not a treasury member")] fn execute_withdraw_non_member_panics() { let env = Env::default(); - let (client, _padmin, alice, _bob, _carol, treasury, _tadmin, _member, token_id) = - setup(&env); + let (client, _padmin, alice, _bob, _carol, treasury, _tadmin, _member, token_id) = setup(&env); let treasury_addr = treasury.address(); let to = alice.clone(); @@ -475,4 +468,3 @@ fn execute_withdraw_non_member_panics() { // alice is not a treasury member client.execute_withdraw(&alice, &id); } -