MilestonesService.close swallows a failed escrow refund in an empty catch and still marks the milestone CLOSED, stranding the funds
Los mantenedores suelen responder en 1 día
Nadie ha tomado este issue todavía.
Evaluación
- Dificultad
- 3/5
- Tiempo estimado
- 1-2 días
- Aptitud para principiantes
- 72/100
- Tipo de issue
- Error
- Claridad
- Bien especificado
- Estado de actividad
- Activo
- Stack tecnológico
- typescript
Línea de trabajo
Start in src/milestones/milestones.service.ts at MilestonesService.close and read the refund handling alongside the milestone state machine in src/milestones/milestone-state-machine.ts. Then inspect existing escrow failure handling in src/escrow/escrow.service.ts and add close specs in src/milestones/milestones.service.spec.ts. Done means a refund failure is surfaced and the milestone is not saved as CLOSED; cover that behavior with a spec.
Escrito por el modelo de indexación a partir del texto del issue.
Descripción
Location
src/milestones/milestones.service.ts:270-279 — MilestonesService.close
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 catch block is empty. err is bound and discarded, nothing is logged (MilestonesService has no Logger instance at all), nothing is recorded on the milestone, and execution proceeds straight to writing the terminal CLOSED status.
The comment justifies the swallow for two benign cases ("already refunded", "partially released"), but the catch is not narrowed to them — it absorbs every failure escrowService.refund can throw, including:
SorobanPendingError/ poll timeout — the refund transaction was submitted and may still land, or may not.EscrowService.invokeOnLockedEscrow(src/escrow/escrow.service.ts:558) recordslastFailureinescrow.metadataand rethrows;close()discards the rethrow.- a contract-level failure (insufficient balance, wrong network passphrase, unfunded treasury, bad
onChainKeyForkey). - an RPC transport failure — RPC endpoint down, timeout, 5xx.
- a Postgres failure inside
refund's ownescrowRepo.saveon line 456.
In all of those the escrow row stays LOCKED and the funds stay on-chain, while the milestone becomes CLOSED.
Why it matters
CLOSED is terminal: MILESTONE_TRANSITIONS[MilestoneStatus.CLOSED] = [] (src/milestones/milestone-state-machine.ts:21). There is no reopen, and close() itself cannot be retried, because the second call hits assertTransition(CLOSED, CLOSED) and throws InvalidMilestoneTransitionError.
So a transient RPC blip during close permanently strands the sponsor's escrowed budget:
- the milestone says the work is wound up,
- the escrow row says
LOCKEDwith funds held, - no route can refund it (
POST /escrow/:id/refundwould work, but only if an operator knows to look — nothing surfaces the failure), and - there is no log line, no
metadatamarker on the milestone, and no exception reaching the client, so the API returns200 OKwithstatus: "closed"as though the refund succeeded.
The reconciliation design the codebase already reaches for elsewhere depends on failures being recorded: EscrowService.fund (lines 149-172) and invokeOnLockedEscrow both deliberately persist a lastFailure entry in escrow.metadata precisely so "a reconciliation job can find and repair the row". close() is the one path that throws that signal away before it reaches anyone.
Reproduction / failure path
- Sponsor funds a milestone:
POST /milestones/M/fund→ escrowEstatusLOCKED,M.status = FUNDED. - Make the Soroban RPC unavailable, or point
STELLAR_RPC_URLat an endpoint that times out — i.e. any condition under whichsoroban.invoke('refund', ...)throws rather than returning a result. POST /milestones/M/close.- Observed:
200 OK, body showsstatus: "closed". Server logs contain nothing about the refund.E.statusis stillLOCKED; onlyE.metadata.lastFailure(written byinvokeOnLockedEscrow) hints at what happened, and nothing queries it. - Retry
POST /milestones/M/closeto recover →InvalidMilestoneTransitionError: Cannot transition milestone from "closed" to "closed". The milestone is unrecoverable through the API.
Suggested fix
- Do not swallow. Let a refund failure fail the request, so the milestone is not marked
CLOSEDand the operation stays retryable:
if (milestone.escrowId) {
await this.escrowService.refundRemaining(milestone.escrowId);
}
milestone.status = MilestoneStatus.CLOSED;
- If a tolerated case is genuinely needed, narrow it rather than catching everything —
EscrowService.assertLockedthrowsBadRequestExceptionfor an escrow that is alreadyREFUNDED/RELEASED, so only that specific condition should be treated as "nothing left to refund", and it should still be logged:
} catch (err) {
if (!(err instanceof BadRequestException)) throw err;
this.logger.warn(
`Milestone ${id}: escrow ${milestone.escrowId} was not refundable on close: ${(err as Error).message}`,
);
}
-
Add a
private readonly logger = new Logger(MilestonesService.name)to the service (it currently has none) so these paths are observable. -
Wrap the refund and the
status = CLOSEDwrite in a singledataSource.transactionso a post-refund DB failure cannot leave the escrow refunded and the milestone still open. -
Add specs for
close()insrc/milestones/milestones.service.spec.ts— the method has no tests today — including one asserting that whenescrowService.refundrejects, the request rejects too andmilestoneRepo.saveis never called withstatus: 'closed'.
- Lenguaje dominante
- TypeScript
- Estrellas
- 2
- Forks
- 90
- Merge medio
- 31 min
- PR fusionados (30 d)
- 20
Preparar el entorno
- Incluye un Dockerfile o un archivo de Docker Compose
- Sin plantilla de pull request
- Sin guía de contribución
Primeros pasos
- Lee el issue completo y luego la guía de contribución del proyecto.
- Comenta en el issue que vas a ocuparte — evita que dos personas hagan lo mismo.
- Haz un fork del repositorio y trabaja en una rama.
- Abre un pull request que haga referencia al número del issue.
Más de MergeFi/backend
-
bug
Dificultad 2/5 1-3 horas Aptitud para principiantes 85/100
Los mantenedores suelen responder en 1 día
-
bug security
Dificultad 2/5 1-3 horas Aptitud para principiantes 74/100
Los mantenedores suelen responder en 1 día
-
bug
Dificultad 2/5 1-3 horas Aptitud para principiantes 82/100
Los mantenedores suelen responder en 1 día
-
bug testing
Dificultad 2/5 1-3 horas Aptitud para principiantes 85/100
Los mantenedores suelen responder en 1 día
-
bug security
Dificultad 2/5 1-3 horas Aptitud para principiantes 76/100
Los mantenedores suelen responder en 1 día
Todos los issues de MergeFi/backend
Issues similares
-
ログイン中の404画面にヘッダーとナビを出すAbiertoCodex使用可 コード
Dificultad 2/5 1-3 horas Aptitud para principiantes 72/100
ryuki-moro/Nicomaru#218 ·
Los mantenedores suelen responder en 3 días
-
Dificultad 2/5 1-3 horas Aptitud para principiantes 72/100
sveltejs/svelte-eslint-parser#1008 ·
Los mantenedores suelen responder en 1 día
-
Dificultad 2/5 1-3 horas Aptitud para principiantes 78/100
silverbucket/inbox-rs#351 ·
Los mantenedores suelen responder en 1 día
-
[Bug]: The H2 series' and the H3C's fan 3 is labelled "Chamber Fan", Bambu calls it the exhaust fanAbiertoarea:ui
Dificultad 2/5 1-3 horas Aptitud para principiantes 88/100
Los mantenedores suelen responder en 1 día
-
bug front P2
Dificultad 2/5 1-3 horas Aptitud para principiantes 86/100
invoicerr-app/invoicerr#964 ·
Los mantenedores suelen responder en 1 día