Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
87 changes: 86 additions & 1 deletion contracts/contracts/group_treasury/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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<Address> = 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<Address, i128> = 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")
}
}
36 changes: 35 additions & 1 deletion contracts/contracts/group_treasury/src/storage.rs
Original file line number Diff line number Diff line change
@@ -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]
Expand All @@ -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,
}
90 changes: 90 additions & 0 deletions contracts/contracts/group_treasury/src/test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}