diff --git a/routstr/payment/lnurl.py b/routstr/payment/lnurl.py index 26cf580d..3f07e36c 100644 --- a/routstr/payment/lnurl.py +++ b/routstr/payment/lnurl.py @@ -1,11 +1,17 @@ from __future__ import annotations +import asyncio import math from typing import TypedDict import httpx from cashu.wallet.wallet import Proof, Wallet +# The Cashu library issues POST /v1/melt/bolt11 with timeout=None, so a hung or +# very slow mint can block a melt (and any caller, e.g. the payout loop) +# indefinitely. Bound it here so callers fail instead of hanging forever. +MELT_TIMEOUT_SECONDS = 60 + try: from bech32 import bech32_decode, convertbits # type: ignore except ModuleNotFoundError: # pragma: no cover – allow runtime miss @@ -220,10 +226,18 @@ async def raw_send_to_lnurl( if amount: proofs, _ = await wallet.select_to_send(proofs, amount, set_reserved=True) - _ = await wallet.melt( - proofs=proofs, - invoice=bolt11_invoice, - fee_reserve_sat=melt_quote_resp.fee_reserve, - quote_id=melt_quote_resp.quote, - ) + try: + _ = await asyncio.wait_for( + wallet.melt( + proofs=proofs, + invoice=bolt11_invoice, + fee_reserve_sat=melt_quote_resp.fee_reserve, + quote_id=melt_quote_resp.quote, + ), + timeout=MELT_TIMEOUT_SECONDS, + ) + except asyncio.TimeoutError as e: + raise LNURLError( + f"Melt timed out after {MELT_TIMEOUT_SECONDS}s (mint unresponsive)" + ) from e return final_amount diff --git a/routstr/wallet.py b/routstr/wallet.py index 4ecb99f2..5e76655e 100644 --- a/routstr/wallet.py +++ b/routstr/wallet.py @@ -748,52 +748,71 @@ async def fetch_all_balances( async def periodic_payout() -> None: while True: await asyncio.sleep(settings.payout_interval_seconds) - print(settings.payout_interval_seconds) - if not settings.receive_ln_address: - continue try: + if not settings.receive_ln_address: + continue + + # Include the primary mint even if it is not listed in cashu_mints, + # matching fetch_all_balances(); otherwise primary-mint funds never + # auto-payout. + mint_urls: list[str] = list(settings.cashu_mints) + if settings.primary_mint and settings.primary_mint not in mint_urls: + mint_urls.append(settings.primary_mint) + async with db.create_session() as session: - for mint_url in settings.cashu_mints: + for mint_url in mint_urls: for unit in ["sat", "msat"]: - wallet = await get_wallet(mint_url, unit) - proofs = get_proofs_per_mint_and_unit( - wallet, mint_url, unit, not_reserved=True - ) - proofs = await slow_filter_spend_proofs(proofs, wallet) - await asyncio.sleep(5) - user_balance = await db.balances_for_mint_and_unit( - session, mint_url, unit - ) - if unit == "sat": - user_balance = user_balance // 1000 - proofs_balance = sum(proof.amount for proof in proofs) - available_balance = proofs_balance - user_balance - # Threshold is configured in sats; convert for msat wallets. - min_amount = ( - settings.min_payout_sat - if unit == "sat" - else settings.min_payout_sat * 1000 - ) - if available_balance > min_amount: - amount_received = await raw_send_to_lnurl( - wallet, - proofs, - settings.receive_ln_address, - unit, - amount=available_balance, + # Isolate failures per mint/unit so one slow or failing + # mint does not abort payout for every other mint/unit. + try: + wallet = await get_wallet(mint_url, unit) + proofs = get_proofs_per_mint_and_unit( + wallet, mint_url, unit, not_reserved=True ) - logger.info( - "Payout sent successfully", + proofs = await slow_filter_spend_proofs(proofs, wallet) + await asyncio.sleep(5) + user_balance = await db.balances_for_mint_and_unit( + session, mint_url, unit + ) + if unit == "sat": + user_balance = user_balance // 1000 + proofs_balance = sum(proof.amount for proof in proofs) + available_balance = proofs_balance - user_balance + # Threshold is configured in sats; convert for msat wallets. + min_amount = ( + settings.min_payout_sat + if unit == "sat" + else settings.min_payout_sat * 1000 + ) + if available_balance > min_amount: + amount_received = await raw_send_to_lnurl( + wallet, + proofs, + settings.receive_ln_address, + unit, + amount=available_balance, + ) + logger.info( + "Payout sent successfully", + extra={ + "mint_url": mint_url, + "unit": unit, + "balance": available_balance, + "amount_received": amount_received, + }, + ) + except Exception as e: + logger.error( + f"Error sending payout: {type(e).__name__}", extra={ + "error": str(e), "mint_url": mint_url, "unit": unit, - "balance": available_balance, - "amount_received": amount_received, }, ) except Exception as e: logger.error( - f"Error sending payout: {type(e).__name__}", + f"Error in periodic payout cycle: {type(e).__name__}", extra={"error": str(e)}, ) diff --git a/tests/unit/test_lnurl_melt_timeout.py b/tests/unit/test_lnurl_melt_timeout.py new file mode 100644 index 00000000..47efbcf2 --- /dev/null +++ b/tests/unit/test_lnurl_melt_timeout.py @@ -0,0 +1,74 @@ +"""raw_send_to_lnurl() must not hang forever on an unresponsive mint. + +The Cashu library issues POST /v1/melt/bolt11 with timeout=None, so a hung +mint would block the melt (and the payout loop) indefinitely. raw_send_to_lnurl +now wraps wallet.melt() in asyncio.wait_for(MELT_TIMEOUT_SECONDS) and surfaces a +timeout as LNURLError instead of hanging. +""" + +import asyncio +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +from routstr.payment import lnurl +from routstr.payment.lnurl import LNURLError, raw_send_to_lnurl + + +@pytest.mark.asyncio +async def test_raw_send_to_lnurl_times_out_on_hung_melt() -> None: + proofs = [MagicMock(amount=1000)] + + wallet = MagicMock() + wallet.melt_quote = AsyncMock(return_value=MagicMock(fee_reserve=1, quote="q")) + wallet.select_to_send = AsyncMock(return_value=(proofs, None)) + + async def _hang(**kwargs: object) -> None: + await asyncio.sleep(5) # far longer than the patched timeout + + wallet.melt = AsyncMock(side_effect=_hang) + + lnurl_data = { + "callback_url": "https://ln.tld/cb", + "min_sendable": 1_000, + "max_sendable": 100_000_000, + } + + with patch.object(lnurl, "MELT_TIMEOUT_SECONDS", 0.05), patch( + "routstr.payment.lnurl.get_lnurl_data", AsyncMock(return_value=lnurl_data) + ), patch( + "routstr.payment.lnurl.get_lnurl_invoice", + AsyncMock(return_value=("lnbc1...", {})), + ): + with pytest.raises(LNURLError, match="Melt timed out"): + await raw_send_to_lnurl(wallet, proofs, "owner@ln.tld", "sat", amount=1000) + + +@pytest.mark.asyncio +async def test_raw_send_to_lnurl_succeeds_within_timeout() -> None: + """A prompt melt still returns the net amount, unaffected by the guard.""" + proofs = [MagicMock(amount=1000)] + + wallet = MagicMock() + wallet.melt_quote = AsyncMock(return_value=MagicMock(fee_reserve=1, quote="q")) + wallet.select_to_send = AsyncMock(return_value=(proofs, None)) + wallet.melt = AsyncMock(return_value=MagicMock()) + + lnurl_data = { + "callback_url": "https://ln.tld/cb", + "min_sendable": 1_000, + "max_sendable": 100_000_000, + } + + with patch.object(lnurl, "MELT_TIMEOUT_SECONDS", 5), patch( + "routstr.payment.lnurl.get_lnurl_data", AsyncMock(return_value=lnurl_data) + ), patch( + "routstr.payment.lnurl.get_lnurl_invoice", + AsyncMock(return_value=("lnbc1...", {})), + ): + paid = await raw_send_to_lnurl( + wallet, proofs, "owner@ln.tld", "sat", amount=1000 + ) + + assert paid > 0 + wallet.melt.assert_awaited_once() diff --git a/tests/unit/test_periodic_payout.py b/tests/unit/test_periodic_payout.py new file mode 100644 index 00000000..54ebadb4 --- /dev/null +++ b/tests/unit/test_periodic_payout.py @@ -0,0 +1,154 @@ +"""Tests for periodic_payout() resilience fixes. + +Covers two regressions from the auto-payout / primary-mint audit +(docs/auto-payout-primary-mint-failure-report.md): + +1. periodic_payout() must include settings.primary_mint even when it is not + listed in settings.cashu_mints, matching fetch_all_balances(); otherwise + primary-mint funds never auto-payout. +2. A failure on one mint/unit must not abort payout for the remaining + mint/units in the same cycle (the try/except is now per mint/unit). +""" + +from collections.abc import Callable, Coroutine +from contextlib import asynccontextmanager +from typing import Any +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +from routstr.wallet import periodic_payout + +# Sentinel interval used to break the otherwise-infinite payout loop after +# exactly one full cycle. +_INTERVAL = 987 + + +class _LoopBreak(Exception): + """Raised via the patched sleep to stop periodic_payout after one cycle.""" + + +@asynccontextmanager +async def _fake_session(): # type: ignore[no-untyped-def] + yield MagicMock() + + +def _one_cycle_sleep() -> Callable[[float], Coroutine[Any, Any, None]]: + """Return an async sleep stub that lets exactly one payout cycle run. + + The top-of-loop sleep uses the sentinel interval; the second time it is + seen (start of the second cycle) we raise to break out. The inner + ``asyncio.sleep(5)`` pass-through is ignored. + """ + seen = {"interval": 0} + + async def _sleep(seconds: float) -> None: + if seconds == _INTERVAL: + seen["interval"] += 1 + if seen["interval"] >= 2: + raise _LoopBreak() + + return _sleep + + +@pytest.mark.asyncio +async def test_periodic_payout_includes_primary_mint_not_in_cashu_mints() -> None: + """primary_mint absent from cashu_mints is still paid out.""" + from routstr.core.settings import settings + + get_wallet = AsyncMock(return_value=MagicMock()) + raw_send = AsyncMock(return_value=1000) + + with patch.object(settings, "cashu_mints", []), patch.object( + settings, "primary_mint", "http://primary:3338" + ), patch.object(settings, "receive_ln_address", "owner@ln.tld"), patch.object( + settings, "payout_interval_seconds", _INTERVAL + ), patch.object(settings, "min_payout_sat", 10), patch( + "routstr.wallet.asyncio.sleep", _one_cycle_sleep() + ), patch("routstr.wallet.db.create_session", _fake_session), patch( + "routstr.wallet.get_wallet", get_wallet + ), patch( + "routstr.wallet.get_proofs_per_mint_and_unit", + MagicMock(return_value=[MagicMock(amount=100_000)]), + ), patch( + "routstr.wallet.slow_filter_spend_proofs", + AsyncMock(side_effect=lambda proofs, wallet: proofs), + ), patch( + "routstr.wallet.db.balances_for_mint_and_unit", AsyncMock(return_value=0) + ), patch("routstr.wallet.raw_send_to_lnurl", raw_send): + with pytest.raises(_LoopBreak): + await periodic_payout() + + processed = {call.args[0] for call in get_wallet.await_args_list} + assert processed == {"http://primary:3338"} + assert raw_send.await_count >= 1 + + +@pytest.mark.asyncio +async def test_periodic_payout_isolates_failing_mint() -> None: + """A failing mint does not prevent payout for the other mints.""" + from routstr.core.settings import settings + + async def _get_wallet(mint_url: str, unit: str) -> MagicMock: + if mint_url == "http://bad:3338": + raise RuntimeError("mint unreachable") + return MagicMock() + + get_wallet = AsyncMock(side_effect=_get_wallet) + raw_send = AsyncMock(return_value=1000) + + with patch.object( + settings, "cashu_mints", ["http://bad:3338", "http://good:3338"] + ), patch.object(settings, "primary_mint", "http://good:3338"), patch.object( + settings, "receive_ln_address", "owner@ln.tld" + ), patch.object(settings, "payout_interval_seconds", _INTERVAL), patch.object( + settings, "min_payout_sat", 10 + ), patch("routstr.wallet.asyncio.sleep", _one_cycle_sleep()), patch( + "routstr.wallet.db.create_session", _fake_session + ), patch("routstr.wallet.get_wallet", get_wallet), patch( + "routstr.wallet.get_proofs_per_mint_and_unit", + MagicMock(return_value=[MagicMock(amount=100_000)]), + ), patch( + "routstr.wallet.slow_filter_spend_proofs", + AsyncMock(side_effect=lambda proofs, wallet: proofs), + ), patch( + "routstr.wallet.db.balances_for_mint_and_unit", AsyncMock(return_value=0) + ), patch("routstr.wallet.raw_send_to_lnurl", raw_send): + with pytest.raises(_LoopBreak): + await periodic_payout() + + # The bad mint raised on get_wallet for both units, yet the good mint was + # still reached and paid out for both units — failures are isolated. + good_calls = [ + c for c in get_wallet.await_args_list if c.args[0] == "http://good:3338" + ] + assert len(good_calls) == 2 # sat + msat + assert raw_send.await_count == 2 # good mint paid for both units + + +@pytest.mark.asyncio +async def test_periodic_payout_handles_session_creation_failure() -> None: + """A db.create_session failure is logged and the payout loop continues.""" + from routstr.core.settings import settings + + create_session = MagicMock(side_effect=RuntimeError("db unavailable")) + logger = MagicMock() + + with patch.object(settings, "cashu_mints", ["http://mint:3338"]), patch.object( + settings, "primary_mint", "http://mint:3338" + ), patch.object(settings, "receive_ln_address", "owner@ln.tld"), patch.object( + settings, "payout_interval_seconds", _INTERVAL + ), patch( + "routstr.wallet.asyncio.sleep", _one_cycle_sleep() + ), patch( + "routstr.wallet.db.create_session", create_session + ), patch("routstr.wallet.logger", logger): + with pytest.raises(_LoopBreak): + await periodic_payout() + + create_session.assert_called_once() + logger.error.assert_called_once() + message = logger.error.call_args.args[0] + extra = logger.error.call_args.kwargs["extra"] + assert message == "Error in periodic payout cycle: RuntimeError" + assert extra == {"error": "db unavailable"}