diff --git a/contracts/contracts/group_treasury/src/lib.rs b/contracts/contracts/group_treasury/src/lib.rs index f847637..5a40120 100644 --- a/contracts/contracts/group_treasury/src/lib.rs +++ b/contracts/contracts/group_treasury/src/lib.rs @@ -7,7 +7,8 @@ mod token_interface; use soroban_sdk::{contract, contractimpl, Address, Env, Map, Symbol, Vec}; use storage::{ DataKey, DepositEvent, MemberAddedEvent, MemberRemovedEvent, ProposalApprovedEvent, - ProposalRejectedEvent, ProposalStatus, WithdrawEvent, WithdrawProposal, WithdrawVoteCastEvent, + ProposalCreatedEvent, ProposalRejectedEvent, ProposalStatus, WithdrawEvent, WithdrawProposal, + WithdrawVoteCastEvent, }; use token_interface::TokenClient; @@ -211,6 +212,81 @@ impl GroupTreasuryContract { balances.get(token).unwrap_or(0) } + /// Member-only: create a new 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(); + + if !Self::is_member(env.clone(), proposer.clone()) { + 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); + env.storage() + .instance() + .set(&DataKey::ProposalCount, &(id + 1)); + + let expires_at = env.ledger().timestamp() + (ttl_ledgers as u64 * 5); // ~5s per ledger + + let proposal = WithdrawProposal { + id, + proposer: proposer.clone(), + to: to.clone(), + token: token.clone(), + amount, + approvals: 1, // proposer auto-approves + rejections: 0, + status: ProposalStatus::Active, + expires_at, + }; + env.storage() + .instance() + .set(&DataKey::Proposal(id), &proposal); + + // Record proposer's auto-approval vote + env.storage() + .instance() + .set(&DataKey::Vote(id, proposer.clone()), &true); + + env.events().publish( + (Symbol::new(&env, "proposal_created"),), + ProposalCreatedEvent { + id, + proposer, + to, + token, + amount, + expires_at, + }, + ); + + id + } + /// 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) diff --git a/contracts/contracts/group_treasury/src/storage.rs b/contracts/contracts/group_treasury/src/storage.rs index f9bd57c..e29d1c2 100644 --- a/contracts/contracts/group_treasury/src/storage.rs +++ b/contracts/contracts/group_treasury/src/storage.rs @@ -81,3 +81,14 @@ pub struct ProposalRejectedEvent { pub id: u32, pub rejections: u32, } + +/// Emitted when a new withdraw proposal is created. +#[contracttype] +pub struct ProposalCreatedEvent { + pub id: u32, + pub proposer: Address, + pub to: Address, + pub token: Address, + pub amount: i128, + pub expires_at: u64, +} diff --git a/contracts/contracts/group_treasury/src/test.rs b/contracts/contracts/group_treasury/src/test.rs index 743c4c9..0024bad 100644 --- a/contracts/contracts/group_treasury/src/test.rs +++ b/contracts/contracts/group_treasury/src/test.rs @@ -594,3 +594,87 @@ fn test_vote_without_auth_panics() { client.approve_withdraw(&member, &0); } + +// ── propose_withdraw Tests (#122) ───────────────────────────────────────────── + +#[test] +fn test_propose_withdraw_returned_id_matches_stored() { + let env = Env::default(); + let (contract_id, token_id, members) = voting_setup(&env, 1, 1); + let client = GroupTreasuryContractClient::new(&env, &contract_id); + let token = mock_token::MockTokenClient::new(&env, &token_id); + let member = members.get(0).unwrap(); + token.mint(&member, &500_000); + client.deposit(&member, &token_id, &500_000); + + let recipient = Address::generate(&env); + let id = client.propose_withdraw(&member, &recipient, &token_id, &100_000, &100); + let proposal = client.get_proposal(&id); + + assert_eq!(id, proposal.id); +} + +#[test] +#[should_panic(expected = "proposer is not a member")] +fn test_propose_withdraw_non_member_panics() { + let env = Env::default(); + let (contract_id, token_id, _members) = voting_setup(&env, 1, 1); + let client = GroupTreasuryContractClient::new(&env, &contract_id); + let token = mock_token::MockTokenClient::new(&env, &token_id); + let outsider = Address::generate(&env); + token.mint(&outsider, &500_000); + client.deposit(&outsider, &token_id, &500_000); + + let recipient = Address::generate(&env); + client.propose_withdraw(&outsider, &recipient, &token_id, &100_000, &100); +} + +#[test] +#[should_panic(expected = "insufficient funds")] +fn test_propose_withdraw_insufficient_balance_panics() { + let env = Env::default(); + let (contract_id, token_id, members) = voting_setup(&env, 1, 1); + let client = GroupTreasuryContractClient::new(&env, &contract_id); + let token = mock_token::MockTokenClient::new(&env, &token_id); + let member = members.get(0).unwrap(); + token.mint(&member, &50_000); + client.deposit(&member, &token_id, &50_000); + + let recipient = Address::generate(&env); + client.propose_withdraw(&member, &recipient, &token_id, &100_000, &100); +} + +#[test] +fn test_propose_withdraw_auto_adds_proposer_approval() { + let env = Env::default(); + let (contract_id, token_id, members) = voting_setup(&env, 1, 1); + let client = GroupTreasuryContractClient::new(&env, &contract_id); + let token = mock_token::MockTokenClient::new(&env, &token_id); + let member = members.get(0).unwrap(); + token.mint(&member, &500_000); + client.deposit(&member, &token_id, &500_000); + + let recipient = Address::generate(&env); + let id = client.propose_withdraw(&member, &recipient, &token_id, &100_000, &100); + let proposal = client.get_proposal(&id); + + assert_eq!(proposal.approvals, 1); +} + +#[test] +fn test_propose_withdraw_increments_proposal_id() { + let env = Env::default(); + let (contract_id, token_id, members) = voting_setup(&env, 1, 1); + let client = GroupTreasuryContractClient::new(&env, &contract_id); + let token = mock_token::MockTokenClient::new(&env, &token_id); + let member = members.get(0).unwrap(); + token.mint(&member, &500_000); + client.deposit(&member, &token_id, &500_000); + + let recipient = Address::generate(&env); + let id0 = client.propose_withdraw(&member, &recipient, &token_id, &100_000, &100); + let id1 = client.propose_withdraw(&member, &recipient, &token_id, &100_000, &100); + + assert_eq!(id0, 0); + assert_eq!(id1, 1); +}