From 1172f0d939df168fbb3d84faf6973ccae1d29984 Mon Sep 17 00:00:00 2001 From: Yggdrasil Date: Wed, 14 Oct 2026 04:54:35 -0400 Subject: [PATCH 1/2] fix(milestones): remove released issues from the allocations map MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit release_issue marked the issue Released and paid out, but left the entry in milestone.allocations — the map grew into a phantom ledger where 'allocated' no longer meant outstanding. Remove it on release (remaining_budget was already reduced at allocate-time, so it's untouched here). Resolves #608 --- contracts/milestones/src/lib.rs | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/contracts/milestones/src/lib.rs b/contracts/milestones/src/lib.rs index 90c53d7..837eca0 100644 --- a/contracts/milestones/src/lib.rs +++ b/contracts/milestones/src/lib.rs @@ -434,6 +434,15 @@ mod contract { } } + // #608 — remove the released allocation: the funds left the + // contract, so allocations must stop listing the issue as + // outstanding. (remaining_budget was already reduced at + // allocate-time; it correctly excludes this amount.) Otherwise the + // map drifts into a phantom ledger. + milestone.allocations.remove(issue_id); + env.storage().persistent().set(&mkey, &milestone); + extend_ttl(&env, &mkey); + // Refresh the parent milestone's Contribution sub-records so they // stay alive as individual issues are released over the milestone's // lifetime — release_issue doesn't touch contributions directly but From 4454ed68d2b5d71ce01250a76f1fba04641db35c Mon Sep 17 00:00:00 2001 From: yggdrasilworld Date: Fri, 16 Oct 2026 00:55:50 -0400 Subject: [PATCH 2/2] fix(#608): track released_total so the budget invariant survives allocation removal; rewire orphaned test imports --- contracts/escrow/src/test.rs | 10 ++++++---- contracts/milestones/src/lib.rs | 8 +++++++- contracts/milestones/src/test.rs | 9 +++++---- contracts/milestones/src/types.rs | 8 ++++++-- 4 files changed, 24 insertions(+), 11 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/lib.rs b/contracts/milestones/src/lib.rs index 837eca0..34e8ec1 100644 --- a/contracts/milestones/src/lib.rs +++ b/contracts/milestones/src/lib.rs @@ -165,6 +165,7 @@ mod contract { deadline, closed: false, allocations: Map::new(&env), + released_total: 0, contributor_count: 1, }; env.storage().persistent().set(&key, &milestone); @@ -382,7 +383,7 @@ mod contract { require_oracle(&env)?.require_auth(); let mkey = DataKey::Milestone(milestone_id); - let milestone: Milestone = env + let mut milestone: Milestone = env .storage() .persistent() .get(&mkey) @@ -439,7 +440,12 @@ mod contract { // outstanding. (remaining_budget was already reduced at // allocate-time; it correctly excludes this amount.) Otherwise the // map drifts into a phantom ledger. + let released_amount = milestone + .allocations + .get(issue_id) + .unwrap_or(0); milestone.allocations.remove(issue_id); + milestone.released_total += released_amount; env.storage().persistent().set(&mkey, &milestone); extend_ttl(&env, &mkey); diff --git a/contracts/milestones/src/test.rs b/contracts/milestones/src/test.rs index 981ea64..df51d5f 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); @@ -1208,8 +1209,8 @@ fn assert_milestone_invariants(client: &MilestonesContractClient, milestone_id: // total_budget == remaining_budget + sum(allocations) assert_eq!( milestone.total_budget, - milestone.remaining_budget + sum_allocations, - "Invariant 1 failed: total_budget != remaining_budget + sum_allocations for open milestone {}", + milestone.remaining_budget + sum_allocations + milestone.released_total, + "Invariant 1 failed: total_budget != remaining_budget + sum_allocations + released_total for open milestone {}", milestone_id ); } diff --git a/contracts/milestones/src/types.rs b/contracts/milestones/src/types.rs index 02947e8..3a8c9b8 100644 --- a/contracts/milestones/src/types.rs +++ b/contracts/milestones/src/types.rs @@ -22,9 +22,13 @@ pub struct Milestone { pub created_at: u64, pub deadline: u64, pub closed: bool, - /// issue_id -> allocated amount (0 once released and removed from the - /// "open" set is not necessary; we track release via `IssueStatus`). + /// issue_id -> allocated amount for issues still outstanding; released + /// issues are removed so the map only ever lists open obligations (#608). pub allocations: Map, + /// Sum of every amount paid out via `release_issue`. Together with + /// `remaining_budget` and `allocations` this preserves the accounting + /// identity: total_budget == remaining + outstanding + released. + pub released_total: i128, /// Number of distinct contributions recorded (`1` for a single-sponsor /// milestone); enumerate the ledger via /// `get_contribution(0..contributor_count)`.