fix(mint): async melt pending race#1071
Conversation
async_melt now durably commits the melt quote and its proofs to PENDING (via the extracted _prepare_melt) before returning the PENDING response; only the Lightning payment stays detached. Previously that commit ran inside the detached task, so a NUT-17 snapshot or a poll in that window read the stale UNPAID — which NUT-05 reuses for "payment failed" — causing clients to revert proofs the mint went on to spend. Adds a regression test asserting the persisted state is already PENDING when async_melt returns.
Replace the 30-iteration polling loop with a single deterministic sleep on fakewallet_delay_outgoing_payment, then assert the quote reached paid state after the background payment completes.
Normalize formatting on the async melt refactor and its test to satisfy the ruff-format pre-commit hook. Whitespace and line-wrapping only — no functional change.
Codecov Report✅ All modified and coverable lines are covered by tests. Additional details and impacted files@@ Coverage Diff @@
## main #1071 +/- ##
==========================================
+ Coverage 75.35% 75.45% +0.10%
==========================================
Files 111 111
Lines 12496 12500 +4
==========================================
+ Hits 9416 9432 +16
+ Misses 3080 3068 -12 ☔ View full report in Codecov by Harness. |
| # respond pending now that it is durably committed | ||
| pending_quote = melt_quote.model_copy(update={"state": MeltQuoteState.pending}) | ||
| return PostMeltQuoteResponse.from_melt_quote(pending_quote) |
There was a problem hiding this comment.
is it necessary to set the melt quote pending in async_melt if prepare_melt does it too?
There was a problem hiding this comment.
Yes it's needed as written, but only for the internal-settlement path. The two paths through
async_melt:
# Lightning path (external invoice)
_prepare_melt() → quote committed PENDING
respond PENDING → benign
background task → pay invoice, then change + invalidate proofs → PAID
# internal-settlement path (invoice belongs to this mint)
_prepare_melt() → quote committed PAID (melt_mint_settle_internally)
respond PENDING → this is where the line matters
background task → skips payment, still does change + invalidate proofs
On the internal path the quote is PAID but the melt isn't finished. Change promises
and proof invalidation still happen in the background. Always responding PENDING from async_melt keeps the same contract as before.
It's a model_copy because the background task holds the same object and
_execute_melt_payment branches on melt_quote.paid. Flipping PAID→PENDING on the object
could make it try to pay an already-settled quote using the backend.
At a minimum, code comments should be improved here.
There is an alternative refactor where this double set PENDING logic could be avoided. Don't background anything if we are settling the melt internally:
melt_quote = await self._prepare_melt(proofs=proofs, quote=quote, outputs=outputs)
if melt_quote.paid:
# settled internally — nothing async left to do; finish now and
# return the real PAID response, change included
return await self._execute_melt_payment(melt_quote, proofs, outputs)
# real Lightning payment → background it, respond PENDING
asyncio.create_task(melt_task())
return PostMeltQuoteResponse.from_melt_quote(melt_quote) # already PENDING, no copy needed
Bug Fixes
async_meltnow runs part of the process synchronously to update the melt quote record to PENDING before returning the PENDING response.This prevents a race condition where a wallet receives a
PENDINGresponse on the melt quotePOST, then reads a staleUNPAIDstate on a subsequent poll or subscription. This can cause wallets to revert proofs the mint later spends.Changes
melt()into_prepare_melt()(validation + set-pending + internal settle) and_execute_melt_payment()(Lightning payment + finalize); synchronous melt path behavior is unchanged.Testing
async_meltreturns, holding the fake backend payment in-flight to expose the window.