From 066f11085124141201d8c98f6a13f58571cd2054 Mon Sep 17 00:00:00 2001 From: samieazubike Date: Thu, 25 Jun 2026 13:05:13 +0100 Subject: [PATCH 1/2] feat: add approval threshold to GroupTreasuryContract initialization and implement related tests --- contracts/contracts/group_treasury/src/lib.rs | 23 ++++++++++- .../contracts/group_treasury/src/storage.rs | 26 +++++++++++++ .../contracts/group_treasury/src/test.rs | 39 ++++++++++++++++--- 3 files changed, 81 insertions(+), 7 deletions(-) diff --git a/contracts/contracts/group_treasury/src/lib.rs b/contracts/contracts/group_treasury/src/lib.rs index 278b60c..eb84738 100644 --- a/contracts/contracts/group_treasury/src/lib.rs +++ b/contracts/contracts/group_treasury/src/lib.rs @@ -23,18 +23,37 @@ pub struct GroupTreasuryContract; #[contractimpl] impl GroupTreasuryContract { - /// One-time initialisation. Sets the admin and sets up the balances map and members set. - pub fn initialize(env: Env, admin: Address, _token: Address) { + /// One-time initialisation. Sets the admin, the approval `threshold`, and sets up the + /// balances map and members set. `threshold` is the number of approvals required to + /// execute a withdraw proposal and must be at least 1. + pub fn initialize(env: Env, admin: Address, _token: Address, threshold: u32) { if env.storage().instance().has(&DataKey::Admin) { panic!("already initialized"); } + if threshold == 0 { + panic!("threshold must be at least 1"); + } env.storage().instance().set(&DataKey::Admin, &admin); + env.storage() + .instance() + .set(&DataKey::Threshold, &threshold); + env.storage() + .instance() + .set(&DataKey::ProposalCount, &0u32); let balances: Map = Map::new(&env); env.storage().instance().set(&DataKey::Balances, &balances); let members: Vec
= Vec::new(&env); env.storage().instance().set(&DataKey::Members, &members); } + /// Returns the configured approval threshold. + pub fn get_threshold(env: Env) -> u32 { + env.storage() + .instance() + .get(&DataKey::Threshold) + .expect("not initialized") + } + /// Admin-only: Add a new member to the treasury. pub fn add_member(env: Env, member: Address) { let admin = require_admin(&env); diff --git a/contracts/contracts/group_treasury/src/storage.rs b/contracts/contracts/group_treasury/src/storage.rs index bdfc58c..e51a794 100644 --- a/contracts/contracts/group_treasury/src/storage.rs +++ b/contracts/contracts/group_treasury/src/storage.rs @@ -5,6 +5,32 @@ pub enum DataKey { Admin, Balances, Members, + Threshold, // u32: approvals required to execute a withdraw proposal + ProposalCount, // u32: total proposals created (also next id source) + Proposal(u32), // WithdrawProposal by id +} + +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum ProposalStatus { + Active, + Passed, + Rejected, + Executed, +} + +#[contracttype] +#[derive(Clone)] +pub struct WithdrawProposal { + pub id: u32, + pub proposer: Address, + pub to: Address, + pub token: Address, + pub amount: i128, + pub approvals: u32, + pub rejections: u32, + pub status: ProposalStatus, + pub expires_at: u64, } #[contracttype] diff --git a/contracts/contracts/group_treasury/src/test.rs b/contracts/contracts/group_treasury/src/test.rs index 9da316f..7817cd5 100644 --- a/contracts/contracts/group_treasury/src/test.rs +++ b/contracts/contracts/group_treasury/src/test.rs @@ -59,7 +59,7 @@ fn setup(env: &Env) -> (Address, Address, Address, Address) { let contract_id = env.register(GroupTreasuryContract, ()); let client = GroupTreasuryContractClient::new(env, &contract_id); - client.initialize(&admin, &token_id); + client.initialize(&admin, &token_id, &1); (contract_id, token_id, admin, member) } @@ -79,7 +79,7 @@ fn test_double_initialize_panics() { let (contract_id, token_id, _admin, _member) = setup(&env); let client = GroupTreasuryContractClient::new(&env, &contract_id); let other = Address::generate(&env); - client.initialize(&other, &token_id); + client.initialize(&other, &token_id, &1); } #[test] @@ -160,7 +160,7 @@ fn test_non_admin_cannot_withdraw() { let contract_id = env.register(GroupTreasuryContract, ()); let client = GroupTreasuryContractClient::new(&env, &contract_id); - client.initialize(&admin, &token_id); + client.initialize(&admin, &token_id, &1); let recipient = Address::generate(&env); // admin.require_auth() inside withdraw will fail — no auth context set up. @@ -197,7 +197,7 @@ fn test_multi_token_deposits_tracked_separately() { let contract_id = env.register(GroupTreasuryContract, ()); let client = GroupTreasuryContractClient::new(&env, &contract_id); - client.initialize(&admin, &xlm_id); // initialize with XLM for compatibility + client.initialize(&admin, &xlm_id, &1); // initialize with XLM for compatibility // Deposit XLM and USDC client.deposit(&member, &xlm_id, &40_000); @@ -262,7 +262,7 @@ fn test_non_admin_cannot_add_member() { let token_id = env.register(mock_token::MockToken, ()); let contract_id = env.register(GroupTreasuryContract, ()); let client = GroupTreasuryContractClient::new(&env, &contract_id); - client.initialize(&admin, &token_id); + client.initialize(&admin, &token_id, &1); // non_admin tries to add member - should fail due to auth client.add_member(&member); @@ -335,3 +335,32 @@ fn test_initialize_creates_empty_members_list() { let members = client.get_members(); assert_eq!(members.len(), 0); } + +// ── Threshold Tests ─────────────────────────────────────────────────────────── + +#[test] +fn test_get_threshold_returns_configured_value() { + let env = Env::default(); + + let admin = Address::generate(&env); + let token_id = env.register(mock_token::MockToken, ()); + + let contract_id = env.register(GroupTreasuryContract, ()); + let client = GroupTreasuryContractClient::new(&env, &contract_id); + client.initialize(&admin, &token_id, &3); + + assert_eq!(client.get_threshold(), 3); +} + +#[test] +#[should_panic(expected = "threshold must be at least 1")] +fn test_initialize_zero_threshold_panics() { + let env = Env::default(); + + let admin = Address::generate(&env); + let token_id = env.register(mock_token::MockToken, ()); + + let contract_id = env.register(GroupTreasuryContract, ()); + let client = GroupTreasuryContractClient::new(&env, &contract_id); + client.initialize(&admin, &token_id, &0); +} From 8e726db60d330c119e8b2073ef4d7dfc5144b438 Mon Sep 17 00:00:00 2001 From: samieazubike Date: Thu, 25 Jun 2026 15:34:17 +0100 Subject: [PATCH 2/2] feat: implement voting mechanism for withdraw proposals with approval and rejection events --- contracts/contracts/group_treasury/src/lib.rs | 144 ++++++++++- .../contracts/group_treasury/src/storage.rs | 30 ++- .../contracts/group_treasury/src/test.rs | 232 +++++++++++++++++- 3 files changed, 398 insertions(+), 8 deletions(-) diff --git a/contracts/contracts/group_treasury/src/lib.rs b/contracts/contracts/group_treasury/src/lib.rs index eb84738..402b053 100644 --- a/contracts/contracts/group_treasury/src/lib.rs +++ b/contracts/contracts/group_treasury/src/lib.rs @@ -5,7 +5,10 @@ mod test; mod token_interface; use soroban_sdk::{contract, contractimpl, Address, Env, Map, Symbol, Vec}; -use storage::{DataKey, DepositEvent, MemberAddedEvent, MemberRemovedEvent, WithdrawEvent}; +use storage::{ + DataKey, DepositEvent, MemberAddedEvent, MemberRemovedEvent, ProposalApprovedEvent, + ProposalRejectedEvent, ProposalStatus, WithdrawEvent, WithdrawProposal, WithdrawVoteCastEvent, +}; use token_interface::TokenClient; fn require_admin(env: &Env) -> Address { @@ -37,9 +40,7 @@ impl GroupTreasuryContract { env.storage() .instance() .set(&DataKey::Threshold, &threshold); - env.storage() - .instance() - .set(&DataKey::ProposalCount, &0u32); + env.storage().instance().set(&DataKey::ProposalCount, &0u32); let balances: Map = Map::new(&env); env.storage().instance().set(&DataKey::Balances, &balances); let members: Vec
= Vec::new(&env); @@ -209,4 +210,139 @@ impl GroupTreasuryContract { balances.get(token).unwrap_or(0) } + + /// Member-only: approve a pending withdraw proposal. Each member may vote at + /// most once per proposal. When the running approval count reaches the + /// configured `threshold` the proposal transitions to `Passed` (approved) + /// and a `ProposalApprovedEvent` is emitted. + pub fn approve_withdraw(env: Env, approver: Address, proposal_id: u32) { + let mut proposal = Self::require_votable(&env, &approver, proposal_id); + + env.storage() + .instance() + .set(&DataKey::Vote(proposal_id, approver.clone()), &true); + + proposal.approvals += 1; + + let threshold: u32 = env + .storage() + .instance() + .get(&DataKey::Threshold) + .expect("not initialized"); + + if proposal.approvals >= threshold { + proposal.status = ProposalStatus::Passed; + env.events().publish( + (Symbol::new(&env, "proposal_approved"),), + ProposalApprovedEvent { + id: proposal_id, + approvals: proposal.approvals, + threshold, + }, + ); + } + + env.storage() + .instance() + .set(&DataKey::Proposal(proposal_id), &proposal); + + env.events().publish( + (Symbol::new(&env, "withdraw_vote"),), + WithdrawVoteCastEvent { + id: proposal_id, + voter: approver, + approve: true, + }, + ); + } + + /// Member-only: reject a pending withdraw proposal. Each member may vote at + /// most once per proposal. When the rejection count reaches the blocking + /// minority — the point at which the remaining members can no longer reach + /// `threshold` approvals — the proposal transitions to `Rejected` and a + /// `ProposalRejectedEvent` is emitted. + pub fn reject_withdraw(env: Env, rejecter: Address, proposal_id: u32) { + let mut proposal = Self::require_votable(&env, &rejecter, proposal_id); + + env.storage() + .instance() + .set(&DataKey::Vote(proposal_id, rejecter.clone()), &false); + + proposal.rejections += 1; + + let threshold: u32 = env + .storage() + .instance() + .get(&DataKey::Threshold) + .expect("not initialized"); + let member_count = Self::get_members(env.clone()).len(); + // Approval becomes impossible once fewer than `threshold` members remain + // un-rejected, i.e. once rejections > member_count - threshold. + let blocking_minority = member_count.saturating_sub(threshold) + 1; + + if proposal.rejections >= blocking_minority { + proposal.status = ProposalStatus::Rejected; + env.events().publish( + (Symbol::new(&env, "proposal_rejected"),), + ProposalRejectedEvent { + id: proposal_id, + rejections: proposal.rejections, + }, + ); + } + + env.storage() + .instance() + .set(&DataKey::Proposal(proposal_id), &proposal); + + env.events().publish( + (Symbol::new(&env, "withdraw_vote"),), + WithdrawVoteCastEvent { + id: proposal_id, + voter: rejecter, + approve: false, + }, + ); + } + + /// Returns the withdraw proposal with the given id. Panics if it does not exist. + pub fn get_proposal(env: Env, proposal_id: u32) -> WithdrawProposal { + env.storage() + .instance() + .get(&DataKey::Proposal(proposal_id)) + .expect("proposal not found") + } + + /// Shared validation for voting: authenticates the voter, confirms + /// membership, loads the proposal, and ensures it is pending, not expired, + /// and not already voted on by this address. Returns the loaded proposal. + fn require_votable(env: &Env, voter: &Address, proposal_id: u32) -> WithdrawProposal { + voter.require_auth(); + + if !Self::is_member(env.clone(), voter.clone()) { + panic!("not a member"); + } + + let proposal: WithdrawProposal = env + .storage() + .instance() + .get(&DataKey::Proposal(proposal_id)) + .expect("proposal not found"); + + if proposal.status != ProposalStatus::Active { + panic!("proposal is not pending"); + } + if env.ledger().timestamp() >= proposal.expires_at { + panic!("proposal expired"); + } + if env + .storage() + .instance() + .has(&DataKey::Vote(proposal_id, voter.clone())) + { + panic!("already voted"); + } + + proposal + } } diff --git a/contracts/contracts/group_treasury/src/storage.rs b/contracts/contracts/group_treasury/src/storage.rs index e51a794..671fb7c 100644 --- a/contracts/contracts/group_treasury/src/storage.rs +++ b/contracts/contracts/group_treasury/src/storage.rs @@ -5,9 +5,10 @@ pub enum DataKey { Admin, Balances, Members, - Threshold, // u32: approvals required to execute a withdraw proposal - ProposalCount, // u32: total proposals created (also next id source) - Proposal(u32), // WithdrawProposal by id + Threshold, // u32: approvals required to execute a withdraw proposal + ProposalCount, // u32: total proposals created (also next id source) + Proposal(u32), // WithdrawProposal by id + Vote(u32, Address), // (proposal_id, voter) -> bool (true = approve, false = reject) } #[contracttype] @@ -56,3 +57,26 @@ pub struct MemberRemovedEvent { pub member: Address, pub removed_by: Address, } + +/// Emitted whenever a member casts a vote on a withdraw proposal. +#[contracttype] +pub struct WithdrawVoteCastEvent { + pub id: u32, + pub voter: Address, + pub approve: bool, +} + +/// Emitted when a proposal's approvals reach the configured threshold. +#[contracttype] +pub struct ProposalApprovedEvent { + pub id: u32, + pub approvals: u32, + pub threshold: u32, +} + +/// Emitted when a proposal's rejections reach the blocking minority. +#[contracttype] +pub struct ProposalRejectedEvent { + pub id: u32, + pub rejections: u32, +} diff --git a/contracts/contracts/group_treasury/src/test.rs b/contracts/contracts/group_treasury/src/test.rs index 7817cd5..743c4c9 100644 --- a/contracts/contracts/group_treasury/src/test.rs +++ b/contracts/contracts/group_treasury/src/test.rs @@ -1,7 +1,11 @@ #![cfg(test)] use super::*; -use soroban_sdk::{testutils::Address as _, Address, Env}; +use crate::storage::{DataKey, ProposalStatus, WithdrawProposal}; +use soroban_sdk::{ + testutils::{Address as _, Ledger as _}, + Address, Env, +}; // ── Minimal mock token contract ─────────────────────────────────────────────── @@ -364,3 +368,229 @@ fn test_initialize_zero_threshold_panics() { let client = GroupTreasuryContractClient::new(&env, &contract_id); client.initialize(&admin, &token_id, &0); } + +// ── Voting Tests (approve_withdraw / reject_withdraw) ────────────────────────── + +/// Registers a treasury initialised with `threshold`, mocks all auths, and adds +/// `num_members` members. Returns (contract_id, token_id, members). +fn voting_setup( + env: &Env, + threshold: u32, + num_members: u32, +) -> (Address, Address, soroban_sdk::Vec
) { + env.mock_all_auths(); + + let admin = Address::generate(env); + let token_id = env.register(mock_token::MockToken, ()); + let contract_id = env.register(GroupTreasuryContract, ()); + let client = GroupTreasuryContractClient::new(env, &contract_id); + client.initialize(&admin, &token_id, &threshold); + + let mut members = soroban_sdk::Vec::new(env); + for _ in 0..num_members { + let member = Address::generate(env); + client.add_member(&member); + members.push_back(member); + } + + (contract_id, token_id, members) +} + +/// Writes a pending `WithdrawProposal` straight into contract storage. Stands in +/// for `propose_withdraw` (#122), which is not implemented yet. +fn seed_proposal( + env: &Env, + contract_id: &Address, + id: u32, + to: &Address, + token: &Address, + amount: i128, + expires_at: u64, +) { + env.as_contract(contract_id, || { + let proposal = WithdrawProposal { + id, + proposer: to.clone(), + to: to.clone(), + token: token.clone(), + amount, + approvals: 0, + rejections: 0, + status: ProposalStatus::Active, + expires_at, + }; + env.storage() + .instance() + .set(&DataKey::Proposal(id), &proposal); + }); +} + +#[test] +fn test_approve_reaches_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); + let after_first = client.get_proposal(&0); + assert_eq!(after_first.approvals, 1); + assert_eq!(after_first.status, ProposalStatus::Active); + + client.approve_withdraw(&members.get(1).unwrap(), &0); + let after_second = client.get_proposal(&0); + assert_eq!(after_second.approvals, 2); + assert_eq!(after_second.status, ProposalStatus::Passed); +} + +#[test] +fn test_single_approval_below_threshold_stays_active() { + 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); + + let proposal = client.get_proposal(&0); + assert_eq!(proposal.approvals, 1); + assert_eq!(proposal.status, ProposalStatus::Active); +} + +#[test] +#[should_panic(expected = "already voted")] +fn test_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 +} + +#[test] +#[should_panic(expected = "already voted")] +fn test_approve_then_reject_same_member_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.reject_withdraw(&voter, &0); // switching vote must panic +} + +#[test] +#[should_panic(expected = "not a member")] +fn test_non_member_approve_panics() { + let env = Env::default(); + let (contract_id, token_id, _members) = voting_setup(&env, 1, 1); + 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 outsider = Address::generate(&env); + client.approve_withdraw(&outsider, &0); +} + +#[test] +#[should_panic(expected = "not a member")] +fn test_non_member_reject_panics() { + let env = Env::default(); + let (contract_id, token_id, _members) = voting_setup(&env, 1, 1); + 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 outsider = Address::generate(&env); + client.reject_withdraw(&outsider, &0); +} + +#[test] +#[should_panic(expected = "proposal is not pending")] +fn test_vote_on_non_pending_panics() { + let env = Env::default(); + // threshold 1: the first approval flips the proposal to Passed. + let (contract_id, token_id, members) = voting_setup(&env, 1, 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::Passed); + + // A different member voting on the now-approved proposal must panic. + client.approve_withdraw(&members.get(1).unwrap(), &0); +} + +#[test] +#[should_panic(expected = "proposal expired")] +fn test_vote_on_expired_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); +} + +#[test] +fn test_reject_blocking_minority_rejects() { + let env = Env::default(); + // threshold 2 of 3 members → blocking minority = 3 - 2 + 1 = 2 rejections. + 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); + let after_first = client.get_proposal(&0); + assert_eq!(after_first.rejections, 1); + assert_eq!(after_first.status, ProposalStatus::Active); + + client.reject_withdraw(&members.get(1).unwrap(), &0); + let after_second = client.get_proposal(&0); + assert_eq!(after_second.rejections, 2); + assert_eq!(after_second.status, ProposalStatus::Rejected); +} + +#[test] +#[should_panic(expected = "proposal not found")] +fn test_approve_unknown_proposal_panics() { + let env = Env::default(); + let (contract_id, _token_id, members) = voting_setup(&env, 1, 1); + let client = GroupTreasuryContractClient::new(&env, &contract_id); + + // No proposal seeded; member votes on a non-existent id. + client.approve_withdraw(&members.get(0).unwrap(), &0); +} + +#[test] +#[should_panic] +fn test_vote_without_auth_panics() { + let env = Env::default(); + // Set up without mock_all_auths so require_auth fails. + let admin = Address::generate(&env); + let member = Address::generate(&env); + let token_id = env.register(mock_token::MockToken, ()); + let contract_id = env.register(GroupTreasuryContract, ()); + let client = GroupTreasuryContractClient::new(&env, &contract_id); + + env.mock_all_auths(); + client.initialize(&admin, &token_id, &1); + client.add_member(&member); + let recipient = Address::generate(&env); + seed_proposal(&env, &contract_id, 0, &recipient, &token_id, 1_000, 10_000); + env.set_auths(&[]); // clear mocked auths — the vote must now fail + + client.approve_withdraw(&member, &0); +}