From aa81f81873e44168b617be4dae6655ddc97d831a Mon Sep 17 00:00:00 2001 From: Kayce10 Date: Thu, 25 Jun 2026 21:03:04 +0000 Subject: [PATCH] feat(group-treasury): implement propose_withdraw (#122) - Add WithdrawProposal, ProposalStatus, ProposalCreatedEvent types to storage.rs - Add ProposalCount and WithdrawProposal(u32) DataKey variants - Implement propose_withdraw: validates member, amount > 0, sufficient balance, increments ProposalCount, stores proposal with Pending status and expires_at, auto-adds proposer approval, emits ProposalCreatedEvent, returns proposal ID - Add get_withdraw_proposal helper - Add 5 tests covering all acceptance criteria --- contracts/contracts/group_treasury/src/lib.rs | 87 +++++++++++++++++- .../contracts/group_treasury/src/storage.rs | 36 +++++++- .../contracts/group_treasury/src/test.rs | 90 +++++++++++++++++++ 3 files changed, 211 insertions(+), 2 deletions(-) diff --git a/contracts/contracts/group_treasury/src/lib.rs b/contracts/contracts/group_treasury/src/lib.rs index 278b60c..25dc5e3 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, ProposalCreatedEvent, + ProposalStatus, WithdrawEvent, WithdrawProposal, +}; use token_interface::TokenClient; fn require_admin(env: &Env) -> Address { @@ -190,4 +193,86 @@ impl GroupTreasuryContract { balances.get(token).unwrap_or(0) } + + /// Member-only: create a withdraw proposal. Returns the new proposal ID. + pub fn propose_withdraw( + env: Env, + proposer: Address, + to: Address, + token: Address, + amount: i128, + ttl_ledgers: u32, + ) -> u32 { + proposer.require_auth(); + + let members: Vec
= env + .storage() + .instance() + .get(&DataKey::Members) + .unwrap_or_else(|| Vec::new(&env)); + if !members.iter().any(|m| m == proposer) { + panic!("proposer is not a member"); + } + + if amount <= 0 { + panic!("amount must be positive"); + } + + let balances: Map = env + .storage() + .instance() + .get(&DataKey::Balances) + .unwrap_or_else(|| Map::new(&env)); + if balances.get(token.clone()).unwrap_or(0) < amount { + panic!("insufficient funds"); + } + + let id: u32 = env + .storage() + .instance() + .get(&DataKey::ProposalCount) + .unwrap_or(0); + + let expires_at = env.ledger().sequence() + ttl_ledgers; + + let proposal = WithdrawProposal { + id, + proposer: proposer.clone(), + to: to.clone(), + token: token.clone(), + amount, + approvals: 1, + status: ProposalStatus::Pending, + expires_at, + }; + + env.storage() + .instance() + .set(&DataKey::WithdrawProposal(id), &proposal); + env.storage() + .instance() + .set(&DataKey::ProposalCount, &(id + 1)); + + env.events().publish( + (Symbol::new(&env, "proposal_created"),), + ProposalCreatedEvent { + id, + proposer, + to, + token, + amount, + expires_at, + }, + ); + + id + } + + /// Get a withdraw proposal by ID. + pub fn get_withdraw_proposal(env: Env, proposal_id: u32) -> WithdrawProposal { + env.storage() + .instance() + .get(&DataKey::WithdrawProposal(proposal_id)) + .expect("proposal not found") + } } diff --git a/contracts/contracts/group_treasury/src/storage.rs b/contracts/contracts/group_treasury/src/storage.rs index bdfc58c..88a51f2 100644 --- a/contracts/contracts/group_treasury/src/storage.rs +++ b/contracts/contracts/group_treasury/src/storage.rs @@ -1,10 +1,34 @@ -use soroban_sdk::{contracttype, Address, Vec}; +use soroban_sdk::{contracttype, Address}; #[contracttype] pub enum DataKey { Admin, Balances, Members, + ProposalCount, + WithdrawProposal(u32), +} + +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum ProposalStatus { + Pending, + Approved, + 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 status: ProposalStatus, + pub expires_at: u32, } #[contracttype] @@ -30,3 +54,13 @@ pub struct MemberRemovedEvent { pub member: Address, pub removed_by: Address, } + +#[contracttype] +pub struct ProposalCreatedEvent { + pub id: u32, + pub proposer: Address, + pub to: Address, + pub token: Address, + pub amount: i128, + pub expires_at: u32, +} diff --git a/contracts/contracts/group_treasury/src/test.rs b/contracts/contracts/group_treasury/src/test.rs index 9da316f..9342741 100644 --- a/contracts/contracts/group_treasury/src/test.rs +++ b/contracts/contracts/group_treasury/src/test.rs @@ -335,3 +335,93 @@ fn test_initialize_creates_empty_members_list() { let members = client.get_members(); assert_eq!(members.len(), 0); } + +// ── propose_withdraw Tests ──────────────────────────────────────────────────── + +/// Helper: setup + deposit + add a member, returns (contract_id, token_id, admin, member) +fn setup_with_member_and_deposit(env: &Env) -> (Address, Address, Address, Address) { + let (contract_id, token_id, admin, member) = setup(env); + let client = GroupTreasuryContractClient::new(env, &contract_id); + client.add_member(&member); + client.deposit(&member, &token_id, &500_000); + (contract_id, token_id, admin, member) +} + +#[test] +fn test_propose_withdraw_returns_correct_id() { + let env = Env::default(); + env.mock_all_auths(); + + let (contract_id, token_id, _admin, member) = setup_with_member_and_deposit(&env); + let client = GroupTreasuryContractClient::new(&env, &contract_id); + let recipient = Address::generate(&env); + + let id = client.propose_withdraw(&member, &recipient, &token_id, &100_000, &100); + assert_eq!(id, 0); + + let proposal = client.get_withdraw_proposal(&id); + assert_eq!(proposal.id, id); +} + +#[test] +fn test_propose_withdraw_ids_increment() { + let env = Env::default(); + env.mock_all_auths(); + + let (contract_id, token_id, _admin, member) = setup_with_member_and_deposit(&env); + let client = GroupTreasuryContractClient::new(&env, &contract_id); + let recipient = Address::generate(&env); + + let id0 = client.propose_withdraw(&member, &recipient, &token_id, &50_000, &100); + let id1 = client.propose_withdraw(&member, &recipient, &token_id, &50_000, &100); + assert_eq!(id0, 0); + assert_eq!(id1, 1); +} + +#[test] +fn test_propose_withdraw_proposal_fields() { + let env = Env::default(); + env.mock_all_auths(); + + let (contract_id, token_id, _admin, member) = setup_with_member_and_deposit(&env); + let client = GroupTreasuryContractClient::new(&env, &contract_id); + let recipient = Address::generate(&env); + + let ttl: u32 = 200; + let id = client.propose_withdraw(&member, &recipient, &token_id, &100_000, &ttl); + let proposal = client.get_withdraw_proposal(&id); + + assert_eq!(proposal.proposer, member); + assert_eq!(proposal.to, recipient); + assert_eq!(proposal.token, token_id); + assert_eq!(proposal.amount, 100_000); + assert_eq!(proposal.approvals, 1); // proposer auto-approved +} + +#[test] +#[should_panic(expected = "proposer is not a member")] +fn test_propose_withdraw_non_member_panics() { + let env = Env::default(); + env.mock_all_auths(); + + let (contract_id, token_id, _admin, _member) = setup_with_member_and_deposit(&env); + let client = GroupTreasuryContractClient::new(&env, &contract_id); + + let non_member = Address::generate(&env); + let recipient = Address::generate(&env); + client.propose_withdraw(&non_member, &recipient, &token_id, &100_000, &100); +} + +#[test] +#[should_panic(expected = "insufficient funds")] +fn test_propose_withdraw_insufficient_funds_panics() { + let env = Env::default(); + env.mock_all_auths(); + + let (contract_id, token_id, _admin, member) = setup_with_member_and_deposit(&env); + let client = GroupTreasuryContractClient::new(&env, &contract_id); + let recipient = Address::generate(&env); + + // Balance is 500_000; request more than that + client.propose_withdraw(&member, &recipient, &token_id, &600_000, &100); +}