Skip to content
Merged
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
78 changes: 77 additions & 1 deletion contracts/contracts/group_treasury/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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<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);
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)
Expand Down
11 changes: 11 additions & 0 deletions contracts/contracts/group_treasury/src/storage.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
}
84 changes: 84 additions & 0 deletions contracts/contracts/group_treasury/src/test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Loading