You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
allocations map retains released issues — phantom ledger drift #608
Bug: release_issue never removes the released allocation — allocations grows into a phantom ledger
release_issue marks the issue Released and transfers the funds out, but it does not remove the entry from milestone.allocations. Over a milestone's lifetime the map accumulates every released allocation alongside live ones — allocations stops meaning "outstanding allocations" and becomes a monotonically-growing phantom ledger.
Why it matters
get_milestone exposes allocations — an indexer/UI summing it to show "outstanding payouts" over-states the real obligation (released funds still appear allocated)
The invariant contract_balance == remaining_budget + sum(allocations) silently breaks — released amounts stay counted
allocations grows unboundedly on a long-lived milestone (allocate adds, deallocate removes, release leaves the entry)
The Released status-check prevents double-pay, so this isn't a direct fund-loss — it's an accounting-integrity bug: the allocation-map's semantics are inconsistent after release.
Suggested fix
Remove the allocation on release — milestone.allocations.remove(issue_id) alongside IssueStatus::Released — so the map reflects outstanding obligations. (If the entries are intentionally kept as history, a separate released flag/counter would preserve the audit-trail without corrupting the outstanding-allocations view.)
Intentional Design Decision in types.rs:
The Milestone struct definition in contracts/milestones/src/types.rs explicitly documents why entries are retained:
/// issue_id -> allocated amount (0 once released and removed from the/// "open" set is not necessary; we track release via `IssueStatus`).pub allocations:Map<u64, i128>,
The contract uses a two-level state machine: milestone.allocations stores the historical allocated amount per issue, while individual issue lifecycle is tracked under separate persistent storage keys: DataKey::IssueStatus(milestone_id, issue_id) (Allocated vs Released).
Core Invariant Checked in Test Suite:
In contracts/milestones/src/test.rs (assert_milestone_invariants), the fuzzing harness enforces:
Because total_budget represents the cumulative lifetime funds contributed to the milestone (which never decreases upon payout), total_budget == remaining_budget + sum(allocations) holds precisely because allocations retains all issue allocation amounts.
Considerations for Modification:
If the maintainers wish to change allocations to represent only active/unreleased allocations:
A total_released: i128 (or lifetime_released: i128) field would need to be added to Milestone (or tracked via storage) to preserve the budget conservation equation: $$\text{total_budget} = \text{remaining_budget} + \sum(\text{active_allocations}) + \text{total_released}$$
assert_milestone_invariants in the fuzzing suite would need to be updated accordingly.
allocate would need an explicit guard checking env.storage().persistent().get(&DataKey::IssueStatus(milestone_id, issue_id)) so that an issue whose allocation was removed upon release cannot be re-allocated.
Awaiting maintainer guidance on whether they prefer maintaining the documented types.rs design or refactoring Milestone with an explicit total_released accounting field.
This is implemented on #637 — opened before this issue was filed and covering exactly the described defect:
milestone.allocations.remove(issue_id) on release (the map reflects outstanding obligations again)
A released_total counter added to milestone state — the audit-trail alternative your "if intentionally kept as history" note suggests, chosen so contract_balance == remaining_budget + released_total + sum(allocations) keeps holding as an exact identity (the fuzz invariant now asserts it)
get_milestone exposes both views: outstanding allocations + released_total
75/75 contract tests green on that head, including a release-then-deallocate ordering case
Linking so reviewers can compare against the reported semantics.
Activity
ranjeet150 commentedon Oct 14, 2026
Hello,
I am interested in taking up "allocations map retains released issues — phantom ledger drift" and implementing a production-grade solution.
Technical Implementation Outline:
Please assign this issue to me. I will submit a clean, well-tested, and production-ready PR promptly!
urielit1313-creator commentedon Oct 14, 2026
Architectural Context & Invariant Analysis
Regarding the lifecycle of
milestone.allocations:Intentional Design Decision in
types.rs:The
Milestonestruct definition incontracts/milestones/src/types.rsexplicitly documents why entries are retained:The contract uses a two-level state machine:
milestone.allocationsstores the historical allocated amount per issue, while individual issue lifecycle is tracked under separate persistent storage keys:DataKey::IssueStatus(milestone_id, issue_id)(AllocatedvsReleased).Core Invariant Checked in Test Suite:
In
contracts/milestones/src/test.rs(assert_milestone_invariants), the fuzzing harness enforces:Because
total_budgetrepresents the cumulative lifetime funds contributed to the milestone (which never decreases upon payout),total_budget == remaining_budget + sum(allocations)holds precisely becauseallocationsretains all issue allocation amounts.Considerations for Modification:
If the maintainers wish to change
allocationsto represent only active/unreleased allocations:total_released: i128(orlifetime_released: i128) field would need to be added toMilestone(or tracked via storage) to preserve the budget conservation equation:assert_milestone_invariantsin the fuzzing suite would need to be updated accordingly.allocatewould need an explicit guard checkingenv.storage().persistent().get(&DataKey::IssueStatus(milestone_id, issue_id))so that an issue whose allocation was removed upon release cannot be re-allocated.Awaiting maintainer guidance on whether they prefer maintaining the documented
types.rsdesign or refactoringMilestonewith an explicittotal_releasedaccounting field.Payout Address (Base USDC):
0xfE777c43e4B0A24fFA1527eD520d1E7f9ECED483fix(MergeFi#608): track released_total so the budget invariant surviv…
yggdrasilworld commentedon Oct 16, 2026
This is implemented on #637 — opened before this issue was filed and covering exactly the described defect:
milestone.allocations.remove(issue_id)on release (the map reflects outstanding obligations again)released_totalcounter added to milestone state — the audit-trail alternative your "if intentionally kept as history" note suggests, chosen socontract_balance == remaining_budget + released_total + sum(allocations)keeps holding as an exact identity (the fuzz invariant now asserts it)get_milestoneexposes both views: outstanding allocations + released_totalLinking so reviewers can compare against the reported semantics.