Skip to content

fix(milestones): enforce issue_id uniqueness across milestones - #192

Open
phalap1 wants to merge 1 commit into
MergeFi:mainfrom
phalap1:fix/milestones-global-issue-claim
Open

fix(milestones): enforce issue_id uniqueness across milestones#192
phalap1 wants to merge 1 commit into
MergeFi:mainfrom
phalap1:fix/milestones-global-issue-claim

Conversation

@phalap1

@phalap1 phalap1 commented Aug 24, 2026

Copy link
Copy Markdown

Closes #51

Problem

allocate's only duplicate-allocation guard is
milestone.allocations.contains_key(issue_id), scoped to the single
Milestone record the function has loaded, and IssueStatus is keyed
(milestone_id, issue_id). Neither has any visibility into another
milestone's state, so allocate(1, 555, x) and allocate(2, 555, y) both
succeed unconditionally, and both allocations can then be independently
released via release_issue(1, 555, ...) and release_issue(2, 555, ...)
each paying out its own full amount for what is, off-chain, one piece of
merged work.

Neither release is wrong in isolation; each pays exactly what its own
milestone reserved. The defect is that the second commitment was allowed to
exist at all, so the fix belongs in allocate, not release_issue.

Fix

Add DataKey::GlobalIssueClaim(issue_id) -> milestone_id, a
contract-instance-wide registry keyed by issue_id alone — the shape
escrow::DataKey::Escrow(issue_id) already has, and the one thing this
contract's keyspace was missing.

allocate reads it after the existing per-milestone guard and rejects when
the issue is claimed by a different milestone_id, then writes the claim
once every other check has passed. Ordering the global check second is
deliberate: a repeat allocation within the same milestone still reports
IssueAlreadyAllocated, so the two collisions stay distinguishable by
error code — "you already allocated this here" (likely a duplicate request)
versus "another milestone owns this issue" (your data is wrong, a human
should look).

The comparison is claimed_by != milestone_id rather than bare key
presence, so that once deallocate lands and can clear allocations while
a claim is still being reconciled, a milestone re-claiming its own issue
still succeeds.

Both the new DataKey variant and the new Error variant are appended,
so no existing storage key encoding or error discriminant changes.

This is defense-in-depth within the trust model, not a change to it. The
oracle still decides whether work was done; what this removes is the case
where an honest backend mistake — one GitHub issue mapped onto two release
milestones by a bad import or a double-written row — silently becomes a
double payout with nothing on-chain objecting.

Acceptance criteria

  • Global issue-claim registry implemented and enforced in allocate
  • New Error variant distinguishing "already allocated in this
    milestone" (IssueAlreadyAllocated) from "already allocated in a
    different milestone" (IssueClaimedByOtherMilestone)
  • Test: test_allocate_rejects_issue_already_claimed_by_different_milestone
  • Test: interaction with deallocateN/A, deallocate has not
    landed
    (grep -rn "fn deallocate" contracts/ is empty), and the
    criterion is conditional on it. The decision it depends on is
    documented on the GlobalIssueClaim variant: deallocate releases
    the claim, so the registry never becomes a source of permanent false
    "already claimed" rejections. Happy to fold the test into whichever
    PR lands deallocate.
  • README updated with the explicit cross-milestone uniqueness guarantee
  • cargo test --workspace passes

A note on cancel_milestone

Worth flagging for whoever implements deallocate: cancel_milestone
must not release claims. It refunds only remaining_budget — the
unallocated portion — so an already-allocated issue keeps its funds in the
contract and keeps a working release_issue path (release_issue has no
closed check). Freeing its claim on cancel would allow the same issue to
be allocated in a second milestone while the first can still pay it out,
reopening this exact bug through a side door. Releasing a claim is
deallocate's job, and only for allocations it actually removes.

Verification

All four CI steps pass locally on this branch:

cargo fmt --all -- --check                             ok
cargo clippy --workspace --all-targets -- -D warnings  ok
cargo test --workspace                                 ok  (36 + 7 + 21)
cargo build --target wasm32v1-none --release           ok

mergefi-milestones goes from 20 to 21 tests; escrow (36) and
maintenance-pool (7) are unchanged.

I ran the new test against a deliberately disabled guard: with the
rejection branch commented out it fails, with it restored it passes — so it
exercises the fix rather than asserting a tautology.

`allocate`'s only duplicate guard was `milestone.allocations.contains_key`,
scoped to the single `Milestone` record it had loaded, and `IssueStatus` is
keyed `(milestone_id, issue_id)`. Neither can see another milestone's state,
so `allocate(1, 555, x)` and `allocate(2, 555, y)` both succeeded and both
could be released — the same merged work paid for twice, with nothing
on-chain objecting.

Add `DataKey::GlobalIssueClaim(issue_id) -> milestone_id`, a
contract-instance-wide registry keyed by `issue_id` alone, in the spirit of
`escrow::DataKey::Escrow(issue_id)`. `allocate` checks it after the
per-milestone guard, so a repeat allocation in the same milestone still
reports `IssueAlreadyAllocated` while a cross-milestone collision reports
the new `IssueClaimedByOtherMilestone`, and writes the claim once every
other check has passed.

The claim is `deallocate`'s to release when that lands, so a removed
allocation frees the issue for legitimate reallocation instead of leaving a
permanent false "already claimed".
@vercel

vercel Bot commented Aug 24, 2026

Copy link
Copy Markdown

@phalap1 is attempting to deploy a commit to the chonilius' projects Team on Vercel.

A member of the Team first needs to authorize it.

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

Labels

None yet

Projects

None yet

1 participant