POST /milestones/:id/close refunds the whole escrow on a partially-distributed milestone, double-counting budget already paid to contributors
Maintainers usually reply within 1 day
Nobody has claimed this yet.
Assessment
- Difficulty
- 4/5
- Estimated time
- 3-5 days
- Newbie friendliness
- 55/100
- Issue type
- Bug
- Clarity
- Clearly specified
- Activity status
- Active
- Tech stack
- typescript
Research direction
Read src/milestones/milestones.service.ts around MilestonesService.close and src/escrow/escrow.service.ts around releasePartial and refund to trace how payments and refunds are recorded. Start by checking the escrow contract's refund and release entry points, then review src/milestones/milestones.service.spec.ts. Done means closing a partially distributed milestone returns only its unspent budget, records consistent ledger state, and has coverage for the listed close scenarios.
Written by the indexing model from the issue text.
Description
Location
src/milestones/milestones.service.ts:262 — MilestonesService.close
async close(id: string, userId: string): Promise<Milestone> {
const milestone = await this.findOne(id);
...
assertTransition(milestone.status, MilestoneStatus.CLOSED);
if (milestone.escrowId) {
try {
await this.escrowService.refund(milestone.escrowId);
} catch (err) {
// Escrow may already be refunded or partially released
}
}
milestone.status = MilestoneStatus.CLOSED;
return this.milestoneRepo.save(milestone);
}
Problem
The route is documented as "Close a milestone and refund unspent budget" (src/milestones/milestones.controller.ts:148), but EscrowService.refund has no concept of "unspent". It refunds the full original locked amount:
src/escrow/escrow.service.ts:440-457
async refund(escrowId: string): Promise<Escrow> {
const escrow = await this.getOrThrow(escrowId);
this.assertLocked(escrow);
const result = await this.invokeOnLockedEscrow(escrow, 'refund', () =>
this.soroban.invoke('refund', [u64(this.onChainKeyFor(escrow))], ...));
escrow.status = EscrowStatus.REFUNDED;
...
}
There is no amount argument, no reading of milestone.distributed, and no subtraction of the Payment rows already written against that escrow by releasePartial.
Crucially, a partially-released milestone escrow is still LOCKED: releasePartial only flips it to RELEASED once the cumulative payouts reach escrow.amount (src/escrow/escrow.service.ts:375-380). So assertLocked passes and the refund proceeds on exactly the escrows where it is wrong.
The milestone state machine permits this path explicitly: IN_PROGRESS -> CLOSED (src/milestones/milestone-state-machine.ts:16-19). IN_PROGRESS is the status resolveIssue writes after every partial payout (milestones.service.ts:223-230), so "partially distributed" is the normal state a milestone is closed from.
Why it matters
For a milestone funded at 1000 with 600 already released to contributors across three resolved issues:
- The ledger has
milestone.distributed = 600andPaymentrows summing to 600. close()asks the contract to refund the escrow keyed byonChainKeyFor(escrow)— the full 1000, not the unspent 400.- The escrow row flips to
REFUNDED.
Consequences:
- Accounting drift. The system now records 600 paid out and a full-amount refund against the same escrow.
Escrow.amountis 1000 whilesum(payments) + refunded = 1600. Every sponsor-dashboard and analytics aggregate that reads either side is now wrong, and the two sides no longer reconcile. - On a contract that tracks its own balance, the refund either over-pays the sponsor by 600 (value created from nothing) or fails outright on insufficient balance — and the failure is swallowed by the empty
catchon line 273-275, so the milestone is still markedCLOSEDand the remaining 400 is stranded on-chain with the escrow row claimingREFUNDED. - In dry-run mode (
SorobanClientServicesucceeding unconditionally), the bad state is written to Postgres with no on-chain pushback at all, which is how this reaches staging unnoticed.
Reproduction / failure path
POST /milestoneswith{ "repositoryId": "...", "budget": "1000.0000000", "asset": "USDC" }→ milestoneM, statusOPEN.POST /milestones/M/issues/<i1>,.../<i2>,.../<i3>— attach three issues.POST /milestones/M/fundwith{ "funderAddress": "G...SPONSOR" }→ escrowE,amount = 1000, statusLOCKED, milestoneFUNDED.POST /milestones/M/issues/<i1>/resolvewith{ "recipientAddress": "G...C1" }→ releases ~333.3333333, milestoneIN_PROGRESS,distributed ≈ 333.- Repeat for
<i2>→distributed ≈ 666. EscrowEis stillLOCKED(666 < 1000). POST /milestones/M/close.escrowService.refund(E)is invoked with no amount: the contract is asked to return the entire 1000 toG...SPONSOR, andE.statusbecomesREFUNDEDwithrefundTxHashset — despite 666 having already left the escrow.
Suggested fix
close() must refund only escrow.amount - sum(payments for that escrow), and must not use the full-amount refund entrypoint on a partially-released escrow:
- Add a partial-refund path to
EscrowService— e.g.refundRemaining(escrowId, manager?)— that runs underwithLockedEscrow(asreleasePartialalready does), computes the remaining stroops from thePaymentrows exactly asreleasePartialdoes (escrow.service.ts:324-339), and invokes the contract's refund with that amount. If the deployed escrow contract has no amount-taking refund entrypoint, the remainder should be returned to the funder via the existingrelease/release_partialpath with the funder as recipient, so the ledger records a realPaymentrow for it. - Have
close()call that instead ofrefund(), inside a single transaction with thestatus = CLOSEDwrite, so the two cannot diverge. - Short-circuit when the remaining amount is zero (every issue resolved,
distributed === budget): close the milestone without any on-chain call. - Add specs to
src/milestones/milestones.service.spec.ts— which currently has noclose()tests at all — covering: close fromOPENwith no escrow, close fromFUNDEDwith nothing distributed (full refund is correct here), and close fromIN_PROGRESSwith partial distribution (asserting the refunded amount equalsbudget - distributed).
- Dominant language
- TypeScript
- Stars
- 2
- Forks
- 90
- Avg merge
- 31m
- Merged PRs (30d)
- 20
Getting set up
- Ships a Dockerfile or Docker Compose file
- No pull request template
- No contributing guide
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- Open a pull request that references the issue number.
More from MergeFi/backend
-
bug
Difficulty 2/5 1-3 hours Newbie friendliness 85/100
Maintainers usually reply within 1 day
-
bug security
Difficulty 2/5 1-3 hours Newbie friendliness 74/100
Maintainers usually reply within 1 day
-
bug
Difficulty 2/5 1-3 hours Newbie friendliness 82/100
Maintainers usually reply within 1 day
-
bug testing
Difficulty 2/5 1-3 hours Newbie friendliness 85/100
Maintainers usually reply within 1 day
-
bug security
Difficulty 2/5 1-3 hours Newbie friendliness 76/100
Maintainers usually reply within 1 day
Similar issues
-
Difficulty 2/5 1-3 hours Newbie friendliness 76/100
zapcooking/frontend#946 ·
Maintainers usually reply within 1 day
-
enhancement good first issue
Difficulty 2/5 1-3 hours Newbie friendliness 88/100
WiliamRosa/cosmos-semantic-platform#40 ·
Maintainers usually reply within 1 day
-
Difficulty 2/5 1-3 hours Newbie friendliness 77/100
liberation-data/drivine#733 · 1 comment ·
Maintainers usually reply within 1 day
-
bug
Difficulty 2/5 1-3 hours Newbie friendliness 64/100
makecindy/cindy#10062 · 1 comment ·
Maintainers usually reply within 1 day
-
Difficulty 2/5 1-3 hours Newbie friendliness 78/100
Maintainers usually reply within 1 day