mirror of
https://github.com/Routstr/routstr-core.git
synced 2026-10-05 12:28:22 +00:00
expire overdue lightning invoices on our own clock and stop polling them
This commit is contained in:
+23
-6
@@ -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
@@ -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",
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user