Skip to content

fix(mint): async melt pending race#1071

Open
orangeshyguy21 wants to merge 3 commits into
cashubtc:mainfrom
orangeshyguy21:fix/async-melt-pending-race
Open

fix(mint): async melt pending race#1071
orangeshyguy21 wants to merge 3 commits into
cashubtc:mainfrom
orangeshyguy21:fix/async-melt-pending-race

Conversation

@orangeshyguy21

@orangeshyguy21 orangeshyguy21 commented Jun 30, 2026

Copy link
Copy Markdown

Bug Fixes

  • async_melt now 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 PENDING response on the melt quote POST, then reads a stale UNPAID state on a subsequent poll or subscription. This can cause wallets to revert proofs the mint later spends.

Changes

  • Melt refactor — split the monolithic melt() into _prepare_melt() (validation + set-pending + internal settle) and _execute_melt_payment() (Lightning payment + finalize); synchronous melt path behavior is unchanged.

Testing

  • Pending-commit regression test — asserts the persisted melt-quote state is already PENDING the moment async_melt returns, holding the fake backend payment in-flight to expose the window.

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

codecov Bot commented Jun 30, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 75.45%. Comparing base (e834c7c) to head (54aad2a).
✅ All tests successful. No failed tests found.

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.
📢 Have feedback on the report? Share it here.

Comment thread cashu/mint/ledger.py
Comment on lines +1039 to +1041
# 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)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

is it necessary to set the melt quote pending in async_melt if prepare_melt does it too?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

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

Labels

None yet

Projects

Status: Backlog

Development

Successfully merging this pull request may close these issues.

2 participants