diff --git a/routstr/core/db.py b/routstr/core/db.py index d7679933..f31691a1 100644 --- a/routstr/core/db.py +++ b/routstr/core/db.py @@ -293,10 +293,11 @@ async def prune_dead_api_keys(session: AsyncSession, min_age_seconds: int) -> in """Delete dead parentless API keys; return the count removed. Dead = 0 balance/reservation/spend/requests, older than the grace period, - no parent, no children, no retryable invoice. Cashu rows are unlinked (not - deleted) first to keep the audit trail. + no parent, no children, no invoice that could still settle. Cashu rows are + unlinked (not deleted) first to keep the audit trail. """ - cutoff = int(time.time()) - min_age_seconds + now = int(time.time()) + cutoff = now - min_age_seconds child = aliased(ApiKey) has_children = ( @@ -304,10 +305,21 @@ async def prune_dead_api_keys(session: AsyncSession, min_age_seconds: int) -> in col(child.parent_key_hash) == col(ApiKey.hashed_key) ) ).exists() - pending_invoice = ( + # An expired invoice stays creditable for the grace window, and crediting it + # after its target key is gone strands the payment at the mint. + settleable_invoice = ( select(LightningInvoice.id) .where(col(LightningInvoice.api_key_hash) == col(ApiKey.hashed_key)) - .where(col(LightningInvoice.status).in_(("pending", "settlement_pending"))) + .where( + col(LightningInvoice.status).in_(("pending", "settlement_pending")) + | ( + (col(LightningInvoice.status) == "expired") + & ( + col(LightningInvoice.expires_at) + > now - INVOICE_EXPIRY_GRACE_SECONDS + ) + ) + ) ).exists() eligible_hashes = ( @@ -318,7 +330,7 @@ async def prune_dead_api_keys(session: AsyncSession, min_age_seconds: int) -> in .where(col(ApiKey.total_requests) == 0) .where(col(ApiKey.parent_key_hash).is_(None)) .where((col(ApiKey.created_at).is_(None)) | (col(ApiKey.created_at) < cutoff)) - .where(~pending_invoice) + .where(~settleable_invoice) .where(~has_children) ) @@ -425,6 +437,11 @@ class ModelPathRow(SQLModel, table=True): # type: ignore ) +# expires_at is our own clock, not the mint's quote expiry, so an expired row +# may still be paid at the mint and must stay creditable for this long after. +INVOICE_EXPIRY_GRACE_SECONDS = 86_400 + + class LightningInvoice(SQLModel, table=True): # type: ignore __tablename__ = "lightning_invoices" diff --git a/routstr/lightning.py b/routstr/lightning.py index 6b269847..a6dd7ec3 100644 --- a/routstr/lightning.py +++ b/routstr/lightning.py @@ -14,7 +14,13 @@ from sqlalchemy.orm.attributes import set_committed_value from sqlmodel import col, select, update from sqlmodel.ext.asyncio.session import AsyncSession -from .core.db import ApiKey, LightningInvoice, create_session, get_session +from .core.db import ( + INVOICE_EXPIRY_GRACE_SECONDS, + ApiKey, + LightningInvoice, + create_session, + get_session, +) from .core.logging import get_logger from .core.settings import settings from .mint import ( @@ -139,7 +145,15 @@ class InvoiceStatusResponse(BaseModel): expires_at: int -_RETRYABLE_INVOICE_STATUSES = ("pending", "settlement_pending") +_SETTLEABLE_INVOICE_STATUSES = ("pending", "settlement_pending", "expired") + + +def _within_settlement_window(invoice: LightningInvoice, now: int) -> bool: + if invoice.status not in _SETTLEABLE_INVOICE_STATUSES: + return False + if invoice.status != "expired": + return True + return now < invoice.expires_at + INVOICE_EXPIRY_GRACE_SECONDS class InvoiceRecoverRequest(BaseModel): @@ -337,7 +351,7 @@ async def get_invoice_status( raise HTTPException(status_code=404, detail="Invoice not found") definitively_unpaid = False - if invoice.status in _RETRYABLE_INVOICE_STATUSES: + if _within_settlement_window(invoice, int(time.time())): definitively_unpaid = await check_invoice_payment(invoice, session) await _expire_invoice_if_authoritatively_unpaid( invoice, session, definitively_unpaid @@ -375,8 +389,10 @@ async def recover_invoice( if not invoice: raise HTTPException(status_code=404, detail="Invoice not found") + # Recovery is the last remedy for a payment we never observed, so it ignores + # the grace window. Holding the bolt11 already proves the caller owns it. definitively_unpaid = False - if invoice.status in _RETRYABLE_INVOICE_STATUSES: + if invoice.status in _SETTLEABLE_INVOICE_STATUSES: definitively_unpaid = await check_invoice_payment(invoice, session) await _expire_invoice_if_authoritatively_unpaid( invoice, session, definitively_unpaid @@ -407,7 +423,7 @@ async def _claim_paid_invoice_for_settlement( """Claim an authoritative paid quote before consuming it at the mint.""" if observed_status == "settlement_pending": return True - if observed_status != "pending": + if observed_status not in ("pending", "expired"): await _reload_invoice_view(invoice, caller_session) return False @@ -416,7 +432,7 @@ async def _claim_paid_invoice_for_settlement( update(LightningInvoice) .where( col(LightningInvoice.id) == invoice.id, - col(LightningInvoice.status) == "pending", + col(LightningInvoice.status).in_(("pending", "expired")), ) .values(status="settlement_pending") .execution_options(synchronize_session=False) @@ -447,7 +463,7 @@ async def check_invoice_payment( # potentially slow mint I/O. All final DB mutations use owned, # short-lived sessions below. await session.refresh(invoice) - if invoice.status not in _RETRYABLE_INVOICE_STATUSES: + if invoice.status not in _SETTLEABLE_INVOICE_STATUSES: await session.commit() return False observed_status = invoice.status @@ -466,7 +482,7 @@ async def check_invoice_payment( if not _is_quote_not_found(error): raise logger.info( - "Invoice quote no longer exists at mint, marking expired", + "Invoice quote no longer exists at mint, treating as unpaid", extra={"invoice_id": invoice.id, "error": str(error)}, ) return True @@ -496,7 +512,7 @@ async def check_invoice_payment( .where( col(LightningInvoice.id) == settlement.id, col(LightningInvoice.status).in_( - _RETRYABLE_INVOICE_STATUSES + _SETTLEABLE_INVOICE_STATUSES ), ) .values(status="reconciliation_required") @@ -554,7 +570,7 @@ async def check_invoice_payment( .where( col(LightningInvoice.id) == invoice.id, col(LightningInvoice.status).in_( - _RETRYABLE_INVOICE_STATUSES + _SETTLEABLE_INVOICE_STATUSES ), ) .values(status="settlement_pending") @@ -703,9 +719,7 @@ async def _finalize_invoice_settlement( claim = await session.exec( # type: ignore[call-overload] update(LightningInvoice) .where(col(LightningInvoice.id) == invoice.id) - .where( - col(LightningInvoice.status).in_(_RETRYABLE_INVOICE_STATUSES) - ) + .where(col(LightningInvoice.status).in_(_SETTLEABLE_INVOICE_STATUSES)) .values(status="paid", paid_at=paid_at, api_key_hash=api_key_hash) .execution_options(synchronize_session=False) ) @@ -811,9 +825,27 @@ def _invoice_poll_due( return age // interval != prev_age // interval +async def _expire_overdue_invoices(now: int) -> int: + """A rate-limited mint must not stall expiry.""" + async with create_session() as expiry_session: + expired = await expiry_session.exec( # type: ignore[call-overload] + update(LightningInvoice) + .where( + col(LightningInvoice.status) == "pending", + col(LightningInvoice.expires_at) < now, + ) + .values(status="expired") + .execution_options(synchronize_session=False) + ) + await expiry_session.commit() + return int(expired.rowcount) + + async def _process_invoice_watch_batch(session: AsyncSession, prev_now: int) -> int: now = int(time.time()) - # Already-paid rows still owe a credit, so they poll far more eagerly. + swept = await _expire_overdue_invoices(now) + if swept: + logger.info("Expired overdue invoices", extra={"invoice_count": swept}) settling = await session.exec( select(LightningInvoice) .where(col(LightningInvoice.status) == "settlement_pending") @@ -822,10 +854,23 @@ async def _process_invoice_watch_batch(session: AsyncSession, prev_now: int) -> ) unpaid = await session.exec( select(LightningInvoice) - .where(col(LightningInvoice.status) == "pending") + .where( + col(LightningInvoice.status) == "pending", + col(LightningInvoice.expires_at) >= now, + ) .order_by(col(LightningInvoice.created_at).desc()) .limit(INVOICE_WATCH_CANDIDATE_LIMIT) ) + recoverable = await session.exec( + select(LightningInvoice) + .where( + col(LightningInvoice.status) == "expired", + col(LightningInvoice.expires_at) > now - INVOICE_EXPIRY_GRACE_SECONDS, + ) + .order_by(col(LightningInvoice.expires_at).desc()) + .limit(INVOICE_WATCH_CANDIDATE_LIMIT) + ) + # The tail only ever consumes budget the first two groups left unused. due = [ inv for inv in settling.all() @@ -836,12 +881,14 @@ async def _process_invoice_watch_batch(session: AsyncSession, prev_now: int) -> for inv in unpaid.all() if _invoice_poll_due(inv, now, prev_now, INVOICE_POLL_MAX_INTERVAL_SECONDS) ] + due += [ + inv + for inv in recoverable.all() + if _invoice_poll_due(inv, now, prev_now, INVOICE_POLL_MAX_INTERVAL_SECONDS) + ] for invoice in due[:INVOICE_WATCH_BATCH_LIMIT]: try: - definitively_unpaid = await check_invoice_payment(invoice, session) - await _expire_invoice_if_authoritatively_unpaid( - invoice, session, definitively_unpaid - ) + await check_invoice_payment(invoice, session) except Exception as e: logger.error( "Invoice watcher failed for invoice", diff --git a/tests/integration/test_lightning_settlement.py b/tests/integration/test_lightning_settlement.py index f38d6623..28803315 100644 --- a/tests/integration/test_lightning_settlement.py +++ b/tests/integration/test_lightning_settlement.py @@ -11,9 +11,13 @@ from sqlmodel.ext.asyncio.session import AsyncSession from routstr.core.db import ApiKey, LightningInvoice from routstr.lightning import ( + INVOICE_EXPIRY_GRACE_SECONDS, + INVOICE_WATCH_BATCH_LIMIT, _expire_invoice_if_authoritatively_unpaid, + _expire_overdue_invoices, _finalize_invoice_settlement, _InvoiceSettlement, + _process_invoice_watch_batch, check_invoice_payment, ) @@ -313,7 +317,7 @@ async def test_expiry_cas_cannot_overwrite_concurrent_paid_invoice( @pytest.mark.asyncio -async def test_paid_quote_worker_does_not_mint_after_expiry_claim_wins( +async def test_paid_quote_still_credits_after_expiry_claim_wins( integration_engine: AsyncEngine, patched_db_engine: None, ) -> None: @@ -339,7 +343,10 @@ async def test_paid_quote_worker_does_not_mint_after_expiry_claim_wins( observed_pending = await worker.get(LightningInvoice, invoice.id) assert observed_pending is not None - with patch("routstr.lightning.get_wallet", AsyncMock(return_value=wallet)): + with ( + patch("routstr.lightning.get_wallet", AsyncMock(return_value=wallet)), + patch("routstr.lightning._mint_invoice_quote", AsyncMock()), + ): settlement_task = asyncio.create_task( check_invoice_payment(observed_pending, worker) ) @@ -358,8 +365,256 @@ async def test_paid_quote_worker_does_not_mint_after_expiry_claim_wins( release_quote.set() assert await settlement_task is False - wallet.mint.assert_not_awaited() async with AsyncSession(integration_engine, expire_on_commit=False) as verify: stored = await verify.get(LightningInvoice, invoice.id) assert stored is not None + assert stored.status == "paid" + assert stored.paid_at is not None + + +@pytest.mark.asyncio +async def test_sweep_expires_only_overdue_pending_invoices( + integration_engine: AsyncEngine, + patched_db_engine: None, +) -> None: + now = int(time.time()) + overdue = _lightning_invoice(expires_at=now - 1) + fresh = _lightning_invoice(expires_at=now + 3600) + settling = _lightning_invoice(expires_at=now - 1, status="settlement_pending") + async with AsyncSession(integration_engine, expire_on_commit=False) as seed: + seed.add_all([overdue, fresh, settling]) + await seed.commit() + + await _expire_overdue_invoices(now) + + async with AsyncSession(integration_engine, expire_on_commit=False) as verify: + for invoice, expected in ( + (overdue, "expired"), + (fresh, "pending"), + (settling, "settlement_pending"), + ): + stored = await verify.get(LightningInvoice, invoice.id) + assert stored is not None + assert stored.status == expected + + +@pytest.mark.asyncio +async def test_watch_batch_expires_overdue_invoices_and_keeps_settling_rows( + integration_engine: AsyncEngine, + patched_db_engine: None, +) -> None: + now = int(time.time()) + overdue = _lightning_invoice(expires_at=now - 1, created_at=now) + fresh = _lightning_invoice(expires_at=now + 3600, created_at=now) + settling = _lightning_invoice( + expires_at=now - 86_400, created_at=now - 86_400, status="settlement_pending" + ) + async with AsyncSession(integration_engine, expire_on_commit=False) as seed: + seed.add_all([overdue, fresh, settling]) + await seed.commit() + + polled: list[str] = [] + + async def record(invoice: LightningInvoice, _session: AsyncSession) -> bool: + polled.append(invoice.id) + return False + + async with AsyncSession(integration_engine, expire_on_commit=False) as watcher: + with patch("routstr.lightning.check_invoice_payment", record): + await _process_invoice_watch_batch(watcher, now - 10) + + assert fresh.id in polled + assert settling.id in polled + + async with AsyncSession(integration_engine, expire_on_commit=False) as verify: + stored = await verify.get(LightningInvoice, overdue.id) + assert stored is not None assert stored.status == "expired" + + +@pytest.mark.asyncio +async def test_sweep_cannot_expire_a_row_a_worker_already_claimed( + integration_engine: AsyncEngine, + patched_db_engine: None, +) -> None: + now = int(time.time()) + invoice = _lightning_invoice(expires_at=now - 1) + async with AsyncSession(integration_engine, expire_on_commit=False) as seed: + seed.add(invoice) + await seed.commit() + + async with AsyncSession(integration_engine, expire_on_commit=False) as claimer: + claimed = await claimer.exec( # type: ignore[call-overload] + update(LightningInvoice) + .where(col(LightningInvoice.id) == invoice.id) + .values(status="settlement_pending") + ) + assert claimed.rowcount == 1 + await claimer.commit() + + assert await _expire_overdue_invoices(now) == 0 + + async with AsyncSession(integration_engine, expire_on_commit=False) as verify: + stored = await verify.get(LightningInvoice, invoice.id) + assert stored is not None + assert stored.status == "settlement_pending" + + +@pytest.mark.asyncio +async def test_expired_invoice_is_credited_exactly_once( + integration_engine: AsyncEngine, + patched_db_engine: None, +) -> None: + key_hash = uuid.uuid4().hex + invoice = _lightning_invoice( + status="expired", purpose="topup", api_key_hash=key_hash, amount_sats=100 + ) + key = ApiKey( + hashed_key=key_hash, + balance=0, + refund_currency="sat", + refund_mint_url="http://mint:3338", + ) + async with AsyncSession(integration_engine, expire_on_commit=False) as seed: + seed.add(invoice) + seed.add(key) + await seed.commit() + + settlement = _InvoiceSettlement.from_invoice(invoice) + async with AsyncSession(integration_engine, expire_on_commit=False) as first: + settled, _ = await _finalize_invoice_settlement(settlement, first, 123) + assert settled is True + + async with AsyncSession(integration_engine, expire_on_commit=False) as second: + replayed, _ = await _finalize_invoice_settlement(settlement, second, 456) + assert replayed is False + + async with AsyncSession(integration_engine, expire_on_commit=False) as verify: + stored = await verify.get(LightningInvoice, invoice.id) + assert stored is not None + assert stored.status == "paid" + assert stored.paid_at == 123 + credited = await verify.get(ApiKey, key_hash) + assert credited is not None + assert credited.balance == 100_000 + + +@pytest.mark.asyncio +async def test_expired_invoice_is_polled_only_inside_the_grace_window( + integration_engine: AsyncEngine, + patched_db_engine: None, +) -> None: + now = int(time.time()) + recoverable = _lightning_invoice( + status="expired", expires_at=now - 3600, created_at=now - 7200 + ) + abandoned = _lightning_invoice( + status="expired", + expires_at=now - INVOICE_EXPIRY_GRACE_SECONDS - 1, + created_at=now - INVOICE_EXPIRY_GRACE_SECONDS - 3600, + ) + async with AsyncSession(integration_engine, expire_on_commit=False) as seed: + seed.add_all([recoverable, abandoned]) + await seed.commit() + + polled: list[str] = [] + + async def record(invoice: LightningInvoice, _session: AsyncSession) -> bool: + polled.append(invoice.id) + return False + + async with AsyncSession(integration_engine, expire_on_commit=False) as watcher: + with patch("routstr.lightning.check_invoice_payment", record): + await _process_invoice_watch_batch(watcher, now - 601) + + assert recoverable.id in polled + assert abandoned.id not in polled + + +@pytest.mark.asyncio +async def test_watcher_credits_a_late_paid_expired_invoice( + integration_engine: AsyncEngine, + patched_db_engine: None, +) -> None: + now = int(time.time()) + key_hash = uuid.uuid4().hex + invoice = _lightning_invoice( + status="expired", + purpose="topup", + api_key_hash=key_hash, + amount_sats=100, + expires_at=now - 3600, + created_at=now - 7200, + ) + key = ApiKey( + hashed_key=key_hash, + balance=0, + refund_currency="sat", + refund_mint_url="http://mint:3338", + ) + async with AsyncSession(integration_engine, expire_on_commit=False) as seed: + seed.add_all([invoice, key]) + await seed.commit() + + async def settle(polled: LightningInvoice, _session: AsyncSession) -> bool: + settlement = _InvoiceSettlement.from_invoice(polled) + async with AsyncSession(integration_engine, expire_on_commit=False) as owned: + await _finalize_invoice_settlement(settlement, owned, now) + return False + + async with AsyncSession(integration_engine, expire_on_commit=False) as watcher: + with patch("routstr.lightning.check_invoice_payment", settle): + await _process_invoice_watch_batch(watcher, now - 601) + + async with AsyncSession(integration_engine, expire_on_commit=False) as verify: + stored = await verify.get(LightningInvoice, invoice.id) + assert stored is not None + assert stored.status == "paid" + credited = await verify.get(ApiKey, key_hash) + assert credited is not None + assert credited.balance == 100_000 + + +@pytest.mark.asyncio +async def test_recovery_tail_cannot_starve_owed_or_live_invoices( + integration_engine: AsyncEngine, + patched_db_engine: None, +) -> None: + now = int(time.time()) + settling = [ + _lightning_invoice( + status="settlement_pending", + expires_at=now - 86_400, + created_at=now - 86_400 - offset, + ) + for offset in range(50) + ] + fresh = [ + _lightning_invoice(expires_at=now + 3600, created_at=now - offset) + for offset in range(50) + ] + tail = [ + _lightning_invoice( + status="expired", + expires_at=now - 3600, + created_at=now - 7200 - offset, + ) + for offset in range(200) + ] + async with AsyncSession(integration_engine, expire_on_commit=False) as seed: + seed.add_all(settling + fresh + tail) + await seed.commit() + + polled: list[str] = [] + + async def record(invoice: LightningInvoice, _session: AsyncSession) -> bool: + polled.append(invoice.id) + return False + + async with AsyncSession(integration_engine, expire_on_commit=False) as watcher: + with patch("routstr.lightning.check_invoice_payment", record): + await _process_invoice_watch_batch(watcher, now - 601) + + assert len(polled) == INVOICE_WATCH_BATCH_LIMIT + assert {inv.id for inv in settling} <= set(polled) + assert {inv.id for inv in fresh} <= set(polled) diff --git a/tests/integration/test_prune_dead_api_keys.py b/tests/integration/test_prune_dead_api_keys.py index 85bb13b0..de2b9d44 100644 --- a/tests/integration/test_prune_dead_api_keys.py +++ b/tests/integration/test_prune_dead_api_keys.py @@ -14,6 +14,7 @@ from sqlalchemy.sql.dml import Update from sqlmodel import col, update from routstr.core.db import ( + INVOICE_EXPIRY_GRACE_SECONDS, ApiKey, CashuTransaction, LightningInvoice, @@ -126,11 +127,18 @@ async def test_parent_and_child_keys_are_not_pruned( @pytest.mark.asyncio -@pytest.mark.parametrize("status", ["pending", "settlement_pending"]) -async def test_retryable_invoice_protects_key( - patched_db_engine: None, status: str +@pytest.mark.parametrize( + ("status", "expires_at"), + [ + ("pending", NOW + 10_000), + ("settlement_pending", NOW + 10_000), + ("expired", NOW - INVOICE_EXPIRY_GRACE_SECONDS + 1_000), + ], +) +async def test_settleable_invoice_protects_key( + patched_db_engine: None, status: str, expires_at: int ) -> None: - """A key referenced by a retryable topup invoice is never pruned mid-topup.""" + """A key referenced by a still-settleable topup invoice is never pruned.""" key = _dead_key(LONG_AGO) invoice = LightningInvoice( id=f"inv_{uuid.uuid4().hex}", @@ -141,7 +149,7 @@ async def test_retryable_invoice_protects_key( status=status, api_key_hash=key.hashed_key, purpose="topup", - expires_at=NOW + 10_000, + expires_at=expires_at, ) async with create_session() as session: session.add(key) @@ -155,6 +163,35 @@ async def test_retryable_invoice_protects_key( assert await _exists(key.hashed_key) +@pytest.mark.asyncio +async def test_expired_invoice_past_grace_does_not_protect_key( + patched_db_engine: None, +) -> None: + """Once the grace window closes the invoice can no longer credit the key.""" + key = _dead_key(LONG_AGO) + invoice = LightningInvoice( + id=f"inv_{uuid.uuid4().hex}", + bolt11=f"lnbc_{uuid.uuid4().hex}", + amount_sats=10, + description="topup", + payment_hash=uuid.uuid4().hex, + status="expired", + api_key_hash=key.hashed_key, + purpose="topup", + expires_at=NOW - INVOICE_EXPIRY_GRACE_SECONDS - 1_000, + ) + async with create_session() as session: + session.add(key) + session.add(invoice) + await session.commit() + + async with create_session() as session: + pruned = await prune_dead_api_keys(session, OLD) + + assert pruned == 1 + assert not await _exists(key.hashed_key) + + @pytest.mark.asyncio async def test_paid_invoice_does_not_protect_key(patched_db_engine: None) -> None: """A settled (non-pending) invoice does not keep a dead key alive.""" diff --git a/tests/unit/test_lightning_settlement.py b/tests/unit/test_lightning_settlement.py index 958a5e19..f791a2d8 100644 --- a/tests/unit/test_lightning_settlement.py +++ b/tests/unit/test_lightning_settlement.py @@ -1,4 +1,5 @@ import asyncio +import time from collections.abc import AsyncIterator from contextlib import asynccontextmanager from types import SimpleNamespace @@ -9,6 +10,7 @@ import pytest from cashu.core.base import MintQuoteState, Proof from routstr.lightning import ( + INVOICE_EXPIRY_GRACE_SECONDS, InvoiceRecoverRequest, _invoice_settlement_locks, _is_outputs_already_signed, @@ -216,9 +218,9 @@ async def test_quote_not_found_case_insensitive() -> None: @pytest.mark.asyncio -async def test_non_pending_invoice_is_not_minted() -> None: +async def test_credited_invoice_is_not_minted() -> None: _invoice_settlement_locks.clear() - invoice = _invoice(status="expired") + invoice = _invoice(status="paid") session = AsyncMock() with patch("routstr.lightning.get_wallet", AsyncMock()) as get_wallet: @@ -464,3 +466,56 @@ async def test_concurrent_invoice_checks_finalize_once_in_process() -> None: assert invoice.status == "paid" finalize.assert_awaited_once() assert _invoice_settlement_locks == {} + + +@pytest.mark.asyncio +async def test_expired_invoice_inside_grace_still_reaches_the_mint() -> None: + now = int(time.time()) + invoice = _invoice(status="expired", expires_at=now - 3600) + session = AsyncMock() + session.get.return_value = invoice + check = AsyncMock(return_value=False) + + with patch("routstr.lightning.check_invoice_payment", check): + response = await get_invoice_status(invoice.id, session) # type: ignore[arg-type] + + check.assert_awaited_once() + assert response.status == "expired" + + +@pytest.mark.asyncio +async def test_expired_invoice_past_grace_answers_without_mint_io() -> None: + now = int(time.time()) + invoice = _invoice( + status="expired", expires_at=now - INVOICE_EXPIRY_GRACE_SECONDS - 1 + ) + session = AsyncMock() + session.get.return_value = invoice + check = AsyncMock(return_value=False) + + with patch("routstr.lightning.check_invoice_payment", check): + status_response = await get_invoice_status(invoice.id, session) # type: ignore[arg-type] + + check.assert_not_awaited() + assert status_response.status == "expired" + + +@pytest.mark.asyncio +async def test_recovery_reaches_the_mint_for_an_invoice_past_grace() -> None: + now = int(time.time()) + invoice = _invoice( + status="expired", expires_at=now - INVOICE_EXPIRY_GRACE_SECONDS - 1 + ) + session = AsyncMock() + result = Mock() + result.first.return_value = invoice + session.exec.return_value = result + check = AsyncMock(return_value=False) + + with patch("routstr.lightning.check_invoice_payment", check): + response = await recover_invoice( + InvoiceRecoverRequest(bolt11="lnbc-test"), session # type: ignore[arg-type] + ) + + check.assert_awaited_once() + assert response.status == "expired"