Skip to content

allocations map retains released issues — phantom ledger drift #608

Open

Description

@yggdrasilworld

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.)

Activity

  1. ranjeet150 commented on Oct 14, 2026

    @ranjeet150

    Hello,

    I am interested in taking up "allocations map retains released issues — phantom ledger drift" and implementing a production-grade solution.

    Technical Implementation Outline:

    1. Investigate workflow execution logs, environment secrets, and failure exit triggers.
    2. Fix pipeline step sequencing, runner/container configuration, and build artifact caching.
    3. Perform local validation of CI/CD configuration to confirm deterministic behavior.
    4. Ensure automated pipeline triggers and deployment workflows run 100% green without failures.

    Please assign this issue to me. I will submit a clean, well-tested, and production-ready PR promptly!

  2. urielit1313-creator commented on Oct 14, 2026

    @urielit1313-creator

    Architectural Context & Invariant Analysis

    Regarding the lifecycle of milestone.allocations:

    1. 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).

    2. Core Invariant Checked in Test Suite:
      In contracts/milestones/src/test.rs (assert_milestone_invariants), the fuzzing harness enforces:

      // Invariant 1 (open milestones only):
      // 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_id
      );

      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.

    3. 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.

    Payout Address (Base USDC): 0xfE777c43e4B0A24fFA1527eD520d1E7f9ECED483

  3. added a commit that references this issue on Oct 16, 2026
  4. yggdrasilworld commented on Oct 16, 2026

    @yggdrasilworld
    Author

    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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions