From 65abcbce9258e716d634f3d68f30dbc0e6eeb251 Mon Sep 17 00:00:00 2001 From: 9qeklajc Date: Mon, 13 Jul 2026 23:33:56 +0200 Subject: [PATCH] fix: harden mint fallback and refund recovery --- routstr/balance.py | 39 +++-- routstr/lightning.py | 27 +++- routstr/upstream/auto_topup.py | 25 +++- routstr/upstream/base.py | 7 +- routstr/wallet.py | 253 +++++++++++++++++++++++++------ tests/unit/test_auto_topup.py | 12 +- tests/unit/test_wallet.py | 264 ++++++++++++++++++++++++++++++++- 7 files changed, 561 insertions(+), 66 deletions(-) diff --git a/routstr/balance.py b/routstr/balance.py index cc37d089..8332577d 100644 --- a/routstr/balance.py +++ b/routstr/balance.py @@ -27,6 +27,7 @@ from .wallet import ( recieve_token, send_to_lnurl, send_token, + token_mint_url, ) router = APIRouter() @@ -220,7 +221,11 @@ async def _lookup_key_no_create( async def _restore_balance( - session: AsyncSession, hashed_key: str, balance: int, reserved_balance: int, mint_url: str + session: AsyncSession, + hashed_key: str, + balance: int, + reserved_balance: int, + mint_url: str, ) -> None: """Restore balance after a failed refund mint attempt.""" restore_stmt = ( @@ -235,7 +240,11 @@ async def _restore_balance( await session.commit() logger.info( "refund_wallet_endpoint: balance restored after mint failure", - extra={"hashed_key": hashed_key, "restored_balance": balance, "mint_url": mint_url}, + extra={ + "hashed_key": hashed_key, + "restored_balance": balance, + "mint_url": mint_url, + }, ) @@ -389,15 +398,14 @@ async def refund_wallet_endpoint( detail="Balance changed concurrently. Please retry the refund.", ) - # --- MINT: balance is locked at zero, safe to create the refund token --- - # Proofs from untrusted mints are swapped to primary_mint on receive. - # Use primary_mint unless key.refund_mint_url is an explicitly trusted mint. + # The balance is locked at zero, so it is safe to create the refund token. effective_refund_mint = ( key.refund_mint_url if key.refund_mint_url and key.refund_mint_url in settings.cashu_mints else settings.primary_mint ) try: + refund_currency = key.refund_currency or "sat" if key.refund_address: await send_to_lnurl( remaining_balance, @@ -407,10 +415,10 @@ async def refund_wallet_endpoint( ) result = {"recipient": key.refund_address} else: - refund_currency = key.refund_currency or "sat" token = await send_token( remaining_balance, refund_currency, effective_refund_mint ) + effective_refund_mint = token_mint_url(token, effective_refund_mint) result = {"token": token} if key.refund_currency == "sat": @@ -431,11 +439,23 @@ async def refund_wallet_endpoint( except HTTPException: # Minting failed — restore the debited balance - await _restore_balance(session, key.hashed_key, pre_debit_balance, pre_debit_reserved, key.refund_mint_url or "") + await _restore_balance( + session, + key.hashed_key, + pre_debit_balance, + pre_debit_reserved, + key.refund_mint_url or "", + ) raise except Exception as e: # Minting failed — restore the debited balance - await _restore_balance(session, key.hashed_key, pre_debit_balance, pre_debit_reserved, key.refund_mint_url or "") + await _restore_balance( + session, + key.hashed_key, + pre_debit_balance, + pre_debit_reserved, + key.refund_mint_url or "", + ) error_msg = str(e) logger.error( "refund_wallet_endpoint: mint/send failed", @@ -462,7 +482,7 @@ async def refund_wallet_endpoint( token=result["token"], amount=remaining_balance, unit=key.refund_currency or "sat", - mint_url=key.refund_mint_url, + mint_url=effective_refund_mint, typ="out", collected=False, source="apikey", @@ -656,7 +676,6 @@ async def reset_child_key_spent( return {"success": True, "message": "Child key balance reset successfully."} - @router.api_route( "/{path:path}", methods=["GET", "POST", "PUT", "DELETE"], diff --git a/routstr/lightning.py b/routstr/lightning.py index c7f0919b..fafffef9 100644 --- a/routstr/lightning.py +++ b/routstr/lightning.py @@ -14,6 +14,7 @@ from .core.settings import settings from .wallet import ( MintConnectionError, _is_mint_rate_limited, + _mint_cooldown_remaining, _mint_operation, get_wallet, is_mint_connection_error, @@ -75,17 +76,39 @@ async def _request_mint_with_fallback( *, allowed_mints: list[str] | None = None, ) -> tuple[str, str, str]: - """Request a quote, falling back only among the allowed trusted mints.""" + """Request a quote, falling back only among the allowed trusted mints. + + Guards against amount_sats <= 0: the cashu library's PostMintQuoteRequest + enforces ``amount > 0`` (Pydantic Field(gt=0)), so passing 0 raises a + cryptic validation error deep in the stack. Fail fast with context. + """ + if amount_sats <= 0: + raise ValueError( + f"generate_lightning_invoice: amount_sats must be > 0, got {amount_sats}." + ) tried: list[str] = [] configured = allowed_mints or [settings.primary_mint, *settings.cashu_mints] candidates = list(dict.fromkeys(configured)) for mint_url in candidates: + cooldown = _mint_cooldown_remaining(mint_url) + if cooldown > 0: + tried.append(f"{mint_url}: cooling down") + logger.info( + "Skipping rate-limited mint", + extra={ + "mint_url": mint_url, + "cooldown_seconds": round(cooldown, 2), + "op_name": "request_mint_invoice", + }, + ) + continue try: - wallet = await get_wallet(mint_url, "sat") + wallet = await get_wallet(mint_url, "sat", retry_on_rate_limit=False) quote = await _mint_operation( lambda: wallet.request_mint(amount_sats), op_name="request_mint_invoice", mint_url=mint_url, + retry_on_rate_limit=False, ) return quote.request, quote.quote, mint_url except Exception as e: diff --git a/routstr/upstream/auto_topup.py b/routstr/upstream/auto_topup.py index 3517be7d..932e3263 100644 --- a/routstr/upstream/auto_topup.py +++ b/routstr/upstream/auto_topup.py @@ -10,7 +10,7 @@ from ..core.db import ( create_session, store_cashu_transaction, ) -from ..wallet import send_token +from ..wallet import release_token_reservation, send_token, token_mint_url from .routstr import RoutstrUpstreamProvider logger = get_logger(__name__) @@ -142,20 +142,33 @@ async def _check_and_topup(row: UpstreamProviderRow) -> None: ) return + actual_mint_url = token_mint_url(token, mint_url) stored = await store_cashu_transaction( token=token, amount=amount, unit="sat", - mint_url=mint_url, + mint_url=actual_mint_url, typ="out", collected=False, source="auto_topup", ) if not stored: - logger.critical( - "Aborting auto top-up because its cashu token could not be persisted", - extra={"provider_id": row.id, "mint_url": mint_url}, - ) + try: + await release_token_reservation(token) + except Exception as error: + logger.critical( + "Failed to release untracked auto-topup token", + extra={ + "provider_id": row.id, + "mint_url": actual_mint_url, + "error": str(error), + }, + ) + else: + logger.warning( + "Auto-topup token was released after persistence failed", + extra={"provider_id": row.id, "mint_url": actual_mint_url}, + ) return result = await provider.topup(token) diff --git a/routstr/upstream/base.py b/routstr/upstream/base.py index f8ea2d4a..bbbe0f49 100644 --- a/routstr/upstream/base.py +++ b/routstr/upstream/base.py @@ -45,6 +45,7 @@ from ..wallet import ( classify_redemption_error, recieve_token, send_token, + token_mint_url, ) from . import messages_dispatch from .cache_breakpoints import ( @@ -3290,7 +3291,7 @@ class BaseUpstreamProvider: token=refund_token, amount=amount, unit=unit, - mint_url=mint, + mint_url=token_mint_url(refund_token, mint), typ="out", request_id=request_id, ) @@ -3645,7 +3646,7 @@ class BaseUpstreamProvider: token=refund_token, amount=emergency_refund, unit=unit, - mint_url=mint, + mint_url=token_mint_url(refund_token, mint), typ="out", request_id=request_id, ) @@ -4609,7 +4610,7 @@ class BaseUpstreamProvider: token=refund_token, amount=emergency_refund, unit=unit, - mint_url=mint, + mint_url=token_mint_url(refund_token, mint), typ="out", request_id=request_id, ) diff --git a/routstr/wallet.py b/routstr/wallet.py index 273d1aaf..f992c7a0 100644 --- a/routstr/wallet.py +++ b/routstr/wallet.py @@ -63,15 +63,13 @@ _TRANSPORT_EXC_TYPES: tuple[type[BaseException], ...] = ( class _MintRateGuard: - """Bound concurrency and adapt to actual per-mint 429 responses.""" + """Limit concurrency and remember per-mint rate-limit cooldowns.""" _guards: dict[str, "_MintRateGuard"] = {} @classmethod - def get(cls, mint_url: str) -> "_MintRateGuard | None": + def get(cls, mint_url: str) -> "_MintRateGuard": concurrency = settings.mint_max_concurrency - if concurrency <= 0: - return None guard = cls._guards.get(mint_url) if guard is None or guard._max_concurrency != concurrency: guard = cls(mint_url, concurrency) @@ -81,7 +79,9 @@ class _MintRateGuard: def __init__(self, mint_url: str, max_concurrency: int): self._mint_url = mint_url self._max_concurrency = max_concurrency - self._semaphore = asyncio.Semaphore(max_concurrency) + self._semaphore = ( + asyncio.Semaphore(max_concurrency) if max_concurrency > 0 else None + ) self._cooldown_until = 0.0 def apply_cooldown(self, delay: float) -> None: @@ -89,19 +89,28 @@ class _MintRateGuard: self._cooldown_until, time.monotonic() + max(0.0, delay) ) + def cooldown_remaining(self) -> float: + return max(0.0, self._cooldown_until - time.monotonic()) + + async def _run_after_cooldown(self, factory: Callable[[], Awaitable[Any]]) -> Any: + wait = self.cooldown_remaining() + if wait > 0: + logger.debug( + "Mint rate guard: cooling down", + extra={"mint_url": self._mint_url, "wait_seconds": round(wait, 2)}, + ) + await asyncio.sleep(wait) + return await factory() + async def run(self, factory: Callable[[], Awaitable[Any]]) -> Any: + if self._semaphore is None: + return await self._run_after_cooldown(factory) async with self._semaphore: - wait = self._cooldown_until - time.monotonic() - if wait > 0: - logger.debug( - "Mint rate guard: cooling down", - extra={ - "mint_url": self._mint_url, - "wait_seconds": round(wait, 2), - }, - ) - await asyncio.sleep(wait) - return await factory() + return await self._run_after_cooldown(factory) + + +def _mint_cooldown_remaining(mint_url: str) -> float: + return _MintRateGuard.get(mint_url).cooldown_remaining() def _is_mint_rate_limited(error: BaseException) -> bool: @@ -126,11 +135,17 @@ async def _mint_operation( op_name: str = "mint_operation", mint_url: str = "", retry_timeouts: bool = True, + retry_on_rate_limit: bool = True, ) -> Any: """Run a mint operation with bounded concurrency and adaptive cooldown. The timeout covers concurrency queueing, 429 cooldown, backoff, and network - work together. ``factory`` must return a fresh coroutine for every retry. + work together. ``factory`` must return a fresh coroutine for every retry. + + When ``retry_on_rate_limit`` is False a 429 is not retried in-place — the + cooldown is still applied to the per-mint guard (so subsequent operations on + that mint wait), but the exception is re-raised so the caller (typically + ``_request_mint_with_fallback``) can immediately try a different mint. """ guard = _MintRateGuard.get(mint_url) if mint_url else None timeout = settings.mint_operation_timeout_seconds @@ -166,6 +181,9 @@ async def _mint_operation( if not _is_mint_rate_limited(exc): raise + # Apply cooldown to the guard regardless — even when we're + # about to re-raise for fallback, the guard must remember that + # this mint is rate-limited for future operations. backoff = (2**attempt) + (time.monotonic() % 1.0) if isinstance(exc, httpx.HTTPStatusError): retry_after = _parse_retry_after(exc.response.headers) @@ -174,6 +192,20 @@ async def _mint_operation( if guard is not None: guard.apply_cooldown(backoff) + # When the caller has a fallback strategy (trusted-mint + # list), re-raise immediately so the caller can try the next + # mint instead of waiting through this mint's cooldown. + if not retry_on_rate_limit: + logger.warning( + "Mint rate-limited, skipping retries for fallback", + extra={ + "op_name": op_name, + "mint_url": mint_url, + "cooldown_seconds": round(backoff, 2), + }, + ) + raise + if attempt >= max_attempts - 1: raise logger.warning( @@ -374,24 +406,13 @@ async def recieve_token( async def send(amount: int, unit: str, mint_url: str | None = None) -> tuple[int, str]: - """Internal send function - returns amount and serialized token""" - effective_mint_url = mint_url or settings.primary_mint - wallet: Wallet = await get_wallet(effective_mint_url, unit) - proofs = get_proofs_per_mint_and_unit(wallet, effective_mint_url, unit) - proofs_for_mint = sum(p.amount for p in proofs) - - # Fallback: proofs from untrusted source mints are swapped to primary_mint - # during receive, so the user's preferred refund_mint_url may have no proofs - # even though the global wallet has the balance. - if proofs_for_mint < amount and effective_mint_url != settings.primary_mint: - logger.info( - f"send: insufficient proofs at {effective_mint_url} " - f"(have {proofs_for_mint}, need {amount}), falling back to primary_mint={settings.primary_mint}" - ) - effective_mint_url = settings.primary_mint - wallet = await get_wallet(effective_mint_url, unit) - proofs = get_proofs_per_mint_and_unit(wallet, effective_mint_url, unit) - proofs_for_mint = sum(p.amount for p in proofs) + """Create a token from the preferred mint or another funded trusted mint.""" + effective_mint_url = await find_trusted_mint_with_funds(amount, unit, mint_url) + wallet = await get_wallet(effective_mint_url, unit) + proofs = get_proofs_per_mint_and_unit( + wallet, effective_mint_url, unit, not_reserved=True + ) + proofs_for_mint = sum(proof.amount for proof in proofs) all_mint_urls = list({k.mint_url for k in wallet.keysets.values()}) proof_summary = { @@ -435,6 +456,61 @@ async def send_token(amount: int, unit: str, mint_url: str | None = None) -> str return token +async def release_token_reservation(token: str) -> None: + """Release a token that was created locally but never handed off.""" + token_obj = deserialize_token_from_string(token) + wallet = await get_wallet(token_obj.mint, token_obj.unit, load=False) + await wallet.set_reserved_for_send(token_obj.proofs, reserved=False) + + secrets = {proof.secret for proof in token_obj.proofs} + for proof in token_obj.proofs: + proof.reserved = False + for proof in wallet.proofs: + if proof.secret in secrets: + proof.reserved = False + + +def token_mint_url(token: str, fallback: str | None = None) -> str: + try: + return str(deserialize_token_from_string(token).mint) + except Exception: + if fallback is None: + raise + return fallback + + +async def find_trusted_mint_with_funds( + amount: int, unit: str, preferred_mint: str | None = None +) -> str: + """Choose a trusted mint that can cover a refund without waiting on cooldown.""" + trusted = list(dict.fromkeys([settings.primary_mint, *settings.cashu_mints])) + candidates: list[str] = [] + if preferred_mint in trusted: + candidates.append(preferred_mint) + candidates.extend(mint for mint in trusted if mint not in candidates) + + balances: dict[str, int] = {} + for mint_url in candidates: + if _mint_cooldown_remaining(mint_url) > 0: + continue + try: + wallet = await get_wallet(mint_url, unit, retry_on_rate_limit=False) + except Exception as error: + if is_mint_connection_error(error) or _is_mint_rate_limited(error): + balances[mint_url] = 0 + continue + raise + + proofs = get_proofs_per_mint_and_unit(wallet, mint_url, unit, not_reserved=True) + balances[mint_url] = sum(proof.amount for proof in proofs) + if balances[mint_url] >= amount: + return mint_url + + raise ValueError( + f"No trusted mint has {amount} {unit} available; balances={balances}" + ) + + # A foreign mint's fee_reserve is a non-binding estimate (NUT-05): the mint may # demand more when re-quoting or at melt execution. Instead of padding the # estimate with a safety buffer (which strands the margin at the foreign mint @@ -505,21 +581,48 @@ async def _request_mint_with_fallback( amount: int, *, op_name: str, primary_wallet: Wallet | None = None ) -> tuple[Wallet, str, MintQuote]: """Try request_mint on the primary mint, fall back to other trusted mints - on transport or rate-limit failure. Returns the wallet, mint_url, and quote.""" + on transport or rate-limit failure. Returns the wallet, mint_url, and quote. + + Guards against amount <= 0: the cashu library's PostMintQuoteRequest + enforces ``amount > 0`` (Pydantic Field(gt=0)), so passing 0 raises a + cryptic validation error deep in the stack. Fail fast with context. + """ + if amount <= 0: + raise ValueError( + f"_request_mint_with_fallback({op_name}): amount must be > 0, got {amount}. " + f"Token value is too small after fee deduction or unit conversion." + ) candidates = [settings.primary_mint] + [ m for m in settings.cashu_mints if m != settings.primary_mint ] tried: list[str] = [] for mint_url in candidates: + cooldown = _mint_cooldown_remaining(mint_url) + if cooldown > 0: + tried.append(f"{mint_url}: cooling down") + logger.info( + "Skipping rate-limited mint", + extra={ + "mint_url": mint_url, + "cooldown_seconds": round(cooldown, 2), + "op_name": op_name, + }, + ) + continue try: if mint_url == settings.primary_mint and primary_wallet is not None: wallet = primary_wallet else: - wallet = await get_wallet(mint_url, settings.primary_mint_unit) + wallet = await get_wallet( + mint_url, + settings.primary_mint_unit, + retry_on_rate_limit=False, + ) quote = await _mint_operation( lambda: wallet.request_mint(amount), op_name=op_name, mint_url=mint_url, + retry_on_rate_limit=False, ) return wallet, mint_url, quote except Exception as e: @@ -563,11 +666,37 @@ async def _calculate_swap_amount( ) return int(receive_amount) + # The cashu library's PostMintQuoteRequest enforces amount > 0 (Pydantic + # Field(gt=0)). When the token's face value in the primary mint's unit + # truncates to 0 (e.g. < 1000 msat with a "sat" primary unit), calling + # request_mint(0) raises a validation error that is cryptic in production + # logs. Guard early with full diagnostic context instead. + if receive_amount <= 0: + logger.error( + "swap_to_primary_mint: receive_amount is zero or negative, cannot estimate fees", + extra={ + "amount_msat": amount_msat, + "token_unit": token_unit, + "token_mint_url": token_mint_url, + "primary_mint": settings.primary_mint, + "primary_mint_unit": settings.primary_mint_unit, + "receive_amount": receive_amount, + }, + ) + raise ValueError( + f"Token amount ({amount_msat} msat, unit={token_unit}) is too small to " + f"swap to primary mint ({settings.primary_mint}, unit={settings.primary_mint_unit}): " + f"receive_amount={receive_amount}. Minimum 1 {settings.primary_mint_unit} required." + ) + logger.info( "swap_to_primary_mint: estimating fees", extra={ "dummy_amount": receive_amount, "unit": settings.primary_mint_unit, + "token_mint_url": token_mint_url, + "primary_mint": settings.primary_mint, + "amount_msat": amount_msat, }, ) @@ -600,6 +729,9 @@ async def _calculate_swap_amount( "input_fees": input_fees, "minted_amount": minted_amount, "minted_unit": settings.primary_mint_unit, + "fee_reserve": fee_reserve, + "token_mint_url": token_mint_url, + "primary_mint": settings.primary_mint, }, ) return minted_amount @@ -607,7 +739,16 @@ async def _calculate_swap_amount( except Exception as e: logger.error( "swap_to_primary_mint: fee estimation failed", - extra={"error": str(e)}, + extra={ + "error": str(e), + "error_type": type(e).__name__, + "amount_msat": amount_msat, + "token_unit": token_unit, + "token_mint_url": token_mint_url, + "primary_mint": settings.primary_mint, + "primary_mint_unit": settings.primary_mint_unit, + "receive_amount": receive_amount, + }, ) if is_mint_connection_error(e): raise MintConnectionError("Cashu mint is unreachable") from e @@ -672,6 +813,24 @@ async def swap_to_primary_mint( dest_mint_url = settings.primary_mint while True: attempt += 1 + if minted_amount <= 0: + logger.error( + "swap_to_primary_mint: minted_amount is zero or negative before requesting quote", + extra={ + "minted_amount": minted_amount, + "attempt": attempt, + "foreign_mint": token_obj.mint, + "token_amount": token_amount, + "token_unit": token_obj.unit, + "amount_msat": amount_msat, + "observed_extra_fee": observed_extra_fee, + "primary_mint": settings.primary_mint, + }, + ) + raise ValueError( + f"Cannot swap token ({token_amount} {token_obj.unit}) from {token_obj.mint}: " + f"minted_amount={minted_amount} after fee deduction (attempt {attempt})" + ) dest_wallet, dest_mint_url, mint_quote = await _request_mint_with_fallback( minted_amount, op_name="swap_request_mint", primary_wallet=primary_wallet ) @@ -1006,7 +1165,12 @@ _wallet_load_locks: dict[str, asyncio.Lock] = {} _WALLOAD_RELOAD_MIN_INTERVAL_SECONDS = 30 -async def get_wallet(mint_url: str, unit: str = "sat", load: bool = True) -> Wallet: +async def get_wallet( + mint_url: str, + unit: str = "sat", + load: bool = True, + retry_on_rate_limit: bool = True, +) -> Wallet: global _wallets, _wallet_last_load, _wallet_load_locks id = f"{mint_url}_{unit}" lock = _wallet_load_locks.setdefault(id, asyncio.Lock()) @@ -1016,17 +1180,19 @@ async def get_wallet(mint_url: str, unit: str = "sat", load: bool = True) -> Wal if load: now = time.monotonic() - last = _wallet_last_load.get(id, 0) - if now - last >= _WALLOAD_RELOAD_MIN_INTERVAL_SECONDS: + last = _wallet_last_load.get(id) + if last is None or now - last >= _WALLOAD_RELOAD_MIN_INTERVAL_SECONDS: await _mint_operation( lambda: _wallets[id].load_mint(), op_name="load_mint", mint_url=mint_url, + retry_on_rate_limit=retry_on_rate_limit, ) await _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] @@ -1361,9 +1527,10 @@ async def periodic_routstr_fee_payout() -> None: async def send_to_lnurl(amount: int, unit: str, mint: str, address: str) -> int: + mint = await find_trusted_mint_with_funds(amount, unit, mint) wallet = await get_wallet(mint, unit) - proofs = wallet._get_proofs_per_keyset(wallet.proofs)[wallet.keyset_id] - proofs, _ = await wallet.select_to_send(proofs, amount, set_reserved=True) + available = get_proofs_per_mint_and_unit(wallet, mint, unit, not_reserved=True) + proofs, _ = await wallet.select_to_send(available, amount, set_reserved=True) return await raw_send_to_lnurl(wallet, proofs, address, unit) diff --git a/tests/unit/test_auto_topup.py b/tests/unit/test_auto_topup.py index c05c85e5..cf3d06e2 100644 --- a/tests/unit/test_auto_topup.py +++ b/tests/unit/test_auto_topup.py @@ -66,6 +66,10 @@ async def test_auto_topup_persists_before_sending_and_marks_success_collected() "routstr.upstream.auto_topup.store_cashu_transaction", AsyncMock(return_value=True), ) as store, + patch( + "routstr.upstream.auto_topup.token_mint_url", + return_value="https://fallback-mint.test", + ), patch("routstr.upstream.auto_topup.create_session", return_value=session), ): await _check_and_topup(_row()) @@ -74,7 +78,7 @@ async def test_auto_topup_persists_before_sending_and_marks_success_collected() token="cashu-token", amount=50, unit="sat", - mint_url="https://mint.test", + mint_url="https://fallback-mint.test", typ="out", collected=False, source="auto_topup", @@ -138,6 +142,12 @@ async def test_auto_topup_does_not_send_untracked_token() -> None: "routstr.upstream.auto_topup.store_cashu_transaction", AsyncMock(return_value=False), ), + patch( + "routstr.upstream.auto_topup.release_token_reservation", + AsyncMock(), + ) as reclaim, ): await _check_and_topup(_row()) + + reclaim.assert_awaited_once_with("cashu-token") provider.topup.assert_not_awaited() diff --git a/tests/unit/test_wallet.py b/tests/unit/test_wallet.py index f712b357..814fb36c 100644 --- a/tests/unit/test_wallet.py +++ b/tests/unit/test_wallet.py @@ -169,6 +169,59 @@ async def test_send_token() -> None: assert token == "test_token" +@pytest.mark.asyncio +async def test_release_token_reservation_unreserves_local_proofs() -> None: + from routstr.wallet import release_token_reservation + + token_proof = Mock(secret="proof-secret", reserved=True) + cached_proof = Mock(secret="proof-secret", reserved=True) + token = Mock(mint="http://mint:3338", unit="sat", proofs=[token_proof]) + wallet = Mock(proofs=[cached_proof], set_reserved_for_send=AsyncMock()) + with ( + patch("routstr.wallet.deserialize_token_from_string", return_value=token), + patch( + "routstr.wallet.get_wallet", AsyncMock(return_value=wallet) + ) as get_wallet, + ): + await release_token_reservation("cashu-token") + + get_wallet.assert_awaited_once_with("http://mint:3338", "sat", load=False) + wallet.set_reserved_for_send.assert_awaited_once_with(token.proofs, reserved=False) + assert token_proof.reserved is False + assert cached_proof.reserved is False + + +@pytest.mark.asyncio +async def test_refund_mint_falls_back_to_trusted_mint_with_funds() -> None: + from routstr.core.settings import settings + from routstr.wallet import find_trusted_mint_with_funds + + primary = "http://primary:3338" + secondary = "http://secondary:3338" + + def wallet_for(mint: str, amount: int) -> Mock: + keyset = Mock(id=f"keyset-{mint}", mint_url=mint) + keyset.unit.name = "sat" + proof = Mock(id=keyset.id, amount=amount, reserved=False) + return Mock(keysets={keyset.id: keyset}, proofs=[proof]) + + wallets = { + primary: wallet_for(primary, 50), + secondary: wallet_for(secondary, 200), + } + with ( + patch.object(settings, "primary_mint", primary), + patch.object(settings, "cashu_mints", [primary, secondary]), + patch( + "routstr.wallet.get_wallet", + AsyncMock(side_effect=lambda mint, *args, **kwargs: wallets[mint]), + ), + ): + mint = await find_trusted_mint_with_funds(100, "sat", primary) + + assert mint == secondary + + @pytest.mark.asyncio async def test_credit_balance() -> None: token_data = { @@ -1416,6 +1469,25 @@ def test_mint_rate_guard_rebuilds_when_setting_changes() -> None: assert second._max_concurrency == 2 +@pytest.mark.asyncio +async def test_mint_rate_guard_keeps_cooldown_when_concurrency_is_unlimited() -> None: + from routstr.core.settings import settings + from routstr.wallet import _MintRateGuard + + operation = AsyncMock(return_value="ok") + with ( + patch.object(settings, "mint_max_concurrency", 0), + patch("routstr.wallet.time.monotonic", return_value=0), + patch("routstr.wallet.asyncio.sleep", AsyncMock()) as sleep, + ): + guard = _MintRateGuard.get("http://mint:3338") + guard.apply_cooldown(5) + assert await guard.run(operation) == "ok" + + sleep.assert_awaited_once_with(5) + operation.assert_awaited_once() + + @pytest.mark.asyncio async def test_mint_operation_honors_retry_after_as_minimum() -> None: from routstr.core.settings import settings @@ -1495,7 +1567,9 @@ async def test_get_wallet_initializes_and_loads_once_concurrently() -> None: with patch( "routstr.wallet.Wallet.with_db", AsyncMock(return_value=mock_wallet) ) as create: - with patch("routstr.wallet.time.monotonic", return_value=100.0): + # A fresh wallet must load even when the host has been up for less than + # the reload interval. + with patch("routstr.wallet.time.monotonic", return_value=10.0): first, second = await asyncio.gather( get_wallet("http://mint:3338"), get_wallet("http://mint:3338") ) @@ -1506,6 +1580,36 @@ async def test_get_wallet_initializes_and_loads_once_concurrently() -> None: mock_wallet.load_proofs.assert_awaited_once_with(reload=True) +@pytest.mark.asyncio +async def test_get_wallet_can_surface_429_without_retrying() -> None: + from routstr.core.settings import settings + from routstr.wallet import get_wallet + + request = httpx.Request("GET", "http://mint:3338/v1/info") + response = httpx.Response(429, request=request, headers={"Retry-After": "60"}) + wallet = Mock( + load_mint=AsyncMock( + side_effect=httpx.HTTPStatusError( + "rate limited", request=request, response=response + ) + ), + load_proofs=AsyncMock(), + ) + + with ( + patch("routstr.wallet.Wallet.with_db", AsyncMock(return_value=wallet)), + patch.object(settings, "mint_retry_max_attempts", 3), + patch.object(settings, "mint_operation_timeout_seconds", 0), + patch("routstr.wallet.asyncio.sleep", AsyncMock()) as sleep, + ): + with pytest.raises(httpx.HTTPStatusError): + await get_wallet("http://mint:3338", retry_on_rate_limit=False) + + wallet.load_mint.assert_awaited_once() + wallet.load_proofs.assert_not_awaited() + sleep.assert_not_awaited() + + @pytest.mark.asyncio async def test_mint_operation_factory_retry_succeeds() -> None: """_mint_operation accepts a zero-arg factory, not a dead coroutine. @@ -1752,3 +1856,161 @@ async def test_lightning_mint_fallback_all_fail() -> None: ): with pytest.raises(MintConnectionError): await _request_mint_with_fallback(1000) + + +@pytest.mark.asyncio +async def test_lightning_mint_fallback_rejects_zero_amount() -> None: + """Zero or negative amounts must be rejected before reaching the mint.""" + from routstr.lightning import _request_mint_with_fallback + + with pytest.raises(ValueError, match="amount_sats must be > 0"): + await _request_mint_with_fallback(0) + + with pytest.raises(ValueError, match="amount_sats must be > 0"): + await _request_mint_with_fallback(-5) + + +@pytest.mark.asyncio +async def test_wallet_request_mint_fallback_rejects_zero_amount() -> None: + """Zero or negative amounts must be rejected before reaching the mint.""" + from routstr.wallet import _request_mint_with_fallback + + with pytest.raises(ValueError, match="amount must be > 0"): + await _request_mint_with_fallback(0, op_name="test") + + with pytest.raises(ValueError, match="amount must be > 0"): + await _request_mint_with_fallback(-1, op_name="test") + + +@pytest.mark.asyncio +async def test_wallet_fallback_on_429_no_in_place_retry() -> None: + """A 429 from the primary mint must trigger immediate fallback to the + secondary — _mint_operation must NOT retry in-place when + retry_on_rate_limit=False is set by _request_mint_with_fallback.""" + from routstr.core.settings import settings + from routstr.wallet import _request_mint_with_fallback + + primary = "http://primary:3338" + secondary = "http://secondary:3338" + + request = httpx.Request("POST", "http://primary:3338/v1/mint/quote/bolt11") + response = httpx.Response(429, request=request, headers={"Retry-After": "60"}) + primary_call_count = 0 + + async def primary_request_mint(amount): + nonlocal primary_call_count + primary_call_count += 1 + raise httpx.HTTPStatusError("rate limited", request=request, response=response) + + mock_primary_wallet = Mock() + mock_primary_wallet.request_mint = AsyncMock(side_effect=primary_request_mint) + + mock_quote = Mock(quote="q_secondary", request="lnbc1secondary") + mock_secondary_wallet = Mock() + mock_secondary_wallet.request_mint = AsyncMock(return_value=mock_quote) + + wallets_map = {primary: mock_primary_wallet, secondary: mock_secondary_wallet} + mock_get = AsyncMock(side_effect=lambda m, *a, **kw: wallets_map[m]) + + with patch.object(settings, "primary_mint", primary): + with patch.object(settings, "cashu_mints", [primary, secondary]): + with patch.object(settings, "mint_retry_max_attempts", 3): + with patch.object(settings, "mint_max_concurrency", 0): + with patch.object(settings, "mint_operation_timeout_seconds", 0): + with patch("asyncio.sleep", AsyncMock()) as mock_sleep: + with patch( + "routstr.wallet.get_wallet", side_effect=mock_get + ): + _, mint_url, _ = await _request_mint_with_fallback( + 1000, op_name="test_429_fallback" + ) + + assert mint_url == secondary + assert primary_call_count == 1 + mock_secondary_wallet.request_mint.assert_called_once() + mock_sleep.assert_not_called() + + +@pytest.mark.asyncio +async def test_wallet_fallback_skips_mint_during_cooldown() -> None: + from routstr.core.settings import settings + from routstr.wallet import _MintRateGuard, _request_mint_with_fallback + + primary = "http://primary:3338" + secondary = "http://secondary:3338" + primary_wallet = Mock(request_mint=AsyncMock()) + 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_max_concurrency", 0), + patch.object(settings, "mint_operation_timeout_seconds", 0), + patch("routstr.wallet.time.monotonic", return_value=10), + patch("routstr.wallet.asyncio.sleep", AsyncMock()) as sleep, + patch( + "routstr.wallet.get_wallet", + AsyncMock(side_effect=lambda mint, *args, **kwargs: wallets[mint]), + ), + ): + _MintRateGuard.get(primary).apply_cooldown(60) + _, mint_url, _ = await _request_mint_with_fallback( + 1000, op_name="test_cooldown_fallback" + ) + + assert mint_url == secondary + primary_wallet.request_mint.assert_not_awaited() + secondary_wallet.request_mint.assert_awaited_once_with(1000) + sleep.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_lightning_fallback_on_429_no_in_place_retry() -> None: + """Same as above but for the lightning.py _request_mint_with_fallback.""" + from routstr.core.settings import settings + from routstr.lightning import _request_mint_with_fallback + + primary = "http://primary:3338" + secondary = "http://secondary:3338" + + request = httpx.Request("POST", "http://primary:3338/v1/mint/quote/bolt11") + response = httpx.Response(429, request=request, headers={"Retry-After": "60"}) + primary_call_count = 0 + + async def primary_request_mint(amount): + nonlocal primary_call_count + primary_call_count += 1 + raise httpx.HTTPStatusError("rate limited", request=request, response=response) + + mock_primary_wallet = Mock() + mock_primary_wallet.request_mint = AsyncMock(side_effect=primary_request_mint) + + mock_quote = Mock(quote="q_secondary", request="lnbc1secondary") + mock_secondary_wallet = Mock() + mock_secondary_wallet.request_mint = AsyncMock(return_value=mock_quote) + + wallets_map = {primary: mock_primary_wallet, secondary: mock_secondary_wallet} + mock_get = AsyncMock(side_effect=lambda m, *a, **kw: wallets_map[m]) + + with patch.object(settings, "primary_mint", primary): + with patch.object(settings, "cashu_mints", [primary, secondary]): + with patch.object(settings, "mint_retry_max_attempts", 3): + with patch.object(settings, "mint_max_concurrency", 0): + with patch.object(settings, "mint_operation_timeout_seconds", 0): + with patch("asyncio.sleep", AsyncMock()) as mock_sleep: + with patch( + "routstr.lightning.get_wallet", side_effect=mock_get + ): + _, _, first_mint = await _request_mint_with_fallback( + 1000 + ) + _, _, second_mint = await _request_mint_with_fallback( + 1000 + ) + + assert first_mint == second_mint == secondary + assert primary_call_count == 1 + assert mock_secondary_wallet.request_mint.await_count == 2 + mock_sleep.assert_not_called()