From 4d41d1787e9aef289bdfe8be76b7134cc933b912 Mon Sep 17 00:00:00 2001 From: Depo-dev Date: Fri, 29 May 2026 12:44:48 +0100 Subject: [PATCH] feat(contract): implement proposal voting and execution Adds proposals Soroban contract with cast_vote/execute_proposal: double-vote prevention, expiry enforcement, yes>no pass threshold, and cross-contract treasury withdraw on passed proposals. Also adds group_treasury contract gating withdraw behind the proposals contract. 20 tests cover all acceptance criteria. Fixes #39 --- contracts/Cargo.lock | 14 + contracts/contracts/group_treasury/Cargo.toml | 15 + contracts/contracts/group_treasury/src/lib.rs | 101 ++++++ .../contracts/group_treasury/src/storage.rs | 23 ++ .../contracts/group_treasury/src/test.rs | 156 ++++++++++ .../group_treasury/src/token_interface.rs | 7 + contracts/contracts/proposals/Cargo.toml | 15 + contracts/contracts/proposals/src/lib.rs | 176 +++++++++++ contracts/contracts/proposals/src/storage.rs | 45 +++ contracts/contracts/proposals/src/test.rs | 293 ++++++++++++++++++ .../proposals/src/treasury_interface.rs | 7 + 11 files changed, 852 insertions(+) create mode 100644 contracts/contracts/group_treasury/Cargo.toml create mode 100644 contracts/contracts/group_treasury/src/lib.rs create mode 100644 contracts/contracts/group_treasury/src/storage.rs create mode 100644 contracts/contracts/group_treasury/src/test.rs create mode 100644 contracts/contracts/group_treasury/src/token_interface.rs create mode 100644 contracts/contracts/proposals/Cargo.toml create mode 100644 contracts/contracts/proposals/src/lib.rs create mode 100644 contracts/contracts/proposals/src/storage.rs create mode 100644 contracts/contracts/proposals/src/test.rs create mode 100644 contracts/contracts/proposals/src/treasury_interface.rs diff --git a/contracts/Cargo.lock b/contracts/Cargo.lock index ee9549d7..8b654e20 100644 --- a/contracts/Cargo.lock +++ b/contracts/Cargo.lock @@ -610,6 +610,13 @@ dependencies = [ "subtle", ] +[[package]] +name = "group_treasury" +version = "0.1.0" +dependencies = [ + "soroban-sdk", +] + [[package]] name = "hashbrown" version = "0.12.3" @@ -906,6 +913,13 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "proposals" +version = "0.1.0" +dependencies = [ + "soroban-sdk", +] + [[package]] name = "quote" version = "1.0.45" diff --git a/contracts/contracts/group_treasury/Cargo.toml b/contracts/contracts/group_treasury/Cargo.toml new file mode 100644 index 00000000..3402b88d --- /dev/null +++ b/contracts/contracts/group_treasury/Cargo.toml @@ -0,0 +1,15 @@ +[package] +name = "group_treasury" +version = "0.1.0" +edition = "2021" +publish = false + +[lib] +crate-type = ["lib", "cdylib"] +doctest = false + +[dependencies] +soroban-sdk = { workspace = true } + +[dev-dependencies] +soroban-sdk = { workspace = true, features = ["testutils"] } diff --git a/contracts/contracts/group_treasury/src/lib.rs b/contracts/contracts/group_treasury/src/lib.rs new file mode 100644 index 00000000..35fa1d5d --- /dev/null +++ b/contracts/contracts/group_treasury/src/lib.rs @@ -0,0 +1,101 @@ +#![no_std] + +mod storage; +mod token_interface; +mod test; + +use soroban_sdk::{contract, contractimpl, Address, Env, Symbol, Vec}; +use storage::{DataKey, DepositEvent, MemberList, WithdrawEvent}; +use token_interface::TokenClient; + +#[contract] +pub struct GroupTreasuryContract; + +#[contractimpl] +impl GroupTreasuryContract { + pub fn initialize(env: Env, admin: Address, token_contract: Address, members: Vec
) { + if env.storage().instance().has(&DataKey::Admin) { + panic!("already initialized"); + } + env.storage().instance().set(&DataKey::Admin, &admin); + env.storage().instance().set(&DataKey::TokenContract, &token_contract); + env.storage().instance().set(&DataKey::Members, &members); + } + + /// Admin-only: authorise the proposals contract to call withdraw. + pub fn set_proposals_contract(env: Env, proposals_contract: Address) { + let admin: Address = env + .storage() + .instance() + .get(&DataKey::Admin) + .expect("not initialized"); + admin.require_auth(); + env.storage() + .instance() + .set(&DataKey::ProposalsContract, &proposals_contract); + } + + /// Transfer tokens from `from` into the treasury. + pub fn deposit(env: Env, from: Address, amount: i128) { + if amount <= 0 { + panic!("amount must be positive"); + } + from.require_auth(); + let token_id: Address = env + .storage() + .instance() + .get(&DataKey::TokenContract) + .expect("not initialized"); + TokenClient::new(&env, &token_id).transfer(&from, &env.current_contract_address(), &amount); + env.events() + .publish((Symbol::new(&env, "deposit"),), DepositEvent { from, amount }); + } + + /// Transfer tokens out of the treasury to `to`. + /// Only the authorised proposals contract may call this. + pub fn withdraw(env: Env, to: Address, amount: i128) { + if amount <= 0 { + panic!("amount must be positive"); + } + let proposals: Address = env + .storage() + .instance() + .get(&DataKey::ProposalsContract) + .expect("proposals contract not set"); + // Satisfied automatically when the proposals contract makes a cross-contract call + proposals.require_auth(); + let token_id: Address = env + .storage() + .instance() + .get(&DataKey::TokenContract) + .expect("not initialized"); + TokenClient::new(&env, &token_id).transfer(&env.current_contract_address(), &to, &amount); + env.events() + .publish((Symbol::new(&env, "withdraw"),), WithdrawEvent { to, amount }); + } + + pub fn balance(env: Env) -> i128 { + let token_id: Address = env + .storage() + .instance() + .get(&DataKey::TokenContract) + .expect("not initialized"); + TokenClient::new(&env, &token_id).balance(&env.current_contract_address()) + } + + pub fn is_member(env: Env, addr: Address) -> bool { + let members: MemberList = env + .storage() + .instance() + .get(&DataKey::Members) + .unwrap_or_else(|| Vec::new(&env)); + members.contains(&addr) + } + + pub fn get_members(env: Env) -> MemberList { + env.storage() + .instance() + .get(&DataKey::Members) + .unwrap_or_else(|| Vec::new(&env)) + } +} diff --git a/contracts/contracts/group_treasury/src/storage.rs b/contracts/contracts/group_treasury/src/storage.rs new file mode 100644 index 00000000..e1711791 --- /dev/null +++ b/contracts/contracts/group_treasury/src/storage.rs @@ -0,0 +1,23 @@ +use soroban_sdk::{contracttype, Address, Vec}; + +#[contracttype] +pub enum DataKey { + Admin, + TokenContract, + ProposalsContract, + Members, +} + +#[contracttype] +pub struct DepositEvent { + pub from: Address, + pub amount: i128, +} + +#[contracttype] +pub struct WithdrawEvent { + pub to: Address, + pub amount: i128, +} + +pub type MemberList = Vec
; diff --git a/contracts/contracts/group_treasury/src/test.rs b/contracts/contracts/group_treasury/src/test.rs new file mode 100644 index 00000000..01b5a7ba --- /dev/null +++ b/contracts/contracts/group_treasury/src/test.rs @@ -0,0 +1,156 @@ +#![cfg(test)] + +use super::*; +use soroban_sdk::{testutils::Address as _, Address, Env, Vec}; + +mod mock_token { + use soroban_sdk::{contract, contractimpl, contracttype, Address, Env}; + + #[contracttype] + pub enum Key { + Balance(Address), + } + + #[contract] + pub struct MockToken; + + #[contractimpl] + impl MockToken { + pub fn mint(env: Env, to: Address, amount: i128) { + let key = Key::Balance(to); + let current: i128 = env.storage().persistent().get(&key).unwrap_or(0); + env.storage().persistent().set(&key, &(current + amount)); + } + + pub fn transfer(env: Env, from: Address, to: Address, amount: i128) { + from.require_auth(); + let from_key = Key::Balance(from.clone()); + let to_key = Key::Balance(to.clone()); + let from_bal: i128 = env.storage().persistent().get(&from_key).unwrap_or(0); + assert!(from_bal >= amount, "insufficient balance"); + env.storage().persistent().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)); + } + + pub fn balance(env: Env, id: Address) -> i128 { + env.storage() + .persistent() + .get(&Key::Balance(id)) + .unwrap_or(0) + } + } +} + +use mock_token::MockTokenClient; + +fn setup(env: &Env) -> (Address, Address, Address, Address, Address) { + let admin = Address::generate(env); + let member1 = Address::generate(env); + let member2 = Address::generate(env); + + let token_id = env.register(mock_token::MockToken, ()); + let token = MockTokenClient::new(env, &token_id); + token.mint(&member1, &1_000_000); + token.mint(&member2, &500_000); + + let mut members = Vec::new(env); + members.push_back(member1.clone()); + members.push_back(member2.clone()); + + let treasury_id = env.register(GroupTreasuryContract, ()); + let treasury = GroupTreasuryContractClient::new(env, &treasury_id); + treasury.initialize(&admin, &token_id, &members); + + (treasury_id, token_id, admin, member1, member2) +} + +#[test] +fn test_initialize() { + let env = Env::default(); + let (treasury_id, _token_id, _admin, member1, member2) = setup(&env); + let treasury = GroupTreasuryContractClient::new(&env, &treasury_id); + assert!(treasury.is_member(&member1)); + assert!(treasury.is_member(&member2)); + assert!(!treasury.is_member(&Address::generate(&env))); +} + +#[test] +#[should_panic(expected = "already initialized")] +fn test_double_initialize_panics() { + let env = Env::default(); + let (treasury_id, token_id, admin, member1, _member2) = setup(&env); + let treasury = GroupTreasuryContractClient::new(&env, &treasury_id); + let mut members = Vec::new(&env); + members.push_back(member1); + treasury.initialize(&admin, &token_id, &members); +} + +#[test] +fn test_deposit_and_balance() { + let env = Env::default(); + env.mock_all_auths(); + let (treasury_id, token_id, _admin, member1, _member2) = setup(&env); + let treasury = GroupTreasuryContractClient::new(&env, &treasury_id); + let token = MockTokenClient::new(&env, &token_id); + + assert_eq!(treasury.balance(), 0); + treasury.deposit(&member1, &300_000); + assert_eq!(treasury.balance(), 300_000); + assert_eq!(token.balance(&member1), 700_000); +} + +#[test] +#[should_panic(expected = "amount must be positive")] +fn test_deposit_zero_panics() { + let env = Env::default(); + env.mock_all_auths(); + let (treasury_id, _token_id, _admin, member1, _member2) = setup(&env); + let treasury = GroupTreasuryContractClient::new(&env, &treasury_id); + treasury.deposit(&member1, &0); +} + +#[test] +fn test_withdraw_by_proposals_contract() { + let env = Env::default(); + env.mock_all_auths(); + let (treasury_id, token_id, admin, member1, _member2) = setup(&env); + let treasury = GroupTreasuryContractClient::new(&env, &treasury_id); + let token = MockTokenClient::new(&env, &token_id); + + treasury.deposit(&member1, &500_000); + + let proposals_contract = Address::generate(&env); + treasury.set_proposals_contract(&proposals_contract); + + let recipient = Address::generate(&env); + treasury.withdraw(&recipient, &200_000); + + assert_eq!(treasury.balance(), 300_000); + assert_eq!(token.balance(&recipient), 200_000); + let _ = admin; +} + +#[test] +#[should_panic(expected = "proposals contract not set")] +fn test_withdraw_without_proposals_contract_panics() { + let env = Env::default(); + env.mock_all_auths(); + let (treasury_id, _token_id, _admin, member1, _member2) = setup(&env); + let treasury = GroupTreasuryContractClient::new(&env, &treasury_id); + treasury.deposit(&member1, &100_000); + + let recipient = Address::generate(&env); + treasury.withdraw(&recipient, &50_000); +} + +#[test] +fn test_get_members() { + let env = Env::default(); + let (treasury_id, _token_id, _admin, member1, member2) = setup(&env); + let treasury = GroupTreasuryContractClient::new(&env, &treasury_id); + let members = treasury.get_members(); + assert_eq!(members.len(), 2); + assert!(members.contains(&member1)); + assert!(members.contains(&member2)); +} diff --git a/contracts/contracts/group_treasury/src/token_interface.rs b/contracts/contracts/group_treasury/src/token_interface.rs new file mode 100644 index 00000000..edcaea39 --- /dev/null +++ b/contracts/contracts/group_treasury/src/token_interface.rs @@ -0,0 +1,7 @@ +use soroban_sdk::{contractclient, Address, Env}; + +#[contractclient(name = "TokenClient")] +pub trait TokenInterface { + fn transfer(env: Env, from: Address, to: Address, amount: i128); + fn balance(env: Env, id: Address) -> i128; +} diff --git a/contracts/contracts/proposals/Cargo.toml b/contracts/contracts/proposals/Cargo.toml new file mode 100644 index 00000000..9b92bc44 --- /dev/null +++ b/contracts/contracts/proposals/Cargo.toml @@ -0,0 +1,15 @@ +[package] +name = "proposals" +version = "0.1.0" +edition = "2021" +publish = false + +[lib] +crate-type = ["lib", "cdylib"] +doctest = false + +[dependencies] +soroban-sdk = { workspace = true } + +[dev-dependencies] +soroban-sdk = { workspace = true, features = ["testutils"] } diff --git a/contracts/contracts/proposals/src/lib.rs b/contracts/contracts/proposals/src/lib.rs new file mode 100644 index 00000000..d2c536a8 --- /dev/null +++ b/contracts/contracts/proposals/src/lib.rs @@ -0,0 +1,176 @@ +#![no_std] + +mod storage; +mod treasury_interface; +mod test; + +use soroban_sdk::{contract, contractimpl, Address, Env, String, Symbol}; +use storage::{DataKey, Proposal, ProposalCreatedEvent, ProposalExecutedEvent, VoteCastEvent}; +use treasury_interface::TreasuryClient; + +#[contract] +pub struct ProposalsContract; + +#[contractimpl] +impl ProposalsContract { + pub fn initialize(env: Env, admin: Address, treasury: Address) { + if env.storage().instance().has(&DataKey::Admin) { + panic!("already initialized"); + } + env.storage().instance().set(&DataKey::Admin, &admin); + env.storage().instance().set(&DataKey::Treasury, &treasury); + env.storage().instance().set(&DataKey::NextId, &0u32); + } + + /// Create a new proposal. Returns the new proposal ID. + pub fn create_proposal( + env: Env, + proposer: Address, + description: String, + amount: i128, + recipient: Address, + duration_secs: u64, + ) -> u32 { + proposer.require_auth(); + if amount <= 0 { + panic!("amount must be positive"); + } + if duration_secs == 0 { + panic!("duration must be positive"); + } + + let id: u32 = env + .storage() + .instance() + .get(&DataKey::NextId) + .unwrap_or(0); + + let proposal = Proposal { + proposer: proposer.clone(), + description, + amount, + recipient, + yes_votes: 0, + no_votes: 0, + end_time: env.ledger().timestamp() + duration_secs, + executed: false, + }; + + env.storage() + .persistent() + .set(&DataKey::Proposal(id), &proposal); + env.storage() + .instance() + .set(&DataKey::NextId, &(id + 1)); + + env.events().publish( + (Symbol::new(&env, "proposal_created"),), + ProposalCreatedEvent { + id, + proposer, + amount, + }, + ); + + id + } + + /// Cast a vote on an open proposal. + /// Panics with "already voted" if the voter has already cast a vote. + /// Panics with "voting period has ended" if the proposal has expired. + pub fn cast_vote(env: Env, voter: Address, proposal_id: u32, approve: bool) { + voter.require_auth(); + + let mut proposal: Proposal = env + .storage() + .persistent() + .get(&DataKey::Proposal(proposal_id)) + .expect("proposal not found"); + + if env.ledger().timestamp() >= proposal.end_time { + panic!("voting period has ended"); + } + + let voted: bool = env + .storage() + .persistent() + .get(&DataKey::Voted(proposal_id, voter.clone())) + .unwrap_or(false); + + if voted { + panic!("already voted"); + } + + if approve { + proposal.yes_votes += 1; + } else { + proposal.no_votes += 1; + } + + env.storage() + .persistent() + .set(&DataKey::Proposal(proposal_id), &proposal); + env.storage() + .persistent() + .set(&DataKey::Voted(proposal_id, voter.clone()), &true); + + env.events().publish( + (Symbol::new(&env, "vote_cast"),), + VoteCastEvent { + proposal_id, + voter, + approve, + }, + ); + } + + /// Execute a proposal once its voting period has ended. + /// If yes_votes > no_votes the treasury withdraw is triggered. + /// Panics if the voting period has not yet ended, or if already executed. + pub fn execute_proposal(env: Env, proposal_id: u32) { + let mut proposal: Proposal = env + .storage() + .persistent() + .get(&DataKey::Proposal(proposal_id)) + .expect("proposal not found"); + + if env.ledger().timestamp() < proposal.end_time { + panic!("voting period not yet ended"); + } + + if proposal.executed { + panic!("proposal already executed"); + } + + proposal.executed = true; + env.storage() + .persistent() + .set(&DataKey::Proposal(proposal_id), &proposal); + + let passed = proposal.yes_votes > proposal.no_votes; + + if passed { + let treasury: Address = env + .storage() + .instance() + .get(&DataKey::Treasury) + .expect("not initialized"); + TreasuryClient::new(&env, &treasury).withdraw(&proposal.recipient, &proposal.amount); + } + + env.events().publish( + (Symbol::new(&env, "proposal_executed"),), + ProposalExecutedEvent { + proposal_id, + passed, + }, + ); + } + + pub fn get_proposal(env: Env, proposal_id: u32) -> Proposal { + env.storage() + .persistent() + .get(&DataKey::Proposal(proposal_id)) + .expect("proposal not found") + } +} diff --git a/contracts/contracts/proposals/src/storage.rs b/contracts/contracts/proposals/src/storage.rs new file mode 100644 index 00000000..20888bed --- /dev/null +++ b/contracts/contracts/proposals/src/storage.rs @@ -0,0 +1,45 @@ +use soroban_sdk::{contracttype, Address, String}; + +#[contracttype] +#[derive(Clone)] +pub struct Proposal { + pub proposer: Address, + pub description: String, + pub amount: i128, + pub recipient: Address, + pub yes_votes: u32, + pub no_votes: u32, + /// Unix timestamp (seconds) when the voting period closes. + pub end_time: u64, + pub executed: bool, +} + +#[contracttype] +pub enum DataKey { + Admin, + Treasury, + NextId, + Proposal(u32), + /// Tracks whether a given voter has already voted on a proposal. + Voted(u32, Address), +} + +#[contracttype] +pub struct ProposalCreatedEvent { + pub id: u32, + pub proposer: Address, + pub amount: i128, +} + +#[contracttype] +pub struct VoteCastEvent { + pub proposal_id: u32, + pub voter: Address, + pub approve: bool, +} + +#[contracttype] +pub struct ProposalExecutedEvent { + pub proposal_id: u32, + pub passed: bool, +} \ No newline at end of file diff --git a/contracts/contracts/proposals/src/test.rs b/contracts/contracts/proposals/src/test.rs new file mode 100644 index 00000000..4e2c28e2 --- /dev/null +++ b/contracts/contracts/proposals/src/test.rs @@ -0,0 +1,293 @@ +#![cfg(test)] + +use super::*; +use soroban_sdk::{testutils::{Address as _, Ledger}, Address, Env, String}; + +// ── Mock treasury that records withdrawals ──────────────────────────────────── + +mod mock_treasury { + use soroban_sdk::{contract, contractimpl, contracttype, Address, Env, Vec}; + + #[contracttype] + pub struct Withdrawal { + pub to: Address, + pub amount: i128, + } + + #[contracttype] + enum Key { + History, + } + + #[contract] + pub struct MockTreasury; + + #[contractimpl] + impl MockTreasury { + pub fn withdraw(env: Env, to: Address, amount: i128) { + let mut history: Vec = + env.storage().persistent().get(&Key::History).unwrap_or(Vec::new(&env)); + history.push_back(Withdrawal { to, amount }); + env.storage().persistent().set(&Key::History, &history); + } + + pub fn get_withdrawals(env: Env) -> Vec { + env.storage() + .persistent() + .get(&Key::History) + .unwrap_or(Vec::new(&env)) + } + } +} + +use mock_treasury::{MockTreasuryClient, MockTreasury}; + +fn setup(env: &Env) -> (Address, Address, Address, Address) { + let admin = Address::generate(env); + let treasury_id = env.register(MockTreasury, ()); + + let contract_id = env.register(ProposalsContract, ()); + let client = ProposalsContractClient::new(env, &contract_id); + client.initialize(&admin, &treasury_id); + + let proposer = Address::generate(env); + let recipient = Address::generate(env); + (contract_id, treasury_id, proposer, recipient) +} + +fn make_proposal( + env: &Env, + client: &ProposalsContractClient, + proposer: &Address, + recipient: &Address, + duration_secs: u64, +) -> u32 { + client.create_proposal( + proposer, + &String::from_str(env, "Fund the project"), + &1_000, + recipient, + &duration_secs, + ) +} + +#[test] +fn test_initialize() { + let env = Env::default(); + let (contract_id, _treasury_id, _proposer, _recipient) = setup(&env); + // Verify initialization does not panic and state is set + let _ = ProposalsContractClient::new(&env, &contract_id); +} + +#[test] +#[should_panic(expected = "already initialized")] +fn test_double_initialize_panics() { + let env = Env::default(); + let (contract_id, treasury_id, _proposer, _recipient) = setup(&env); + let client = ProposalsContractClient::new(&env, &contract_id); + let admin = Address::generate(&env); + client.initialize(&admin, &treasury_id); +} + +#[test] +fn test_create_proposal_returns_id() { + let env = Env::default(); + env.mock_all_auths(); + let (contract_id, _treasury_id, proposer, recipient) = setup(&env); + let client = ProposalsContractClient::new(&env, &contract_id); + + let id0 = make_proposal(&env, &client, &proposer, &recipient, 3600); + let id1 = make_proposal(&env, &client, &proposer, &recipient, 3600); + assert_eq!(id0, 0); + assert_eq!(id1, 1); +} + +#[test] +fn test_approve_vote_increments_yes() { + let env = Env::default(); + env.mock_all_auths(); + let (contract_id, _treasury_id, proposer, recipient) = setup(&env); + let client = ProposalsContractClient::new(&env, &contract_id); + + let id = make_proposal(&env, &client, &proposer, &recipient, 3600); + let voter = Address::generate(&env); + client.cast_vote(&voter, &id, &true); + + let p = client.get_proposal(&id); + assert_eq!(p.yes_votes, 1); + assert_eq!(p.no_votes, 0); +} + +#[test] +fn test_reject_vote_increments_no() { + let env = Env::default(); + env.mock_all_auths(); + let (contract_id, _treasury_id, proposer, recipient) = setup(&env); + let client = ProposalsContractClient::new(&env, &contract_id); + + let id = make_proposal(&env, &client, &proposer, &recipient, 3600); + let voter = Address::generate(&env); + client.cast_vote(&voter, &id, &false); + + let p = client.get_proposal(&id); + assert_eq!(p.yes_votes, 0); + assert_eq!(p.no_votes, 1); +} + +#[test] +#[should_panic(expected = "already voted")] +fn test_double_vote_panics() { + let env = Env::default(); + env.mock_all_auths(); + let (contract_id, _treasury_id, proposer, recipient) = setup(&env); + let client = ProposalsContractClient::new(&env, &contract_id); + + let id = make_proposal(&env, &client, &proposer, &recipient, 3600); + let voter = Address::generate(&env); + client.cast_vote(&voter, &id, &true); + client.cast_vote(&voter, &id, &true); // second vote panics +} + +#[test] +#[should_panic(expected = "voting period has ended")] +fn test_vote_after_expiry_panics() { + let env = Env::default(); + env.mock_all_auths(); + let (contract_id, _treasury_id, proposer, recipient) = setup(&env); + let client = ProposalsContractClient::new(&env, &contract_id); + + // Duration of 0 is rejected, so use 1 second then advance the ledger + let id = make_proposal(&env, &client, &proposer, &recipient, 1); + + // Advance ledger past the end_time + env.ledger().with_mut(|l| l.timestamp += 10); + + let voter = Address::generate(&env); + client.cast_vote(&voter, &id, &true); +} + +#[test] +fn test_execute_passed_proposal_triggers_treasury_withdraw() { + let env = Env::default(); + env.mock_all_auths(); + let (contract_id, treasury_id, proposer, recipient) = setup(&env); + let client = ProposalsContractClient::new(&env, &contract_id); + let treasury = MockTreasuryClient::new(&env, &treasury_id); + + let id = make_proposal(&env, &client, &proposer, &recipient, 1); + + let voter_a = Address::generate(&env); + let voter_b = Address::generate(&env); + client.cast_vote(&voter_a, &id, &true); + client.cast_vote(&voter_b, &id, &true); + + // Advance past voting period + env.ledger().with_mut(|l| l.timestamp += 10); + + client.execute_proposal(&id); + + let withdrawals = treasury.get_withdrawals(); + assert_eq!(withdrawals.len(), 1); + assert_eq!(withdrawals.get(0).unwrap().amount, 1_000); + + let p = client.get_proposal(&id); + assert!(p.executed); +} + +#[test] +fn test_execute_failed_proposal_no_treasury_call() { + let env = Env::default(); + env.mock_all_auths(); + let (contract_id, treasury_id, proposer, recipient) = setup(&env); + let client = ProposalsContractClient::new(&env, &contract_id); + let treasury = MockTreasuryClient::new(&env, &treasury_id); + + let id = make_proposal(&env, &client, &proposer, &recipient, 1); + + // More no votes than yes votes + client.cast_vote(&Address::generate(&env), &id, &false); + client.cast_vote(&Address::generate(&env), &id, &false); + client.cast_vote(&Address::generate(&env), &id, &true); + + env.ledger().with_mut(|l| l.timestamp += 10); + + client.execute_proposal(&id); + + // Treasury should NOT have been called + assert_eq!(treasury.get_withdrawals().len(), 0); + + let p = client.get_proposal(&id); + assert!(p.executed); +} + +#[test] +#[should_panic(expected = "voting period not yet ended")] +fn test_execute_before_expiry_panics() { + let env = Env::default(); + env.mock_all_auths(); + let (contract_id, _treasury_id, proposer, recipient) = setup(&env); + let client = ProposalsContractClient::new(&env, &contract_id); + + let id = make_proposal(&env, &client, &proposer, &recipient, 3600); + client.execute_proposal(&id); // voting still open +} + +#[test] +#[should_panic(expected = "proposal already executed")] +fn test_double_execute_panics() { + let env = Env::default(); + env.mock_all_auths(); + let (contract_id, _treasury_id, proposer, recipient) = setup(&env); + let client = ProposalsContractClient::new(&env, &contract_id); + + let id = make_proposal(&env, &client, &proposer, &recipient, 1); + env.ledger().with_mut(|l| l.timestamp += 10); + client.execute_proposal(&id); + client.execute_proposal(&id); // second execution panics +} + +#[test] +fn test_tied_vote_does_not_pass() { + let env = Env::default(); + env.mock_all_auths(); + let (contract_id, treasury_id, proposer, recipient) = setup(&env); + let client = ProposalsContractClient::new(&env, &contract_id); + let treasury = MockTreasuryClient::new(&env, &treasury_id); + + let id = make_proposal(&env, &client, &proposer, &recipient, 1); + client.cast_vote(&Address::generate(&env), &id, &true); + client.cast_vote(&Address::generate(&env), &id, &false); + + env.ledger().with_mut(|l| l.timestamp += 10); + client.execute_proposal(&id); + + // Tie means no_votes == yes_votes, so proposal does NOT pass + assert_eq!(treasury.get_withdrawals().len(), 0); +} + +#[test] +fn test_multiple_voters_tally() { + let env = Env::default(); + env.mock_all_auths(); + let (contract_id, treasury_id, proposer, recipient) = setup(&env); + let client = ProposalsContractClient::new(&env, &contract_id); + let treasury = MockTreasuryClient::new(&env, &treasury_id); + + let id = make_proposal(&env, &client, &proposer, &recipient, 1); + + for _ in 0..3 { + client.cast_vote(&Address::generate(&env), &id, &true); + } + for _ in 0..2 { + client.cast_vote(&Address::generate(&env), &id, &false); + } + + let p = client.get_proposal(&id); + assert_eq!(p.yes_votes, 3); + assert_eq!(p.no_votes, 2); + + env.ledger().with_mut(|l| l.timestamp += 10); + client.execute_proposal(&id); + + assert_eq!(treasury.get_withdrawals().len(), 1); +} diff --git a/contracts/contracts/proposals/src/treasury_interface.rs b/contracts/contracts/proposals/src/treasury_interface.rs new file mode 100644 index 00000000..f27a90ab --- /dev/null +++ b/contracts/contracts/proposals/src/treasury_interface.rs @@ -0,0 +1,7 @@ +use soroban_sdk::{contractclient, Address, Env}; + +/// Minimal interface for calling the group treasury contract. +#[contractclient(name = "TreasuryClient")] +pub trait TreasuryInterface { + fn withdraw(env: Env, to: Address, amount: i128); +} \ No newline at end of file