diff --git a/routstr/lightning.py b/routstr/lightning.py index cb3164bf..6ef85f3c 100644 --- a/routstr/lightning.py +++ b/routstr/lightning.py @@ -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( diff --git a/tests/unit/test_lightning_settlement.py b/tests/unit/test_lightning_settlement.py index 584950d6..958a5e19 100644 --- a/tests/unit/test_lightning_settlement.py +++ b/tests/unit/test_lightning_settlement.py @@ -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()