expire overdue lightning invoices on our own clock and stop polling them

This commit is contained in:
9qeklajc
2026-08-17 22:19:47 +02:00
parent 374ab83192
commit a2990c010c
5 changed files with 446 additions and 35 deletions
+23 -6
View File
@@ -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"
+66 -19
View File
@@ -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",
+258 -3
View File
@@ -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)
+42 -5
View File
@@ -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."""
+57 -2
View File
@@ -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"