Merge pull request #647 from Routstr/fix/invoice-quote-not-found-v2

fix: treat 'quote not found' as definitive unpaid for invoice expiry
This commit is contained in:
9qeklajc
2026-08-04 23:19:44 +02:00
committed by GitHub
2 changed files with 119 additions and 5 deletions
+23 -5
View File
@@ -456,11 +456,20 @@ async def check_invoice_payment(
mint_url = settlement.mint_url or settings.primary_mint
wallet = await get_wallet(mint_url, "sat")
mint_status = await run_mint_operation(
lambda: wallet.get_mint_quote(settlement.payment_hash),
op_name="get_mint_quote",
mint_url=mint_url,
)
try:
mint_status = await run_mint_operation(
lambda: wallet.get_mint_quote(settlement.payment_hash),
op_name="get_mint_quote",
mint_url=mint_url,
)
except Exception as error:
if not _is_quote_not_found(error):
raise
logger.info(
"Invoice quote no longer exists at mint, marking expired",
extra={"invoice_id": invoice.id, "error": str(error)},
)
return True
if not mint_status.paid:
return getattr(mint_status, "state", None) == MintQuoteState.unpaid
payment_confirmed = True
@@ -575,6 +584,15 @@ async def check_invoice_payment(
return False
def _is_quote_not_found(error: BaseException) -> bool:
"""Check if the error indicates the mint no longer has this quote."""
message = str(error)
return bool(
re.search(r"\bquote\s+not\s+found\b", message, re.IGNORECASE)
and re.search(r"\bcode\s*:?\s*0\b", message, re.IGNORECASE)
)
def _is_outputs_already_signed(error: BaseException) -> bool:
message = str(error)
return bool(
+96
View File
@@ -149,6 +149,72 @@ async def test_invoice_mint_rejects_unrelated_concurrent_balance_growth() -> Non
await _mint_invoice_quote(wallet, invoice) # type: ignore[arg-type]
@pytest.mark.asyncio
async def test_quote_not_found_is_definitively_unpaid() -> None:
_invoice_settlement_locks.clear()
invoice = _invoice(status="pending", expires_at=0)
session = AsyncMock()
wallet = Mock(
get_mint_quote=AsyncMock(
side_effect=Exception("Mint Error: quote not found (Code: 0)")
)
)
with (
patch("routstr.lightning.get_wallet", AsyncMock(return_value=wallet)),
patch("routstr.lightning._reload_invoice_view", AsyncMock()),
):
result = await check_invoice_payment(invoice, session) # type: ignore[arg-type]
assert result is True
@pytest.mark.asyncio
@pytest.mark.parametrize(
"message",
[
"Mint Error: quote not found (Code: 10000)",
"Mint Error: quote not found (Code: 01)",
"Mint Error: quote not found (Code: 0x10)",
],
)
async def test_quote_not_found_without_exact_code_0_is_not_definitively_unpaid(
message: str,
) -> None:
_invoice_settlement_locks.clear()
invoice = _invoice(status="pending", expires_at=0)
session = AsyncMock()
wallet = Mock(get_mint_quote=AsyncMock(side_effect=Exception(message)))
with (
patch("routstr.lightning.get_wallet", AsyncMock(return_value=wallet)),
patch("routstr.lightning._reload_invoice_view", AsyncMock()),
):
result = await check_invoice_payment(invoice, session) # type: ignore[arg-type]
assert result is False
@pytest.mark.asyncio
async def test_quote_not_found_case_insensitive() -> None:
_invoice_settlement_locks.clear()
invoice = _invoice(status="pending", expires_at=0)
session = AsyncMock()
wallet = Mock(
get_mint_quote=AsyncMock(
side_effect=Exception("MINT ERROR: Quote Not Found (code 0)")
)
)
with (
patch("routstr.lightning.get_wallet", AsyncMock(return_value=wallet)),
patch("routstr.lightning._reload_invoice_view", AsyncMock()),
):
result = await check_invoice_payment(invoice, session) # type: ignore[arg-type]
assert result is True
@pytest.mark.asyncio
async def test_non_pending_invoice_is_not_minted() -> None:
_invoice_settlement_locks.clear()
@@ -194,6 +260,36 @@ async def test_ambiguous_invoice_mint_timeout_remains_recoverable() -> None:
session.commit.assert_awaited_once()
@pytest.mark.asyncio
async def test_quote_not_found_after_payment_confirmation_is_not_unpaid() -> None:
_invoice_settlement_locks.clear()
invoice = _invoice()
session = AsyncMock()
wallet = Mock(get_mint_quote=AsyncMock(return_value=Mock(paid=True)))
state_session = AsyncMock()
state_session.exec.return_value.rowcount = 1
@asynccontextmanager
async def owned_session() -> AsyncIterator[AsyncMock]:
yield state_session
with (
patch("routstr.lightning.get_wallet", AsyncMock(return_value=wallet)),
patch("routstr.lightning.create_session", owned_session),
patch(
"routstr.lightning._mint_invoice_quote",
AsyncMock(
side_effect=Exception("Mint Error: quote not found (Code: 0)")
),
),
patch("routstr.lightning._reload_invoice_view", AsyncMock()),
):
result = await check_invoice_payment(invoice, session) # type: ignore[arg-type]
assert result is False
assert invoice.status == "settlement_pending"
@pytest.mark.asyncio
async def test_quote_lookup_timeout_is_not_definitively_unpaid() -> None:
_invoice_settlement_locks.clear()