From d76a36a8be4677e3930b9d3df927621e49ebdabf Mon Sep 17 00:00:00 2001 From: Your Name Date: Tue, 13 Oct 2026 21:12:03 -0400 Subject: [PATCH 1/2] =?UTF-8?q?fix(milestones):=20allow=20deallocate=20whi?= =?UTF-8?q?le=20paused=20=E2=80=94=20it's=20a=20reclaim=20path?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit deallocate was pause-gated like a spend operation, but it only moves an allocated amount back toward remaining_budget (recoverable via cancel_milestone) — a sponsor-protective refund path. During a pause, allocated-but-unreleased sponsor funds were trapped, which violates the pause design's first principle ('a paused contract must still allow sponsor-protective refund/reclaim paths'). Remove the is_paused gate on deallocate, document why it's allowed while paused, and update the regression test to assert deallocate succeeds during a pause and returns the allocation to remaining_budget. Resolves #530 Note: not locally compiled — the workspace pins rustc 1.95 + wasm32v1- none (not installable here); flagging for CI verification. --- contracts/milestones/src/lib.rs | 14 ++++++++------ contracts/milestones/src/test.rs | 16 +++++++++++----- 2 files changed, 19 insertions(+), 11 deletions(-) diff --git a/contracts/milestones/src/lib.rs b/contracts/milestones/src/lib.rs index 90c53d7..ebe40ec 100644 --- a/contracts/milestones/src/lib.rs +++ b/contracts/milestones/src/lib.rs @@ -493,13 +493,15 @@ mod contract { /// Rejects if the issue is already Released (funds have left the /// contract) or not currently Allocated. /// - /// Blocked while the contract is paused, like `allocate`, since it - /// mutates the same allocation state. + /// **Allowed while paused.** Deallocate is a sponsor-protective reclaim + /// path — it only moves an allocated amount back toward + /// `remaining_budget` (recoverable via `cancel_milestone`), so it must + /// stay live during a pause per the pause design's first principle + /// ("a paused contract must still allow sponsor-protective + /// refund/reclaim paths"). Blocking it during an incident would trap + /// allocated-but-unreleased sponsor funds. Only spend-direction + /// mutations (allocate, contribute, release) are pause-gated. pub fn deallocate(env: Env, milestone_id: u64, issue_id: u64) -> Result<(), Error> { - if is_paused(&env) { - return Err(Error::ContractPaused); - } - require_admin(&env)?.require_auth(); let mkey = DataKey::Milestone(milestone_id); diff --git a/contracts/milestones/src/test.rs b/contracts/milestones/src/test.rs index 981ea64..8e0e7f4 100644 --- a/contracts/milestones/src/test.rs +++ b/contracts/milestones/src/test.rs @@ -424,7 +424,11 @@ fn test_deallocate_requires_admin_auth() { } #[test] -fn test_deallocate_blocked_while_paused() { +fn test_deallocate_allowed_while_paused() { + // Deallocate is a sponsor-protective reclaim path — it must stay live + // during a pause so allocated-but-unreleased sponsor funds aren't + // trapped by an incident pause. Only spend-direction mutations are + // pause-gated. let env = Env::default(); env.mock_all_auths(); let (_admin, _treasury, client) = setup(&env); @@ -436,12 +440,14 @@ fn test_deallocate_blocked_while_paused() { client.create_milestone(&95u64, &sponsor, &token_addr, &10_000_000_000i128, &1_000u64); client.allocate(&95u64, &950u64, &1_000_000_000i128); + // Paused — deallocate still succeeds: the allocation returns to + // remaining_budget and the issue-status record clears. client.pause(); - let result = client.try_deallocate(&95u64, &950u64); - assert_eq!(result, Err(Ok(Error::ContractPaused))); - - client.unpause(); client.deallocate(&95u64, &950u64); + + let milestone = client.get_milestone(&95u64); + assert_eq!(milestone.remaining_budget, 10_000_000_000i128); + assert!(milestone.allocations.get(950u64).is_none()); } #[test] From 1f90a4b7110609438182ddc044614e5961723ea2 Mon Sep 17 00:00:00 2001 From: yggdrasilworld Date: Fri, 16 Oct 2026 00:50:50 -0400 Subject: [PATCH 2/2] test: contract-path imports + pause/pool expectations --- contracts/escrow/src/test.rs | 10 ++++++---- contracts/milestones/src/test.rs | 10 +++++----- 2 files changed, 11 insertions(+), 9 deletions(-) diff --git a/contracts/escrow/src/test.rs b/contracts/escrow/src/test.rs index 59d1417..dfd233d 100644 --- a/contracts/escrow/src/test.rs +++ b/contracts/escrow/src/test.rs @@ -1,6 +1,7 @@ #![cfg(test)] use super::*; +use crate::contract::{EscrowContract, EscrowContractClient}; use soroban_sdk::{ testutils::{storage::Persistent as _, Address as _, Ledger}, token, vec, Address, Env, @@ -369,8 +370,8 @@ fn test_adversarial_ordering_resistance() { let admin = Address::generate(&env); let oracle = Address::generate(&env); let treasury = Address::generate(&env); - let contract_id = env.register(crate::EscrowContract, ()); - let client = crate::EscrowContractClient::new(&env, &contract_id); + let contract_id = env.register(crate::contract::EscrowContract, ()); + let client = crate::contract::EscrowContractClient::new(&env, &contract_id); // Initialize with 0% fee to simplify fraction/dust calculations client.initialize(&admin, &oracle, &treasury, &0u32, &None); @@ -435,8 +436,8 @@ fn test_large_split_distributes_dust_by_largest_remainder() { let admin = Address::generate(&env); let oracle = Address::generate(&env); let treasury = Address::generate(&env); - let contract_id = env.register(crate::EscrowContract, ()); - let client = crate::EscrowContractClient::new(&env, &contract_id); + let contract_id = env.register(crate::contract::EscrowContract, ()); + let client = crate::contract::EscrowContractClient::new(&env, &contract_id); // 0% fee so the whole total is distributable. client.initialize(&admin, &oracle, &treasury, &0u32, &None); @@ -2152,6 +2153,7 @@ fn test_set_treasury_updates_treasury_address() { #[test] fn test_set_treasury_requires_admin_auth() { let env = Env::default(); + env.mock_all_auths(); let (_, admin, _treasury, client) = setup(&env); let new_treasury = Address::generate(&env); diff --git a/contracts/milestones/src/test.rs b/contracts/milestones/src/test.rs index 8e0e7f4..4e8d9b4 100644 --- a/contracts/milestones/src/test.rs +++ b/contracts/milestones/src/test.rs @@ -1,6 +1,7 @@ #![cfg(test)] use super::*; +use crate::contract::{MilestonesContract, MilestonesContractClient}; use mergefi_common::MAX_SPONSORS; use soroban_sdk::{ testutils::{Address as _, Ledger as _}, @@ -200,8 +201,8 @@ fn test_large_split_distributes_dust_by_largest_remainder() { let admin = Address::generate(&env); let oracle = Address::generate(&env); let treasury = Address::generate(&env); - let contract_id = env.register(crate::MilestonesContract, ()); - let client = crate::MilestonesContractClient::new(&env, &contract_id); + let contract_id = env.register(crate::contract::MilestonesContract, ()); + let client = crate::contract::MilestonesContractClient::new(&env, &contract_id); // 0% fee so the whole total is distributable. client.initialize(&admin, &oracle, &treasury, &0u32, &None, &None); @@ -1033,15 +1034,14 @@ fn test_pause_blocks_commitment_paths_but_allows_cancel() { let allocate_err = client.try_allocate(&90u64, &901u64, &1_000_000_000i128); assert_eq!(allocate_err, Err(Ok(Error::ContractPaused))); - let deallocate_err = client.try_deallocate(&90u64, &900u64); - assert_eq!(deallocate_err, Err(Ok(Error::ContractPaused))); + client.deallocate(&90u64, &900u64); let recipients = vec![&env, (recipient, 10_000u32)]; let release_err = client.try_release_issue(&90u64, &900u64, &recipients); assert_eq!(release_err, Err(Ok(Error::ContractPaused))); client.cancel_milestone(&90u64); - assert_eq!(token_client.balance(&sponsor), 9_000_000_000i128); + assert_eq!(token_client.balance(&sponsor), 10_000_000_000i128); assert_eq!(token_client.balance(&treasury), 0); }