Skip to content

fix(mint): close websocket when subscribed mint quotes expire unpaid#1036

Open
b-l-u-e wants to merge 1 commit into
cashubtc:mainfrom
b-l-u-e:fix/mint-websocket-quote-expiry-disconnect
Open

fix(mint): close websocket when subscribed mint quotes expire unpaid#1036
b-l-u-e wants to merge 1 commit into
cashubtc:mainfrom
b-l-u-e:fix/mint-websocket-quote-expiry-disconnect

Conversation

@b-l-u-e

@b-l-u-e b-l-u-e commented Jun 3, 2026

Copy link
Copy Markdown
Contributor

Inspired by the quote lifecycle work in #1022 (wallet-side handling of unpaid/expired invoices), this PR hardens the mint side: websocket subscriptions to bolt11_mint_quote are closed when all subscribed quotes are unpaid and past expiry, instead of holding connections open until mint_websocket_read_timeout (default 600s).

Also fixes mint quote expiry not being persisted or returned on GET: POST returned expiry, but GET and the websocket monitor saw null because mint_quotes had no expiry column and store_mint_quote never wrote it.

Problem

  • WebSocket clients subscribing to an unpaid mint quote could keep a connection open until the read timeout (600s) after the quote TTL, even though the quote is no longer actionable.
  • expiry was computed at quote creation but not stored in mint_quotes (unlike melt_quotes), so REST GET, MintQuote.from_row, and the expiry monitor could not see it.

Changes

  • MintQuote.from_row: load expiry from DB rows (SQLite + Postgres).
  • store_mint_quote: persist expiry on insert.
  • Migration m036_add_expiry_to_mint_quotes: add expiry column to mint_quotes.
  • LedgerEventClientManager: background poll (MINT_WEBSOCKET_QUOTE_EXPIRY_CHECK_INTERVAL, default 30s) closes WS with code 1000 / reason mint quote subscription expired when every subscribed mint quote is unpaid and expiry <= now.
  • Tests: websocket monitor unit tests + assert TTL quote survives round-trip via get_mint_quote.

Verification

Local mint:

MINT_QUOTE_TTL=60
FAKEWALLET_BRR=false
MINT_WEBSOCKET_QUOTE_EXPIRY_CHECK_INTERVAL=5
export MINT_URL=http://127.0.0.1:3338
QUOTE_RESP=$(curl -sS -X POST "$MINT_URL/v1/mint/quote/bolt11" \
  -H 'Content-Type: application/json' -d '{"amount": 64, "unit": "sat"}')
QUOTE_ID=$(echo "$QUOTE_RESP" | jq -r .quote)

POST and GET both return matching expiry:

{ "quote": "k4D70Q30zKmxgnAGmxGcGLYSdRNKL71TXj8ZRttb", "state": "UNPAID", "expiry": 1780484602 }

WebSocket (python3 /tmp/ws_quote_hold.py "$QUOTE_ID"), ~60s after quote creation:

CLOSED 1000 mint quote subscription expired

Mint log:

Closing websocket: all subscribed mint quotes expired unpaid

After expiry, GET still returns state: "UNPAID" with expiry set expected: state tracks payment/mint progress; expiry is a separate deadline. Clients treat UNPAID + expiry < now as dead; POST /v1/mint/bolt11 rejects with quote expired.

Test plan

 pytest tests/mint/test_mint_websocket_protocol.py

 pytest tests/mint/test_mint.py::test_mint_quote_ttl_setting_overrides_invoice_expiry

 Manual repro above (POST/GET expiry match, WS close at TTL + poll interval)

@codecov

codecov Bot commented Jun 3, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 97.14286% with 1 line in your changes missing coverage. Please review.
✅ Project coverage is 75.17%. Comparing base (2376e47) to head (8b9af9c).
⚠️ Report is 23 commits behind head on main.
✅ All tests successful. No failed tests found.

Files with missing lines Patch % Lines
cashu/mint/events/client.py 96.55% 1 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main    #1036      +/-   ##
==========================================
+ Coverage   75.04%   75.17%   +0.13%     
==========================================
  Files         111      110       -1     
  Lines       12244    12128     -116     
==========================================
- Hits         9188     9117      -71     
+ Misses       3056     3011      -45     

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

Persist mint quote expiry in the database (migration + store path) and
poll bolt11_mint_quote subscriptions so idle websocket connections
are closed after all subscribed quotes are unpaid and past expiry.

Fixes MintQuote.from_row not loading expiry and store_mint_quote not
writing it (melt quotes already had expiry; mint quotes did not).
@b-l-u-e b-l-u-e force-pushed the fix/mint-websocket-quote-expiry-disconnect branch from 118a270 to 8b9af9c Compare June 3, 2026 14:34
Comment on lines +222 to +229
if (
not mint_quote
or not mint_quote.unpaid
or not mint_quote.expiry
or mint_quote.expiry > now
):
all_expired = False
break

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Correct me if I'm wrong, looking at the condition — a paid quote causes not mint_quote.unpaid to be true, which sets all_expired = False and breaks. So a paid quote actually keeps the connection alive. If a wallet doesn't close the connection after payment (even though nutshell's wallet does), this task won't clean it up either.

Would it make sense to treat paid quotes as terminal too, so the check becomes "close if every subscribed quote is either paid or expired+unpaid"?

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.

I think that makes sense.

afaik we don't respect the expired field for mint quotes in the mint, so I'm not sure if we should drop the connection

Comment on lines +359 to +360
# already paid (even if expiry is in the past) -> keep open
(MintQuoteState.paid, lambda: int(time.time()) - 10),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Referring to earlier suggestion about Paid being a terminal state so perhaps it should also be closed.

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.

3 participants