From 6905d011b6ebc14ba9d45e0403fc60076aa60070 Mon Sep 17 00:00:00 2001 From: hj Date: Tue, 27 Oct 2026 05:58:10 +0800 Subject: [PATCH] fix(escrow,milestones): reject contribute at or after deadline (#568) --- contracts/escrow/src/lib.rs | 9 ++++++-- contracts/escrow/src/test.rs | 38 +++++++++++++++++++++++++++++++ contracts/milestones/src/error.rs | 7 +++--- contracts/milestones/src/lib.rs | 9 ++++++-- contracts/milestones/src/test.rs | 38 +++++++++++++++++++++++++++++++ 5 files changed, 94 insertions(+), 7 deletions(-) diff --git a/contracts/escrow/src/lib.rs b/contracts/escrow/src/lib.rs index d5c83c5b..d4ed0f18 100644 --- a/contracts/escrow/src/lib.rs +++ b/contracts/escrow/src/lib.rs @@ -377,8 +377,9 @@ mod contract { /// the token already recorded on the escrow (no `token` parameter), so /// a top-up can never silently use a different asset than the original /// funder intended. Rejects `EscrowNotFound`, `AlreadyPaid`, - /// `AlreadyRefunded`, and `TooManySponsors` once `MAX_SPONSORS` - /// contributions have already been recorded. Blocked when paused (issue #14). + /// `AlreadyRefunded`, `InvalidDeadline` once `now >= deadline` (issue #568), + /// and `TooManySponsors` once `MAX_SPONSORS` contributions have already + /// been recorded. Blocked when paused (issue #14). pub fn contribute( env: Env, issue_id: u64, @@ -408,6 +409,10 @@ mod contract { EscrowStatus::Funded => {} } + if env.ledger().timestamp() >= escrow.deadline { + return Err(Error::InvalidDeadline); + } + let max_sponsors: u32 = env .storage() .instance() diff --git a/contracts/escrow/src/test.rs b/contracts/escrow/src/test.rs index 87a014a7..a64fd776 100644 --- a/contracts/escrow/src/test.rs +++ b/contracts/escrow/src/test.rs @@ -3346,3 +3346,41 @@ fn test_set_admin_requires_current_and_new_admin_auth() { client.set_admin(&third_admin); assert_eq!(client.get_admin(), third_admin); } + +// ── Regression: issue #568 ──────────────────────────────────────────────────── + +#[test] +fn test_contribute_rejects_at_or_after_deadline() { + let env = Env::default(); + env.mock_all_auths(); + let (_, _admin, _treasury, client) = setup(&env); + + let token_admin = Address::generate(&env); + let (token_addr, asset_client, token_client) = create_token(&env, &token_admin); + let alice = Address::generate(&env); + let bob = Address::generate(&env); + asset_client.mint(&alice, &10_000i128); + asset_client.mint(&bob, &10_000i128); + + env.ledger().set_timestamp(100); + client.fund(&568u64, &alice, &token_addr, &5_000i128, &200u64, &None); + + // Strictly before deadline (now == deadline - 1): succeeds. + env.ledger().set_timestamp(199); + client.contribute(&568u64, &bob, &1_000i128); + assert_eq!(client.get_escrow(&568u64).amount, 6_000i128); + + // Exactly at deadline (now == deadline): rejected with InvalidDeadline. + env.ledger().set_timestamp(200); + let err = client.try_contribute(&568u64, &bob, &1_000i128); + assert_eq!(err, Err(Ok(Error::InvalidDeadline))); + + // After deadline + GRACE_PERIOD (permissionless refund window): rejected with InvalidDeadline. + env.ledger().set_timestamp(200 + crate::GRACE_PERIOD); + let err = client.try_contribute(&568u64, &bob, &1_000i128); + assert_eq!(err, Err(Ok(Error::InvalidDeadline))); + + assert_eq!(client.get_escrow(&568u64).amount, 6_000i128); + assert_eq!(token_client.balance(&bob), 9_000i128); +} + diff --git a/contracts/milestones/src/error.rs b/contracts/milestones/src/error.rs index 44d65b82..e50d6248 100644 --- a/contracts/milestones/src/error.rs +++ b/contracts/milestones/src/error.rs @@ -36,9 +36,10 @@ pub enum Error { /// `i128::MAX`). Returned instead of panicking under release /// `overflow-checks = true` + `panic = "abort"`. ArithmeticOverflow = 20, - /// `allocate` was called on a milestone whose deadline has already passed - /// (#489). Once past the deadline, sponsors may cancel; allowing further - /// allocations would let the admin pre-empt that cancel. + /// `allocate` or `contribute` was called on a milestone whose deadline has + /// already passed (#489, #568). Once past the deadline, sponsors may cancel; + /// allowing further allocations or contributions would let late funds enter + /// a wind-down window or let the admin pre-empt that cancel. DeadlinePassed = 21, /// The deadline supplied to `create_milestone` is in the past (issue #491). DeadlineInPast = 22, diff --git a/contracts/milestones/src/lib.rs b/contracts/milestones/src/lib.rs index 5e3d8347..0296c2ab 100644 --- a/contracts/milestones/src/lib.rs +++ b/contracts/milestones/src/lib.rs @@ -386,8 +386,9 @@ mod contract { /// authorization. Uses the token already recorded on the milestone (no /// `token` parameter), so a top-up can never silently use a different /// asset than the original funder intended. Rejects `MilestoneNotFound`, - /// `MilestoneClosed`, and `TooManySponsors` once `MAX_SPONSORS` - /// contributions have already been recorded. + /// `MilestoneClosed`, `DeadlinePassed` once `now >= deadline` (issue #568), + /// and `TooManySponsors` once `MAX_SPONSORS` contributions have already + /// been recorded. pub fn contribute( env: Env, milestone_id: u64, @@ -414,6 +415,10 @@ mod contract { if milestone.closed { return Err(Error::MilestoneClosed); } + + if env.ledger().timestamp() >= milestone.deadline { + return Err(Error::DeadlinePassed); + } let max_sponsors: u32 = env .storage() .instance() diff --git a/contracts/milestones/src/test.rs b/contracts/milestones/src/test.rs index 7ab259b6..71aa5c7b 100644 --- a/contracts/milestones/src/test.rs +++ b/contracts/milestones/src/test.rs @@ -2770,3 +2770,41 @@ fn test_create_milestone_accepts_future_deadline() { // deadline = 501 > 500: must succeed client.create_milestone(&1u64, &sponsor, &token_addr, &1_000i128, &501u64); } + +// ── Regression: issue #568 ──────────────────────────────────────────────────── + +#[test] +fn test_contribute_rejects_at_or_after_deadline() { + let env = Env::default(); + env.mock_all_auths(); + let (_admin, _treasury, client) = setup(&env); + + let token_admin = Address::generate(&env); + let (token_addr, asset_client, token_client) = create_token(&env, &token_admin); + let alice = Address::generate(&env); + let bob = Address::generate(&env); + asset_client.mint(&alice, &10_000i128); + asset_client.mint(&bob, &10_000i128); + + env.ledger().set_timestamp(100); + client.create_milestone(&568u64, &alice, &token_addr, &5_000i128, &200u64); + + // Strictly before deadline (now == deadline - 1): succeeds. + env.ledger().set_timestamp(199); + client.contribute(&568u64, &bob, &1_000i128); + assert_eq!(client.get_milestone(&568u64).total_budget, 6_000i128); + + // Exactly at deadline (now == deadline): rejected with DeadlinePassed. + env.ledger().set_timestamp(200); + let err = client.try_contribute(&568u64, &bob, &1_000i128); + assert_eq!(err, Err(Ok(Error::DeadlinePassed))); + + // After deadline + GRACE_PERIOD (permissionless cancel window): rejected with DeadlinePassed. + env.ledger().set_timestamp(200 + crate::GRACE_PERIOD); + let err = client.try_contribute(&568u64, &bob, &1_000i128); + assert_eq!(err, Err(Ok(Error::DeadlinePassed))); + + assert_eq!(client.get_milestone(&568u64).total_budget, 6_000i128); + assert_eq!(token_client.balance(&bob), 9_000i128); +} +