From 55b57067077a6d929fd303dcb48ca6a1a4e4f5b3 Mon Sep 17 00:00:00 2001 From: Quantara CI Date: Sun, 28 Jun 2026 04:07:39 +0100 Subject: [PATCH] feat: add proposal expiry mechanism and finalize_expired_proposal Add finalize_expired_proposal to proposals contract to transition Pending proposals to Expired after expiry. Update group_treasury to panic when attempting to vote on an Expired proposal. --- contracts/contracts/group_treasury/src/lib.rs | 2 +- .../contracts/group_treasury/src/storage.rs | 1 + contracts/contracts/proposals/src/lib.rs | 24 +++++++++++- contracts/contracts/proposals/src/storage.rs | 7 ++++ contracts/contracts/proposals/src/test.rs | 37 +++++++++++++++++++ 5 files changed, 69 insertions(+), 2 deletions(-) diff --git a/contracts/contracts/group_treasury/src/lib.rs b/contracts/contracts/group_treasury/src/lib.rs index 402b053..4e0be7e 100644 --- a/contracts/contracts/group_treasury/src/lib.rs +++ b/contracts/contracts/group_treasury/src/lib.rs @@ -332,7 +332,7 @@ impl GroupTreasuryContract { if proposal.status != ProposalStatus::Active { panic!("proposal is not pending"); } - if env.ledger().timestamp() >= proposal.expires_at { + if proposal.status == ProposalStatus::Expired || env.ledger().timestamp() >= proposal.expires_at { panic!("proposal expired"); } if env diff --git a/contracts/contracts/group_treasury/src/storage.rs b/contracts/contracts/group_treasury/src/storage.rs index 671fb7c..f9bd57c 100644 --- a/contracts/contracts/group_treasury/src/storage.rs +++ b/contracts/contracts/group_treasury/src/storage.rs @@ -18,6 +18,7 @@ pub enum ProposalStatus { Passed, Rejected, Executed, + Expired, } #[contracttype] diff --git a/contracts/contracts/proposals/src/lib.rs b/contracts/contracts/proposals/src/lib.rs index 28a3b67..872b6b2 100644 --- a/contracts/contracts/proposals/src/lib.rs +++ b/contracts/contracts/proposals/src/lib.rs @@ -25,7 +25,7 @@ use soroban_sdk::{contract, contractimpl, symbol_short, Address, Env, String, Sy pub use storage::{ DataKey, Proposal, ProposalCreatedEvent, ProposalExecutedEvent, ProposalFinalizedEvent, - ProposalStatus, VoteCastEvent, + ProposalStatus, VoteCastEvent, ProposalExpiredEvent, }; // ── Contract ───────────────────────────────────────────────────────────────── @@ -191,6 +191,28 @@ impl ProposalsContract { new_status } + pub fn finalize_expired_proposal(env: Env, proposal_id: u64) { + let mut proposal = Self::load_proposal(&env, proposal_id); + + if !matches!(proposal.status, ProposalStatus::Active) { + panic!("proposal not Pending"); + } + let now = env.ledger().timestamp(); + if now <= proposal.expires_at { + panic!("proposal not expired"); + } + + proposal.status = ProposalStatus::Expired; + env.storage() + .instance() + .set(&DataKey::Proposal(proposal_id), &proposal); + + env.events().publish( + (Symbol::new(&env, "proposal_expired"),), + ProposalExpiredEvent { id: proposal_id }, + ); + } + /// Execute a Passed proposal. Refuses unless `status == Passed`. /// MVP execution simply flips the status to `Executed` and emits /// the event; downstream wiring (treasury withdrawals, etc.) can diff --git a/contracts/contracts/proposals/src/storage.rs b/contracts/contracts/proposals/src/storage.rs index e63f3b9..f0ca25b 100644 --- a/contracts/contracts/proposals/src/storage.rs +++ b/contracts/contracts/proposals/src/storage.rs @@ -16,6 +16,7 @@ pub enum ProposalStatus { Passed, Rejected, Executed, + Expired, } #[contracttype] @@ -71,6 +72,12 @@ pub struct ProposalFinalizedEvent { pub no_votes: u32, } +#[contracttype] +#[derive(Clone)] +pub struct ProposalExpiredEvent { + pub id: u64, +} + #[contracttype] #[derive(Clone)] pub struct ProposalExecutedEvent { diff --git a/contracts/contracts/proposals/src/test.rs b/contracts/contracts/proposals/src/test.rs index 9df74cf..26cdb0c 100644 --- a/contracts/contracts/proposals/src/test.rs +++ b/contracts/contracts/proposals/src/test.rs @@ -313,6 +313,43 @@ fn create_with_past_expiry_panics() { ); } +#[test] +#[should_panic(expected = "proposal not expired")] +fn finalize_expired_before_expiry_panics() { + let env = Env::default(); + let (client, _padmin, alice, _bob, _carol, _treasury, _tadmin, m, token_id) = setup(&env); + + let id = create_proposal_in(&env, &client, &alice, 1_000, &m, &token_id, &alice, 1); + client.finalize_expired_proposal(&id); +} + +#[test] +#[should_panic(expected = "proposal not Pending")] +fn finalize_expired_when_passed_panics() { + let env = Env::default(); + let (client, _padmin, alice, _bob, _carol, _treasury, _tadmin, m, token_id) = setup(&env); + + let id = create_proposal_in(&env, &client, &alice, 500, &m, &token_id, &alice, 1); + + advance_time(&env, 501); + client.finalize_proposal(&id); // becomes Passed + client.finalize_expired_proposal(&id); +} + +#[test] +fn finalize_expired_success() { + let env = Env::default(); + let (client, _padmin, alice, _bob, _carol, _treasury, _tadmin, m, token_id) = setup(&env); + + let id = create_proposal_in(&env, &client, &alice, 500, &m, &token_id, &alice, 1); + + advance_time(&env, 501); + client.finalize_expired_proposal(&id); + + let proposal = client.get_proposal(&id); + assert_eq!(proposal.status, ProposalStatus::Expired); +} + // ───────────────────────────────────────────────────────────────────────────── // execute_withdraw acceptance criteria