mirror of
https://github.com/Routstr/routstr-core.git
synced 2026-08-05 09:34:36 +00:00
Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c7e5fba910 | ||
|
|
93df935446 | ||
|
|
27f53948ca | ||
|
|
48c69857ed | ||
|
|
c971862ac6 |
+23
-5
@@ -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(
|
||||
|
||||
@@ -996,6 +996,7 @@ async def _request_mint_with_fallback(
|
||||
lambda: wallet.request_mint(amount),
|
||||
op_name=op_name,
|
||||
mint_url=mint_url,
|
||||
retry_timeouts=False,
|
||||
retry_on_rate_limit=False,
|
||||
)
|
||||
logger.info(
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -2636,6 +2636,43 @@ async def test_wallet_fallback_on_429_no_in_place_retry() -> None:
|
||||
mock_sleep.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_wallet_fallback_on_timeout_no_in_place_retry() -> None:
|
||||
"""A timeout from one destination must immediately try the next mint."""
|
||||
from routstr.core.settings import settings
|
||||
from routstr.wallet import _request_mint_with_fallback
|
||||
|
||||
primary = "http://primary:3338"
|
||||
secondary = "http://secondary:3338"
|
||||
primary_wallet = Mock(
|
||||
request_mint=AsyncMock(side_effect=httpx.TimeoutException("timed out"))
|
||||
)
|
||||
quote = Mock(quote="q_secondary", request="lnbc1secondary")
|
||||
secondary_wallet = Mock(request_mint=AsyncMock(return_value=quote))
|
||||
wallets = {primary: primary_wallet, secondary: secondary_wallet}
|
||||
|
||||
with (
|
||||
patch.object(settings, "primary_mint", primary),
|
||||
patch.object(settings, "cashu_mints", [primary, secondary]),
|
||||
patch.object(settings, "mint_retry_max_attempts", 3),
|
||||
patch.object(settings, "mint_max_concurrency", 0),
|
||||
patch.object(settings, "mint_operation_timeout_seconds", 0),
|
||||
patch("routstr.mint.asyncio.sleep", AsyncMock()) as sleep,
|
||||
patch(
|
||||
"routstr.wallet.get_wallet",
|
||||
AsyncMock(side_effect=lambda mint, *args, **kwargs: wallets[mint]),
|
||||
),
|
||||
):
|
||||
_, mint_url, _ = await _request_mint_with_fallback(
|
||||
1000, op_name="test_timeout_fallback"
|
||||
)
|
||||
|
||||
assert mint_url == secondary
|
||||
primary_wallet.request_mint.assert_awaited_once_with(1000)
|
||||
secondary_wallet.request_mint.assert_awaited_once_with(1000)
|
||||
sleep.assert_not_awaited()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_wallet_fallback_skips_mint_during_cooldown() -> None:
|
||||
from routstr.core.settings import settings
|
||||
|
||||
Reference in New Issue
Block a user