diff --git a/routstr/core/admin.py b/routstr/core/admin.py index 2da3baf1..2a4c7fb7 100644 --- a/routstr/core/admin.py +++ b/routstr/core/admin.py @@ -1,4 +1,3 @@ -import asyncio import json import re import secrets @@ -1368,50 +1367,39 @@ async def initiate_provider_topup( else {} ) - last_status_code = 500 - last_error_detail: object = "Failed to create top-up invoice" + # This POST creates a Cashu mint quote upstream. Without an + # idempotency key, retrying a timeout or 5xx can create a + # second invoice while abandoning the first. + resp = await client.post( + f"{clean_url}/v1/balance/lightning/invoice", + json=request_json, + headers=headers, + ) - # Some upstream Routstr nodes fail the first invoice request after warm-up - # and succeed immediately on retry. Retry once here so the UI stays single-click. - for attempt in range(2): - resp = await client.post( - f"{clean_url}/v1/balance/lightning/invoice", - json=request_json, - headers=headers, - ) - - if resp.status_code == 200: - data = resp.json() - return { - "ok": True, - "topup_data": { - "payment_request": data.get("bolt11"), - "invoice_id": data.get("invoice_id"), - "status": "pending", - }, - } - - logger.error( - f"Upstream topup request failed: {resp.text}", - extra={ - "provider_id": provider_id, - "attempt": attempt + 1, - "status_code": resp.status_code, + if resp.status_code == 200: + data = resp.json() + return { + "ok": True, + "topup_data": { + "payment_request": data.get("bolt11"), + "invoice_id": data.get("invoice_id"), + "status": "pending", }, - ) - try: - last_error_detail = resp.json() - except Exception: - last_error_detail = resp.text - last_status_code = resp.status_code - - if resp.status_code < 500 or attempt == 1: - break - - await asyncio.sleep(0.2) + } + logger.error( + f"Upstream topup request failed: {resp.text}", + extra={ + "provider_id": provider_id, + "status_code": resp.status_code, + }, + ) + try: + error_detail: object = resp.json() + except Exception: + error_detail = resp.text raise HTTPException( - status_code=last_status_code, detail=last_error_detail + status_code=resp.status_code, detail=error_detail ) upstream_instance = _instantiate_provider(provider) diff --git a/routstr/core/db.py b/routstr/core/db.py index 2ab5ef25..76539c45 100644 --- a/routstr/core/db.py +++ b/routstr/core/db.py @@ -37,6 +37,14 @@ def create_db_engine(database_url: str = DATABASE_URL) -> AsyncEngine: is_memory_sqlite = is_sqlite and url.database in {None, "", ":memory:"} pool_pre_ping = settings.database_pool_pre_ping or not is_sqlite options: dict[str, int | float | bool] = {"pool_pre_ping": pool_pre_ping} + connect_args: dict[str, object] = {} + if is_sqlite and not is_memory_sqlite: + # SQLite's default busy_timeout is only 5s, and aiosqlite does not set + # one of its own. Without this, concurrent payment-settlement writes + # across the pooled engine wait just 5s, then raise + # sqlite3.OperationalError: database is locked. Give writers a real + # chance to acquire the single SQLite write lock. + connect_args["timeout"] = settings.database_busy_timeout if not is_memory_sqlite: options.update( pool_size=settings.database_pool_size, @@ -51,9 +59,12 @@ def create_db_engine(database_url: str = DATABASE_URL) -> AsyncEngine: "database_url_backend": backend, "in_memory_sqlite": is_memory_sqlite, **options, + "connect_args": connect_args, }, ) - created_engine = create_async_engine(database_url, echo=False, **options) + created_engine = create_async_engine( + database_url, echo=False, connect_args=connect_args, **options + ) hold_warn_seconds = settings.database_pool_hold_warn_seconds def record_pool_checkout( diff --git a/routstr/core/settings.py b/routstr/core/settings.py index 53e71a74..8f4caed8 100644 --- a/routstr/core/settings.py +++ b/routstr/core/settings.py @@ -148,6 +148,13 @@ class Settings(BaseSettings): database_pool_hold_warn_seconds: float = Field( default=10.0, gt=0, env="DATABASE_POOL_HOLD_WARN_SECONDS" ) + # SQLite busy_timeout (seconds): how long a writer waits on a locked DB + # before raising "database is locked". Defaults to SQLite's 5s in stock + # aiosqlite; raise it so concurrent payment-settlement writes can queue + # instead of erroring. Referenced only by create_db_engine for SQLite. + database_busy_timeout: float = Field( + default=30.0, gt=0, env="DATABASE_BUSY_TIMEOUT" + ) # Logging log_level: str = Field(default="INFO", env="LOG_LEVEL") @@ -209,6 +216,7 @@ ENV_ONLY_FIELDS = frozenset( "database_pool_recycle", "database_pool_pre_ping", "database_pool_hold_warn_seconds", + "database_busy_timeout", } ) diff --git a/routstr/lightning.py b/routstr/lightning.py index df48756c..358e8515 100644 --- a/routstr/lightning.py +++ b/routstr/lightning.py @@ -215,11 +215,19 @@ async def _request_mint_with_fallback( ) continue try: - wallet = await get_wallet(mint_url, "sat", retry_on_rate_limit=False) + wallet = await get_wallet( + mint_url, + "sat", + retry_on_rate_limit=False, + load_proofs=False, + ) quote = await run_mint_operation( lambda: wallet.request_mint(amount_sats), op_name="request_mint_invoice", mint_url=mint_url, + # Response loss may leave a valid quote at the mint. Creating a + # second quote is not a safe retry without an idempotency key. + retry_timeouts=False, retry_on_rate_limit=False, ) return quote.request, quote.quote, mint_url @@ -471,7 +479,9 @@ async def check_invoice_payment( await session.commit() mint_url = settlement.mint_url or settings.primary_mint - wallet = await get_wallet(mint_url, "sat") + # Quote status is remote state and does not inspect local proofs. + # _mint_invoice_quote loads proofs exactly when settlement needs them. + wallet = await get_wallet(mint_url, "sat", load_proofs=False) try: mint_status = await run_mint_operation( lambda: wallet.get_mint_quote(settlement.payment_hash), diff --git a/routstr/payment/lnurl.py b/routstr/payment/lnurl.py index c9f48253..814f83fa 100644 --- a/routstr/payment/lnurl.py +++ b/routstr/payment/lnurl.py @@ -107,6 +107,18 @@ async def _fetch_lnurl_json( return data +def _contains_mint_transport_error(error: BaseException) -> bool: + """Detect transport failures wrapped by the Cashu wallet implementation.""" + seen: set[int] = set() + current: BaseException | None = error + while current is not None and id(current) not in seen: + seen.add(id(current)) + if isinstance(current, MINT_TRANSPORT_EXCEPTIONS): + return True + current = current.__cause__ or current.__context__ + return False + + async def decode_lnurl(lnurl: str) -> str: """Decode LNURL to get the actual URL. @@ -293,6 +305,8 @@ async def raw_send_to_lnurl( lambda: wallet.melt_quote(invoice=bolt11_invoice), op_name="lnurl_melt_quote", mint_url=str(wallet.url), + # Creating another quote after response loss only abandons the first. + retry_timeouts=False, ) # The invoice comes from the LNURL service, so its amount is untrusted. The @@ -331,8 +345,21 @@ async def raw_send_to_lnurl( # reserved as though a Lightning payment could still settle. await wallet.set_reserved_for_send(proofs, reserved=False) raise - if not isinstance(error, MINT_TRANSPORT_EXCEPTIONS): + if not _contains_mint_transport_error(error): raise + # Cashu 0.20 clears melt reservations before wrapping transport errors + # in a plain Exception. Restore the durable melt association before + # asking for quote state so these proofs cannot be spent again while + # the Lightning outcome is unknown. + try: + await wallet.set_reserved_for_melt( + proofs, reserved=True, quote_id=melt_quote_resp.quote + ) + except Exception as reservation_error: + raise MeltOutcomeAmbiguousError( + "Melt outcome is ambiguous and its proof reservation could not " + "be restored; proofs must not be retried" + ) from reservation_error melt_response = None melt_error: BaseException | None = error else: @@ -356,6 +383,10 @@ async def raw_send_to_lnurl( if quote is not None and quote.state == MeltQuoteState.paid: return final_amount + if quote is not None and quote.state == MeltQuoteState.unpaid: + # get_melt_quote() has authoritatively released the melt reservation; + # callers may restore their debit and retry with a new payment plan. + raise LNURLError("Cashu mint confirmed that the melt was unpaid") from melt_error state = getattr(getattr(quote, "state", None), "value", "unknown") raise MeltOutcomeAmbiguousError( diff --git a/routstr/upstream/base.py b/routstr/upstream/base.py index 4080be0f..595ec670 100644 --- a/routstr/upstream/base.py +++ b/routstr/upstream/base.py @@ -3628,54 +3628,32 @@ class BaseUpstreamProvider: extra={"amount": amount, "unit": unit, "mint": mint}, ) - max_retries = 3 - last_exception = None - refund_token = None - - for attempt in range(max_retries): - try: - refund_token = await send_token(amount, unit=unit, mint_url=mint) - break - except Exception as e: - last_exception = e - if attempt < max_retries - 1: - logger.warning( - "Refund token creation failed, retrying", - extra={ - "error": str(e), - "error_type": type(e).__name__, - "attempt": attempt + 1, - "max_retries": max_retries, - "amount": amount, - "unit": unit, - "mint": mint, - }, - ) - else: - logger.error( - "Failed to create refund token after all retries", - extra={ - "error": str(e), - "error_type": type(e).__name__, - "attempt": attempt + 1, - "max_retries": max_retries, - "amount": amount, - "unit": unit, - "mint": mint, - }, - ) - - if refund_token is None: + try: + # send_token may perform an irreversible Cashu swap to make exact + # denominations. A blanket retry after response loss can dispatch + # a second swap, so this call is intentionally single-attempt. + refund_token = await send_token(amount, unit=unit, mint_url=mint) + except Exception as error: + logger.error( + "Failed to create refund token", + extra={ + "error": str(error), + "error_type": type(error).__name__, + "amount": amount, + "unit": unit, + "mint": mint, + }, + ) raise HTTPException( status_code=401, detail={ "error": { - "message": f"failed to create refund after {max_retries} attempts: {str(last_exception)}", + "message": f"failed to create refund: {error}", "type": "invalid_request_error", "code": "send_token_failed", } }, - ) + ) from error logger.info( "Refund token created successfully", @@ -3683,7 +3661,6 @@ class BaseUpstreamProvider: "amount": amount, "unit": unit, "mint": mint, - "attempt": attempt + 1, "token_preview": refund_token[:20] + "..." if len(refund_token) > 20 else refund_token, diff --git a/routstr/wallet.py b/routstr/wallet.py index c6cb1238..3814ac42 100644 --- a/routstr/wallet.py +++ b/routstr/wallet.py @@ -14,6 +14,7 @@ from typing import AsyncGenerator, TypedDict import httpx from cashu.core.base import MeltQuote, MeltQuoteState, MintQuote, Proof, Token from cashu.core.mint_info import MintInfo as _CashuMintInfo +from cashu.wallet.crud import get_keysets as get_cashu_keysets from cashu.wallet.helpers import deserialize_token_from_string from cashu.wallet.wallet import Wallet as _CashuWallet from pydantic_core import PydanticUndefined @@ -121,6 +122,12 @@ def _mints_to_inspect() -> list[str]: return mint_urls +_WALLET_PROOF_RELOAD_MIN_INTERVAL_SECONDS = 30 +_WALLET_MINT_RELOAD_MIN_INTERVAL_SECONDS = 300 +_mint_metadata_last_load: dict[str, float] = {} +_mint_metadata_load_locks: dict[str, asyncio.Lock] = {} + + class Wallet(_CashuWallet): """Cashu adapter that preserves HTTP 429 for Routstr's mint policy.""" @@ -141,11 +148,37 @@ class Wallet(_CashuWallet): _CashuWallet.raise_on_error_request(resp) async def load_mint( - self, keyset_id: str = "", force_old_keysets: bool = False + self, + keyset_id: str = "", + force_old_keysets: bool = False, + *, + force_refresh: bool = False, ) -> None: - await self.load_mint_keysets(force_old_keysets) - await self.activate_keyset(keyset_id) - await self.load_mint_info(reload=True) + """Load metadata once per mint URL, then hydrate unit wallets locally.""" + mint_url = str(self.url) + lock = _mint_metadata_load_locks.setdefault(mint_url, asyncio.Lock()) + async with lock: + now = time.monotonic() + last = _mint_metadata_last_load.get(mint_url) + if ( + not force_refresh + and last is not None + and now - last < _WALLET_MINT_RELOAD_MIN_INTERVAL_SECONDS + ): + try: + await self.load_keysets_from_db() + await self.activate_keyset(keyset_id) + await self.load_mint_info(reload=False) + return + except Exception: + # An empty/stale local cache is not authoritative. Fall + # through to one remote refresh under the per-mint lock. + pass + + await self.load_mint_keysets(force_old_keysets) + await self.activate_keyset(keyset_id) + await self.load_mint_info(reload=True) + _mint_metadata_last_load[mint_url] = time.monotonic() class MintConnectionError(Exception): @@ -1048,6 +1081,7 @@ async def _request_mint_with_fallback( mint_url, settings.primary_mint_unit, retry_on_rate_limit=False, + load_proofs=False, ) quote = await run_mint_operation( lambda: wallet.request_mint(amount), @@ -1179,6 +1213,7 @@ async def _calculate_swap_amount( lambda: token_wallet.melt_quote(dummy_mint_quote.request), op_name="swap_fee_est_melt_quote", mint_url=token_mint_url, + retry_timeouts=False, ) fee_reserve = dummy_melt_quote.fee_reserve @@ -1416,6 +1451,7 @@ async def swap_to_trusted_mint( lambda: token_wallet.melt_quote(mint_quote.request), op_name="swap_melt_quote", mint_url=token_obj.mint, + retry_timeouts=False, ) except Exception as error: if is_mint_connection_error(error): @@ -1796,13 +1832,13 @@ async def _credit_balance_locked( _wallets: dict[str, Wallet] = {} +# Proofs are local SQLite state and need a short refresh window because another +# worker process can reserve or spend them. Mint metadata is remote, shared by +# every operation on a wallet, and changes far less often; refreshing it on the +# proof cadence caused repeated /keysets, /keys, and /info requests. _wallet_last_load: dict[str, float] = {} +_wallet_last_mint_load: dict[str, float] = {} _wallet_load_locks: dict[str, asyncio.Lock] = {} -# Minimum seconds between full mint info + proof reloads for the same -# wallet. Prevents redundant mint API calls when get_wallet(load=True) -# is called rapidly by multiple background tasks (balance fetch, payout, -# auto-topup all hitting get_wallet within the same cycle). -_WALLOAD_RELOAD_MIN_INTERVAL_SECONDS = 30 async def get_wallet( @@ -1811,8 +1847,16 @@ async def get_wallet( load: bool = True, retry_on_rate_limit: bool = True, force_reload: bool = False, + load_proofs: bool = True, ) -> Wallet: - global _wallets, _wallet_last_load, _wallet_load_locks + """Return a cached wallet, refreshing remote and local state independently. + + ``load=False`` remains the fully offline path. Quote-only callers can use + ``load_proofs=False``: mint metadata is initialized when needed, but local + proofs are not re-read when the operation cannot spend or inspect them. + ``force_reload`` still refreshes every requested layer immediately. + """ + global _wallets, _wallet_last_load, _wallet_last_mint_load, _wallet_load_locks id = f"{mint_url}_{unit}" lock = _wallet_load_locks.setdefault(id, asyncio.Lock()) async with lock: @@ -1821,25 +1865,40 @@ async def get_wallet( if load: now = time.monotonic() - last = _wallet_last_load.get(id) + last_mint_load = _wallet_last_mint_load.get(id) if ( force_reload - or last is None - or now - last >= _WALLOAD_RELOAD_MIN_INTERVAL_SECONDS + or last_mint_load is None + or now - last_mint_load >= _WALLET_MINT_RELOAD_MIN_INTERVAL_SECONDS ): await run_mint_operation( - lambda: _wallets[id].load_mint(), + lambda: ( + _wallets[id].load_mint(force_refresh=True) + if force_reload + else _wallets[id].load_mint() + ), op_name="load_mint", mint_url=mint_url, retry_on_rate_limit=retry_on_rate_limit, ) - await run_mint_operation( - lambda: _wallets[id].load_proofs(reload=True), - op_name="load_proofs", - mint_url=mint_url, - retry_on_rate_limit=retry_on_rate_limit, - ) - _wallet_last_load[id] = time.monotonic() + _wallet_last_mint_load[id] = time.monotonic() + + if load_proofs: + last_proof_load = _wallet_last_load.get(id) + if ( + force_reload + or last_proof_load is None + or now - last_proof_load + >= _WALLET_PROOF_RELOAD_MIN_INTERVAL_SECONDS + ): + # cashu's load_proofs is local SQLite I/O, not a mint call. + await run_mint_operation( + lambda: _wallets[id].load_proofs(reload=True), + op_name="load_proofs", + mint_url=mint_url, + retry_on_rate_limit=retry_on_rate_limit, + ) + _wallet_last_load[id] = time.monotonic() return _wallets[id] @@ -1912,13 +1971,16 @@ async def _get_supported_mint_units(mint_url: str) -> list[str]: if cached is not None and now < cached[0]: return cached[1] - wallet = await get_wallet(mint_url, settings.primary_mint_unit, load=False) - keysets = await run_mint_operation( - lambda: wallet._get_keysets(), - op_name="get_mint_keysets", - mint_url=mint_url, + # One full remote metadata load populates Cashu's shared SQLite keyset + # cache. Discover all advertised units from that cache instead of issuing a + # separate /keysets request before each unit wallet loads. + wallet = await get_wallet( + mint_url, + settings.primary_mint_unit, retry_on_rate_limit=False, + load_proofs=False, ) + keysets = await get_cashu_keysets(mint_url=wallet.url, db=wallet.db) units: list[str] = [] for keyset in keysets: if not keyset.active or keyset.unit is None: diff --git a/tests/integration/test_provider_management.py b/tests/integration/test_provider_management.py index b7db0c6c..de9a3112 100644 --- a/tests/integration/test_provider_management.py +++ b/tests/integration/test_provider_management.py @@ -686,7 +686,7 @@ async def test_no_database_changes_during_provider_operations( @pytest.mark.integration @pytest.mark.asyncio -async def test_admin_routstr_topup_retries_transient_upstream_failure( +async def test_admin_routstr_topup_does_not_duplicate_invoice_on_upstream_failure( integration_client: AsyncClient, integration_session: Any, ) -> None: @@ -739,16 +739,7 @@ async def test_admin_routstr_topup_retries_transient_upstream_failure( assert json["api_key"] == "sk-upstream-test" assert headers["Authorization"] == "Bearer sk-upstream-test" - if self.calls == 1: - return MockResponse(500, {"detail": "warmup failure"}) - - return MockResponse( - 200, - { - "bolt11": "lnbc1testinvoice", - "invoice_id": "invoice-123", - }, - ) + return MockResponse(500, {"detail": "ambiguous upstream failure"}) mock_client = MockAsyncClient() @@ -759,11 +750,7 @@ async def test_admin_routstr_topup_retries_transient_upstream_failure( json={"amount": 10}, ) - assert response.status_code == 200 - data = response.json() - assert data["ok"] is True - assert data["topup_data"]["payment_request"] == "lnbc1testinvoice" - assert data["topup_data"]["invoice_id"] == "invoice-123" - assert mock_client.calls == 2 + assert response.status_code == 500 + assert mock_client.calls == 1 finally: admin_sessions.pop(admin_token, None) diff --git a/tests/unit/test_fetch_all_balances.py b/tests/unit/test_fetch_all_balances.py index 89813cac..12e57fd4 100644 --- a/tests/unit/test_fetch_all_balances.py +++ b/tests/unit/test_fetch_all_balances.py @@ -136,19 +136,20 @@ async def test_supported_mint_units_come_from_active_keysets() -> None: msat = MagicMock(active=False, unit="msat") usd = MagicMock(active=True) usd.unit.name = "usd" - wallet = MagicMock() - wallet._get_keysets = AsyncMock(return_value=[usd, msat, sat]) + wallet = MagicMock(url="http://mint:3338", db=MagicMock()) + get_keysets = AsyncMock(return_value=[usd, msat, sat]) with ( patch.object(settings, "primary_mint_unit", "sat"), patch("routstr.wallet.get_wallet", AsyncMock(return_value=wallet)), + patch("routstr.wallet.get_cashu_keysets", get_keysets), ): units = await _get_supported_mint_units("http://mint:3338") cached_units = await _get_supported_mint_units("http://mint:3338") assert units == ["sat", "usd"] assert cached_units == units - wallet._get_keysets.assert_awaited_once() + get_keysets.assert_awaited_once_with(mint_url=wallet.url, db=wallet.db) @pytest.mark.asyncio diff --git a/tests/unit/test_lnurl_melt_timeout.py b/tests/unit/test_lnurl_melt_timeout.py index dc35ab4c..dd0dfb02 100644 --- a/tests/unit/test_lnurl_melt_timeout.py +++ b/tests/unit/test_lnurl_melt_timeout.py @@ -11,6 +11,7 @@ from cashu.core.base import MeltQuoteState from routstr.core.settings import settings from routstr.mint import MintCooldownError, MintRateGuard from routstr.payment.lnurl import ( + LNURLError, MeltOutcomeAmbiguousError, raw_send_to_lnurl, ) @@ -32,7 +33,10 @@ def _wallet() -> tuple[MagicMock, list[MagicMock]]: wallet.melt_quote = AsyncMock( return_value=MagicMock(fee_reserve=1, quote="q", amount=QUOTE_AMOUNT_SAT) ) + wallet.melt = AsyncMock() wallet.select_to_send = AsyncMock(return_value=(proofs, None)) + wallet.set_reserved_for_melt = AsyncMock() + wallet.set_reserved_for_send = AsyncMock() return wallet, proofs @@ -50,7 +54,7 @@ def _lnurl_patches() -> tuple[Any, Any]: @pytest.mark.asyncio -async def test_raw_send_to_lnurl_timeout_keeps_unpaid_outcome_ambiguous() -> None: +async def test_raw_send_to_lnurl_timeout_reconciled_unpaid_is_retry_safe() -> None: wallet, proofs = _wallet() async def _hang(**kwargs: object) -> None: @@ -67,12 +71,65 @@ async def test_raw_send_to_lnurl_timeout_keeps_unpaid_outcome_ambiguous() -> Non patch.object(settings, "mint_retry_max_attempts", 0), data_patch, invoice_patch, - pytest.raises(MeltOutcomeAmbiguousError, match="outcome is ambiguous"), + pytest.raises(LNURLError, match="confirmed that the melt was unpaid") as raised, ): await raw_send_to_lnurl(wallet, proofs, "owner@ln.tld", "sat", amount=1000) + assert not isinstance(raised.value, MeltOutcomeAmbiguousError) wallet.get_melt_quote.assert_awaited_once_with("q") - wallet.set_reserved_for_melt.assert_not_called() + wallet.set_reserved_for_melt.assert_awaited_once_with( + proofs, reserved=True, quote_id="q" + ) + + +@pytest.mark.asyncio +async def test_raw_send_to_lnurl_wrapped_transport_error_is_reconciled_once() -> None: + wallet, proofs = _wallet() + + async def _wrapped_transport_error(**kwargs: object) -> None: + try: + raise httpx.ReadTimeout("response lost") + except httpx.ReadTimeout as transport_error: + raise Exception("could not pay invoice") from transport_error + + wallet.melt = AsyncMock(side_effect=_wrapped_transport_error) + wallet.get_melt_quote = AsyncMock( + return_value=MagicMock(state=MeltQuoteState.unpaid) + ) + data_patch, invoice_patch = _lnurl_patches() + + with ( + patch.object(settings, "mint_retry_max_attempts", 3), + data_patch, + invoice_patch, + pytest.raises(LNURLError, match="confirmed that the melt was unpaid") as raised, + ): + await raw_send_to_lnurl(wallet, proofs, "owner@ln.tld", "sat", amount=1000) + + assert not isinstance(raised.value, MeltOutcomeAmbiguousError) + wallet.melt.assert_awaited_once() + wallet.get_melt_quote.assert_awaited_once_with("q") + wallet.set_reserved_for_melt.assert_awaited_once_with( + proofs, reserved=True, quote_id="q" + ) + + +@pytest.mark.asyncio +async def test_raw_send_to_lnurl_does_not_retry_melt_quote_timeout() -> None: + wallet, proofs = _wallet() + wallet.melt_quote = AsyncMock(side_effect=httpx.ReadTimeout("response lost")) + data_patch, invoice_patch = _lnurl_patches() + + with ( + patch.object(settings, "mint_retry_max_attempts", 3), + data_patch, + invoice_patch, + pytest.raises(httpx.TimeoutException), + ): + await raw_send_to_lnurl(wallet, proofs, "owner@ln.tld", "sat", amount=1000) + + wallet.melt_quote.assert_awaited_once() + wallet.melt.assert_not_awaited() @pytest.mark.asyncio @@ -98,6 +155,9 @@ async def test_raw_send_to_lnurl_timeout_reconciled_paid_is_success() -> None: assert paid > 0 wallet.get_melt_quote.assert_awaited_once_with("q") + wallet.set_reserved_for_melt.assert_awaited_once_with( + proofs, reserved=True, quote_id="q" + ) @pytest.mark.asyncio diff --git a/tests/unit/test_mint_fallback_trust.py b/tests/unit/test_mint_fallback_trust.py index f7809458..5f2a2ab6 100644 --- a/tests/unit/test_mint_fallback_trust.py +++ b/tests/unit/test_mint_fallback_trust.py @@ -1,7 +1,8 @@ """Persisted mint preferences must not bypass the configured trusted set.""" -from unittest.mock import AsyncMock, patch +from unittest.mock import AsyncMock, MagicMock, patch +import httpx import pytest from routstr.core.settings import settings @@ -31,6 +32,23 @@ async def test_untrusted_allowed_mints_fall_back_to_trusted_set() -> None: assert attempted == [TRUSTED] +async def test_mint_quote_timeout_is_not_retried() -> None: + wallet = MagicMock() + wallet.request_mint = AsyncMock(side_effect=httpx.ReadTimeout("response lost")) + + with ( + patch.object(settings, "primary_mint", TRUSTED), + patch.object(settings, "cashu_mints", [TRUSTED]), + patch.object(settings, "mint_retry_max_attempts", 3), + patch("routstr.lightning.get_wallet", AsyncMock(return_value=wallet)), + patch("routstr.lightning.mint_cooldown_remaining", return_value=0.0), + pytest.raises(Exception), + ): + await _request_mint_with_fallback(10) + + wallet.request_mint.assert_awaited_once_with(10) + + async def test_trusted_allowed_mints_are_used_verbatim() -> None: attempted: list[str] = [] diff --git a/tests/unit/test_refund_no_retry.py b/tests/unit/test_refund_no_retry.py new file mode 100644 index 00000000..8c6b4b88 --- /dev/null +++ b/tests/unit/test_refund_no_retry.py @@ -0,0 +1,26 @@ +"""Refund token issuance must not repeat an ambiguous Cashu swap.""" + +from unittest.mock import AsyncMock, patch + +import httpx +import pytest +from fastapi import HTTPException + +from routstr.upstream.base import BaseUpstreamProvider + + +@pytest.mark.asyncio +async def test_send_refund_does_not_retry_ambiguous_token_creation() -> None: + provider = object.__new__(BaseUpstreamProvider) + send_token = AsyncMock(side_effect=httpx.ReadTimeout("swap response lost")) + + with ( + patch("routstr.upstream.base.send_token", send_token), + pytest.raises(HTTPException) as raised, + ): + await provider.send_refund(10, "sat", mint="https://mint.test") + + assert raised.value.status_code == 401 + send_token.assert_awaited_once_with( + 10, unit="sat", mint_url="https://mint.test" + ) diff --git a/tests/unit/test_wallet.py b/tests/unit/test_wallet.py index 7c5de1fe..8b738f89 100644 --- a/tests/unit/test_wallet.py +++ b/tests/unit/test_wallet.py @@ -40,13 +40,19 @@ def isolate_wallet_runtime_state() -> Generator[None, None, None]: wallet_module._MintRateGuard._guards.clear() wallet_module._wallets.clear() wallet_module._wallet_last_load.clear() + wallet_module._wallet_last_mint_load.clear() wallet_module._wallet_load_locks.clear() + wallet_module._mint_metadata_last_load.clear() + wallet_module._mint_metadata_load_locks.clear() yield settings.mint_max_concurrency = original_concurrency wallet_module._MintRateGuard._guards.clear() wallet_module._wallets.clear() wallet_module._wallet_last_load.clear() + wallet_module._wallet_last_mint_load.clear() wallet_module._wallet_load_locks.clear() + wallet_module._mint_metadata_last_load.clear() + wallet_module._mint_metadata_load_locks.clear() @pytest.mark.asyncio @@ -66,6 +72,63 @@ async def test_get_balance() -> None: assert balance == 50000 +@pytest.mark.asyncio +async def test_wallet_metadata_is_reused_across_units() -> None: + from routstr.wallet import Wallet + + sat_wallet = MagicMock(url="http://mint:3338") + sat_wallet.load_mint_keysets = AsyncMock() + sat_wallet.activate_keyset = AsyncMock() + sat_wallet.load_mint_info = AsyncMock() + sat_wallet.load_keysets_from_db = AsyncMock() + + msat_wallet = MagicMock(url="http://mint:3338") + msat_wallet.load_mint_keysets = AsyncMock() + msat_wallet.activate_keyset = AsyncMock() + msat_wallet.load_mint_info = AsyncMock() + msat_wallet.load_keysets_from_db = AsyncMock() + + with patch("routstr.wallet.time.monotonic", return_value=1000.0): + await Wallet.load_mint(sat_wallet) + await Wallet.load_mint(msat_wallet) + + sat_wallet.load_mint_keysets.assert_awaited_once_with(False) + sat_wallet.load_mint_info.assert_awaited_once_with(reload=True) + msat_wallet.load_mint_keysets.assert_not_awaited() + msat_wallet.load_keysets_from_db.assert_awaited_once_with() + msat_wallet.load_mint_info.assert_awaited_once_with(reload=False) + + +@pytest.mark.asyncio +async def test_get_wallet_refreshes_local_proofs_without_reloading_mint() -> None: + from routstr import wallet as wallet_module + from routstr.wallet import get_wallet + + mock_wallet = Mock(load_mint=AsyncMock(), load_proofs=AsyncMock()) + with ( + patch("routstr.wallet.Wallet.with_db", AsyncMock(return_value=mock_wallet)), + patch("routstr.wallet.time.monotonic", return_value=1000.0), + ): + await get_wallet("http://mint:3338", "sat") + wallet_module._wallet_last_load["http://mint:3338_sat"] = 900.0 + await get_wallet("http://mint:3338", "sat") + + assert mock_wallet.load_mint.await_count == 1 + assert mock_wallet.load_proofs.await_count == 2 + + +@pytest.mark.asyncio +async def test_get_wallet_quote_only_skips_proof_reload() -> None: + from routstr.wallet import get_wallet + + mock_wallet = Mock(load_mint=AsyncMock(), load_proofs=AsyncMock()) + with patch("routstr.wallet.Wallet.with_db", AsyncMock(return_value=mock_wallet)): + await get_wallet("http://mint:3338", "sat", load_proofs=False) + + mock_wallet.load_mint.assert_awaited_once_with() + mock_wallet.load_proofs.assert_not_awaited() + + @pytest.mark.asyncio async def test_get_wallet_force_reload_bypasses_reload_interval() -> None: from routstr.wallet import get_wallet @@ -2970,6 +3033,7 @@ async def test_load_mint_propagates_rate_limit() -> None: from routstr.wallet import Wallet wallet = Wallet.__new__(Wallet) + wallet.url = "https://rate-limited-mint.example" error = MintRateLimitedError( "Cashu mint rate limited", request=httpx.Request("GET", "https://mint.example/v1/keysets"), @@ -2987,6 +3051,7 @@ async def test_load_mint_propagates_connection_error() -> None: from routstr.wallet import Wallet wallet = Wallet.__new__(Wallet) + wallet.url = "https://unavailable-mint.example" error = httpx.ConnectError("mint unavailable") with ( patch.object(wallet, "load_mint_keysets", new=AsyncMock(side_effect=error)), @@ -3002,6 +3067,7 @@ async def test_load_mint_runs_keysets_activation_and_info() -> None: from routstr.wallet import Wallet wallet = Wallet.__new__(Wallet) + wallet.url = "https://mint-load.example" with ( patch.object(wallet, "load_mint_keysets", new=AsyncMock()) as load_keysets, patch.object(wallet, "activate_keyset", new=AsyncMock()) as activate,