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
18 changes: 18 additions & 0 deletions contracts/TODO.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
# TODO

- [ ] Implement `execute_withdraw(env, caller, proposal_id)` in `contracts/contracts/proposals/src/lib.rs`
- [ ] Verify caller is a treasury member (via treasury client)
- [ ] Verify proposal status is `Approved` (and not Executed)
- [ ] Verify treasury has sufficient balance
- [ ] Call `TokenClient::transfer` (or treasury/withdraw path consistent with repo)
- [ ] Deduct balance from `DataKey::Balances`
- [ ] Set proposal status to `Executed`
- [ ] Emit `WithdrawEvent` and `ProposalExecutedEvent`
- [ ] Add/extend treasury interface(s) in proposals contract to match the needed calls
- [ ] Add unit tests covering acceptance criteria:
- [ ] Pending proposal panics with "proposal not approved"
- [ ] Already executed proposal panics
- [ ] Balance correctly reduced after execution
- [ ] Non-member caller panics
- [ ] Run contract tests (`cargo test -p proposals` and any other affected crates)
- [ ] Create new git branch `blackboxai/...`, commit changes, and push to GitHub
96 changes: 91 additions & 5 deletions contracts/contracts/proposals/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@
mod storage;
mod test;

mod treasury_interface;

use soroban_sdk::{contract, contractimpl, symbol_short, Address, Env, String, Symbol};

pub use storage::{
Expand Down Expand Up @@ -54,12 +56,19 @@ impl ProposalsContract {
proposer: Address,
description: String,
expires_at: u64,
treasury: Address,
token: Address,
to: Address,
amount: i128,
) -> u64 {
proposer.require_auth();
let now = env.ledger().timestamp();
if expires_at <= now {
panic!("expires_at must be in the future");
}
if amount <= 0 {
panic!("amount must be positive");
}

let id: u64 = env
.storage()
Expand All @@ -75,7 +84,13 @@ impl ProposalsContract {
yes_votes: 0,
no_votes: 0,
status: ProposalStatus::Active,
treasury: treasury.clone(),
token: token.clone(),
to: to.clone(),
amount,
};


env.storage()
.instance()
.set(&DataKey::Proposal(id), &proposal);
Expand All @@ -89,8 +104,14 @@ impl ProposalsContract {
id,
proposer,
expires_at,
treasury,
token,
to,
amount,
},
);


id
}

Expand Down Expand Up @@ -132,13 +153,14 @@ impl ProposalsContract {
);
}

/// Finalise a proposal after its `expires_at`. Callable by anyone
/// — the auto-rejection mechanic from the issue. Sets the status
/// to `Passed` when `yes_votes > no_votes`, else `Rejected`. The
/// tie (yes_votes == no_votes) breaks toward Rejected per the
/// issue's `yes_votes <= no_votes` condition.
/// Finalise a proposal after its `expires_at`. Callable by anyone.
///
/// Status mapping (required by execute_withdraw acceptance criteria):
/// - `yes_votes > no_votes` => `Approved`
/// - otherwise => `Rejected`
pub fn finalize_proposal(env: Env, proposal_id: u64) -> ProposalStatus {
let mut proposal = Self::load_proposal(&env, proposal_id);

if !matches!(proposal.status, ProposalStatus::Active) {
panic!("proposal already finalized");
}
Expand Down Expand Up @@ -192,6 +214,70 @@ impl ProposalsContract {
);
}

/// Withdraw from the group treasury for an approved proposal.
///
/// Acceptance criteria requirements:
/// - caller must be a treasury member
/// - proposal must be Approved
/// - proposal must not already be Executed
/// - treasury must have sufficient balance
/// - emits WithdrawEvent (from treasury) and ProposalExecutedEvent (from proposals)
pub fn execute_withdraw(env: Env, caller: Address, proposal_id: u64) {
caller.require_auth();

let mut proposal = Self::load_proposal(&env, proposal_id);

if matches!(proposal.status, ProposalStatus::Executed) {
panic!("proposal already executed");
}
if !matches!(proposal.status, ProposalStatus::Approved) {
panic!("proposal not approved");
}


// Verify caller is a treasury member.
let treasury_client = crate::treasury_interface::TreasuryClient::new(
&env,
&proposal.treasury,
);

if !treasury_client.is_member(&caller.clone()) {
panic!("caller is not a treasury member");
}

// Verify sufficient balance.
let bal = treasury_client.balance(&proposal.token.clone());
if bal < proposal.amount {
panic!("insufficient funds");
}

// Withdraw from the treasury.
treasury_client.withdraw(
&proposal.to.clone(),
&proposal.token.clone(),
&proposal.amount,
);


// Update proposal status.
proposal.status = ProposalStatus::Executed;
env.storage()
.instance()
.set(&DataKey::Proposal(proposal_id), &proposal);

// Emit proposal execution event.
env.events().publish(
(symbol_short!("execut"),),
ProposalExecutedEvent {
id: proposal_id,
executor: caller,
},
);

}



pub fn get_proposal(env: Env, proposal_id: u64) -> Proposal {
Self::load_proposal(&env, proposal_id)
}
Expand Down
14 changes: 14 additions & 0 deletions contracts/contracts/proposals/src/storage.rs
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ pub enum DataKey {
#[derive(Clone, Debug, Eq, PartialEq)]
pub enum ProposalStatus {
Active,
Approved,
Passed,
Rejected,
Executed,
Expand All @@ -28,8 +29,15 @@ pub struct Proposal {
pub yes_votes: u32,
pub no_votes: u32,
pub status: ProposalStatus,

// Withdrawal execution parameters.
pub treasury: Address,
pub token: Address,
pub to: Address,
pub amount: i128,
}


// ── Events ───────────────────────────────────────────────────────────────────

#[contracttype]
Expand All @@ -38,8 +46,14 @@ pub struct ProposalCreatedEvent {
pub id: u64,
pub proposer: Address,
pub expires_at: u64,

pub treasury: Address,
pub token: Address,
pub to: Address,
pub amount: i128,
}


#[contracttype]
#[derive(Clone)]
pub struct VoteCastEvent {
Expand Down
Loading