From cc55868ecab2f5184f42b37899ba0430584bbfbf Mon Sep 17 00:00:00 2001 From: 9qeklajc Date: Fri, 4 Sep 2026 01:35:43 +0200 Subject: [PATCH] harden cashu path --- docs/api/errors.md | 20 +- routstr/auth.py | 32 +- routstr/payment/helpers.py | 21 +- routstr/upstream/ehbp.py | 31 +- routstr/wallet.py | 923 ++----------- tests/integration/test_swap_fee_retry.py | 210 --- .../integration/test_topup_untrusted_mint.py | 56 + tests/unit/test_auth_cashu.py | 21 +- tests/unit/test_balance.py | 42 +- .../unit/test_cashu_untrusted_source_mint.py | 345 +++++ tests/unit/test_coverage_payment_helpers.py | 4 + tests/unit/test_melt_reconciliation.py | 91 -- tests/unit/test_messages_litellm_dispatch.py | 42 +- tests/unit/test_short_keyset_ids.py | 57 +- tests/unit/test_wallet.py | 1147 +---------------- 15 files changed, 676 insertions(+), 2366 deletions(-) delete mode 100644 tests/integration/test_swap_fee_retry.py create mode 100644 tests/integration/test_topup_untrusted_mint.py create mode 100644 tests/unit/test_cashu_untrusted_source_mint.py delete mode 100644 tests/unit/test_melt_reconciliation.py diff --git a/docs/api/errors.md b/docs/api/errors.md index 95983de8..293ddb1a 100644 --- a/docs/api/errors.md +++ b/docs/api/errors.md @@ -131,28 +131,32 @@ granularity) on any of them. |--------|--------|--------|-----------|---------| | `token_already_spent` | 400 | `cashu_token_already_spent` | No | The token was already redeemed. | | `invalid_token` | 400 | `invalid_cashu_token` | No | The token is malformed or cannot be decoded. | -| `mint_error` | 422 | `cashu_token_swap_fees_exceed_amount` | No | Token value is too small to cover the mint's swap/melt fees. | -| `mint_error` | 422 | `cashu_foreign_mint_swap_failed` | No | Swapping the token from a foreign mint to the primary mint failed. | +| `mint_error` | 422 | `cashu_token_swap_fees_exceed_amount` | No | Token value is too small to cover the mint's NUT-02 input fees. | +| `untrusted_mint` | 400 | `cashu_untrusted_source_mint` | No | The token was issued by a mint this node does not accept. Only the node's configured mints (`PRIMARY_MINT_URL` / `CASHU_MINTS`) are redeemable. | | `mint_unreachable` | 503 | `cashu_source_mint_unreachable` | **Yes** | The mint that issued the token could not be reached; it cannot be redeemed at another mint. | | `mint_rate_limited` | 503 | `cashu_mint_rate_limited` | **Yes** | The mint rate-limited the request; retry after the cooldown. | -| `mint_unreachable` | 503 | `cashu_mint_unreachable` | **Yes** | The mint could not be reached (DNS failure, refused/reset connection, timeout). The token is fine — retry once the mint recovers. | +| `mint_timeout` | 503 | `cashu_mint_timeout` | **Yes** | The mint did not respond in time; retry later. | +| `mint_unreachable` | 503 | `cashu_mint_unreachable` | **Yes** | The mint could not be reached (DNS failure, refused/reset connection). The token is fine — retry once the mint recovers. | | `cashu_error` | 400 | `cashu_token_redemption_failed` | No | The token could not be redeemed for another expected reason. | | `cashu_error` | 400 | `cashu_token_zero_value` | No | The token redeemed to zero (empty/dust token, or value fully consumed by fees). | | `token_consumed` | 500 | `cashu_token_consumed` | No | The token was **spent** (melted/redeemed) but crediting it then failed. Do not retry — the token is gone; contact support to reconcile. | | `api_error` | 500 | `internal_error` | Maybe | Unexpected server-side fault during redemption. | !!! important "Retry only transient mint failures" - Only `mint_unreachable` and `mint_rate_limited` (503) are retryable — the - same token may work again later. Everything else is a permanent property of - the token and must not be blindly retried. Use exponential backoff for the + Only `mint_unreachable`, `mint_rate_limited` and `mint_timeout` (503) are + retryable — the same token may work again later. Everything else is a + permanent property of the token and must not be blindly retried. + `untrusted_mint` is permanent: the node will never accept that mint until + an operator adds it to `CASHU_MINTS`. Use exponential backoff for the 503 responses, and honor the mint's cooldown for `mint_rate_limited`. In particular, a `token_consumed` 500 means the mint already spent the token, so a retry would fail as `token_already_spent`. #### Mint failures (retryable) -`mint_unreachable` and `mint_rate_limited` are retryable redemption errors. For -`mint_rate_limited`, honor the mint's cooldown before retrying. +`mint_unreachable`, `mint_rate_limited` and `mint_timeout` are retryable +redemption errors. For `mint_rate_limited`, honor the mint's cooldown before +retrying. ```json { diff --git a/routstr/auth.py b/routstr/auth.py index e3faeed3..76b421b1 100644 --- a/routstr/auth.py +++ b/routstr/auth.py @@ -37,6 +37,7 @@ from .wallet import ( classify_redemption_error, credit_balance, deserialize_token_from_string, + resolve_trusted_source_mint, wallet_operation_guard, ) @@ -380,24 +381,20 @@ async def _validate_bearer_key_locked( "has_expiry_time": bool(key_expiry_time), }, ) - if token_obj.mint == settings.primary_mint: - if token_obj.unit != settings.primary_mint_unit: - raise redemption_error_to_http_exception( - ValueError( - "Cashu token unit does not match the configured primary " - f"mint unit: expected {settings.primary_mint_unit}, " - f"got {token_obj.unit}" - ) + token_mint = resolve_trusted_source_mint(token_obj.mint) or token_obj.mint + if ( + token_mint == settings.primary_mint + and token_obj.unit != settings.primary_mint_unit + ): + raise redemption_error_to_http_exception( + ValueError( + "Cashu token unit does not match the configured primary " + f"mint unit: expected {settings.primary_mint_unit}, " + f"got {token_obj.unit}" ) - refund_currency = token_obj.unit - refund_mint_url = settings.primary_mint - elif token_obj.mint in settings.cashu_mints: - refund_currency = token_obj.unit - refund_mint_url = token_obj.mint - else: - # Foreign tokens are swapped into the configured primary mint. - refund_currency = settings.primary_mint_unit - refund_mint_url = settings.primary_mint + ) + refund_currency = token_obj.unit + refund_mint_url = token_mint new_key = ApiKey( hashed_key=hashed_key, @@ -449,6 +446,7 @@ async def _validate_bearer_key_locked( "cashu_source_mint_unreachable", "cashu_mint_unreachable", "cashu_mint_rate_limited", + "cashu_mint_timeout", } log = ( logger.info diff --git a/routstr/payment/helpers.py b/routstr/payment/helpers.py index 4509649c..1acd3d21 100644 --- a/routstr/payment/helpers.py +++ b/routstr/payment/helpers.py @@ -14,10 +14,16 @@ from ..core import get_logger from ..core.exceptions import UpstreamError from ..core.redaction import redact_org_ids from ..core.settings import settings -from ..wallet import deserialize_token_from_string +from ..wallet import ( + UntrustedSourceMintError, + classify_redemption_error, + deserialize_token_from_string, + is_trusted_source_mint, +) logger = get_logger(__name__) + def check_token_balance(headers: dict, body: dict, max_cost_for_model: int) -> None: if x_cashu := headers.get("x-cashu", None): cashu_token = x_cashu @@ -68,6 +74,19 @@ def check_token_balance(headers: dict, body: dict, max_cost_for_model: int) -> N detail="Invalid authentication token format", ) + if not is_trusted_source_mint(token_obj.mint): + classified = classify_redemption_error( + UntrustedSourceMintError(f"Untrusted source mint: {token_obj.mint}") + ) + assert classified is not None + error_type, status_code, message, error_code = classified + raise HTTPException( + status_code=status_code, + detail={ + "error": {"message": message, "type": error_type, "code": error_code} + }, + ) + amount_msat = ( token_obj.amount if token_obj.unit == "msat" else token_obj.amount * 1000 ) diff --git a/routstr/upstream/ehbp.py b/routstr/upstream/ehbp.py index 839cb079..3f749795 100644 --- a/routstr/upstream/ehbp.py +++ b/routstr/upstream/ehbp.py @@ -40,7 +40,12 @@ from ..payment.cost_calculation import ( ) from ..payment.helpers import create_error_response from ..payment.models import Model -from ..wallet import recieve_token, send_token +from ..wallet import ( + SPENT_TOKEN_CODES, + classify_redemption_error, + recieve_token, + send_token, +) from .tinfoil_trailer import forward_with_trailer logger = get_logger(__name__) @@ -1070,6 +1075,30 @@ async def forward_ehbp_x_cashu_request( }, ) + if not redeemed: + classified = classify_redemption_error(e) + if classified is not None: + error_type, status_code, message, error_code = classified + # Never re-offer a spent/consumed token. + echo_token = None if error_code in SPENT_TOKEN_CODES else x_cashu_token + return create_error_response( + error_type, + message, + status_code, + request=request, + token=echo_token, + code=error_code, + ) + # Raw exception text may contain the attacker-supplied mint URL. + return create_error_response( + "api_error", + "Internal error during token redemption", + 500, + request=request, + token=x_cashu_token, + code="internal_error", + ) + if "already spent" in error_message.lower(): return create_error_response( "token_already_spent", diff --git a/routstr/wallet.py b/routstr/wallet.py index 51ee7f72..5e1588a0 100644 --- a/routstr/wallet.py +++ b/routstr/wallet.py @@ -2,7 +2,6 @@ import asyncio import fcntl import json import os -import re import time import typing from contextlib import asynccontextmanager @@ -10,9 +9,10 @@ from contextvars import ContextVar from dataclasses import dataclass from pathlib import Path from typing import AsyncGenerator, TypedDict +from urllib.parse import urlsplit, urlunsplit import httpx -from cashu.core.base import MeltQuote, MeltQuoteState, MintQuote, Proof, Token +from cashu.core.base import MeltQuote, 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 @@ -24,7 +24,6 @@ from .core import db, get_logger from .core.db import store_cashu_transaction_with_retry as store_cashu_transaction from .core.settings import settings from .mint import ( - MINT_TRANSPORT_COOLDOWN_SECONDS, MINT_TRANSPORT_EXCEPTIONS, MintError, MintRateGuard, @@ -189,6 +188,10 @@ class SourceMintConnectionError(MintConnectionError): """The mint that issued the incoming proofs cannot be reached.""" +class UntrustedSourceMintError(ValueError): + """The token names a mint outside primary_mint/cashu_mints.""" + + class TokenConsumedError(Exception): """A failure that happened AFTER the token's proofs were spent (melt succeeded, or redemption already returned) — e.g. minting on the primary @@ -200,6 +203,31 @@ class TokenConsumedError(Exception): """ +_MINT_TIMEOUT_EXCEPTIONS: tuple[type[BaseException], ...] = ( + httpx.TimeoutException, + asyncio.TimeoutError, +) + + +def _exception_chain(error: BaseException) -> typing.Iterator[BaseException]: + seen: set[int] = set() + current: BaseException | None = error + while current is not None and id(current) not in seen: + seen.add(id(current)) + yield current + current = current.__cause__ or current.__context__ + + +def is_mint_timeout(error: BaseException) -> bool: + """True if the mint accepted the connection but did not answer in time.""" + for current in _exception_chain(error): + if isinstance(current, TokenConsumedError): + return False + if isinstance(current, _MINT_TIMEOUT_EXCEPTIONS): + return True + return False + + def is_source_mint_connection_error(error: BaseException) -> bool: seen: set[int] = set() current: BaseException | None = error @@ -263,25 +291,39 @@ def classify_redemption_error( "Token was redeemed but could not be credited; do not retry", "cashu_token_consumed", ) - if is_source_mint_connection_error(error): + if isinstance(error, UntrustedSourceMintError): return ( - "mint_unreachable", - 503, - "The mint that issued this Cashu token is unreachable; the token cannot be redeemed at another mint", - "cashu_source_mint_unreachable", + "untrusted_mint", + 400, + "Cashu token was issued by a mint this node does not accept", + "cashu_untrusted_source_mint", ) if is_mint_rate_limited(error): return ( "mint_rate_limited", 503, - "Cashu mint rate-limited; retry after cooldown", + "Cashu mint is rate-limiting requests; retry later", "cashu_mint_rate_limited", ) + if is_mint_timeout(error): + return ( + "mint_timeout", + 503, + "Cashu mint did not respond in time; retry later", + "cashu_mint_timeout", + ) + if is_source_mint_connection_error(error): + return ( + "mint_unreachable", + 503, + "The mint that issued this Cashu token is unreachable; retry later", + "cashu_source_mint_unreachable", + ) if is_mint_connection_error(error): return ( "mint_unreachable", 503, - "Cashu mint is unreachable", + "Cashu mint is unreachable; retry later", "cashu_mint_unreachable", ) lowered = str(error).lower() @@ -304,13 +346,6 @@ def classify_redemption_error( "Token value is too small to cover swap fees", "cashu_token_swap_fees_exceed_amount", ) - if "failed to melt" in lowered: - return ( - "mint_error", - 422, - "Failed to swap token from foreign mint", - "cashu_foreign_mint_swap_failed", - ) if ("invalid" in lowered or "decode" in lowered) and "token" in lowered: # Anchored to "token" so internal faults whose text merely contains # "invalid"/"decode" fall through to the 500 branch, not a token error. @@ -454,64 +489,83 @@ async def _redeem_same_mint( async def recieve_token( token: str, - destination_mint: str | None = None, destination_unit: str | None = None, ) -> tuple[int, str, str]: # amount, unit, mint_url - """Redeem a token while serializing all wallet proof mutation.""" + """Redeem a token on its own (trusted) mint while serializing proof mutation.""" async with wallet_operation_guard(): - return await _recieve_token_locked(token, destination_mint, destination_unit) + return await _recieve_token_locked(token, destination_unit) + + +def _normalized_mint_url(mint_url: str) -> str: + """Fold cosmetic URL differences for trust comparison; keep path case, + userinfo, non-default port, query and fragment so hosts never alias.""" + stripped = mint_url.strip() + if any(char in stripped for char in "\t\r\n"): + return stripped + try: + parts = urlsplit(stripped) + port = parts.port + except ValueError: + return stripped + scheme = parts.scheme.lower() + if (scheme == "https" and port == 443) or (scheme == "http" and port == 80): + port = None + netloc = (parts.hostname or "").lower() + if parts.username is not None or parts.password is not None: + netloc = f"{parts.username or ''}:{parts.password or ''}@{netloc}" + if port is not None: + netloc = f"{netloc}:{port}" + return urlunsplit( + (scheme, netloc, parts.path.rstrip("/"), parts.query, parts.fragment) + ) + + +def resolve_trusted_source_mint(mint_url: str) -> str | None: + """Return the operator's spelling of ``mint_url`` if it is a trusted mint.""" + normalized = _normalized_mint_url(mint_url) + if not normalized: + return None + for candidate in _trusted_destination_candidates(): + if candidate and _normalized_mint_url(candidate) == normalized: + return candidate + return None + + +def is_trusted_source_mint(mint_url: str) -> bool: + """True if ``mint_url`` is the primary mint or one of the configured mints.""" + return resolve_trusted_source_mint(mint_url) is not None async def _recieve_token_locked( token: str, - destination_mint: str | None = None, destination_unit: str | None = None, ) -> tuple[int, str, str]: + """Redeem on the issuing mint; only trusted mints are accepted, never swapped.""" token_obj = deserialize_token_from_string(token) + mint_url = resolve_trusted_source_mint(token_obj.mint) + if mint_url is None: + raise UntrustedSourceMintError(f"Untrusted source mint: {token_obj.mint}") if len(token_obj.keysets) > 1: raise ValueError("Multiple keysets per token currently not supported") - - destinations = ( - [destination_mint] - if destination_mint is not None - else list(dict.fromkeys([settings.primary_mint, *settings.cashu_mints])) - ) - output_unit = ( - token_obj.unit if token_obj.mint in destinations else settings.primary_mint_unit - ) - if destination_unit is not None and output_unit != destination_unit: + if destination_unit is not None and token_obj.unit != destination_unit: raise ValueError( "Cashu token unit does not match the API key liability unit: " - f"expected {destination_unit}, got {output_unit}" - ) - - wallet = await get_wallet(token_obj.mint, token_obj.unit, load=False) - if token_obj.mint not in destinations: - logger.info( - "Cashu cross-mint swap required", - extra={ - "event": "cashu_swap_started", - "source_mint": token_obj.mint, - "source_unit": token_obj.unit, - "source_amount": token_obj.amount, - "destination_candidates": destinations, - }, - ) - return await swap_to_trusted_mint( - token_obj, wallet, destination_mints=destinations + f"expected {destination_unit}, got {token_obj.unit}" ) + wallet = await get_wallet(mint_url, token_obj.unit, load=False) logger.info( "Trying same-mint Cashu redemption", extra={ "event": "cashu_same_mint_redemption", - "source_mint": token_obj.mint, + "source_mint": mint_url, "source_unit": token_obj.unit, "source_amount": token_obj.amount, "cross_mint_fallback_on_connection_failure": False, }, ) - return await _redeem_same_mint(wallet, token_obj) + amount, unit, _ = await _redeem_same_mint(wallet, token_obj) + return amount, unit, mint_url async def send(amount: int, unit: str, mint_url: str | None = None) -> tuple[int, str]: @@ -938,83 +992,6 @@ async def find_trusted_mint_with_funds( ) -# 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 -# on every swap), the swap retries with the amount recomputed from the fees the -# mint actually demands, up to this many attempts. -_MAX_SWAP_ATTEMPTS = 3 - -_MINT_ERROR_CODE_RE = re.compile(r"\(Code: (\d+)\)") -_MELT_SHORTFALL_RE = re.compile(r"Provided: (\d+), needed: (\d+)") - -# Insufficient-melt-inputs failures differ across mint implementations. 11005 is -# the registered "Transaction is not balanced" code (cdk), specific enough to -# trust on the code alone. 11000 is nutshell's generic, unregistered -# TransactionError covering many unrelated failures, so it only counts as a fee -# shortfall alongside the "not enough inputs" detail text. With no code suffix at -# all, that same text is the only signal. - - -def _net_minted_amount(amount_msat: int, token_unit: str, fees: int) -> int: - """ - Convert the token value minus fees (given in the token unit) into an - amount in the primary mint's unit. - """ - fee_msat = _sats_to_msats(fees) if token_unit == "sat" else fees - remaining_msat = amount_msat - fee_msat - if settings.primary_mint_unit == "sat": - return _msats_to_sats(remaining_msat) - return int(remaining_msat) - - -def _melt_definitively_failed(error: Exception) -> bool: - """Return whether the mint authoritatively rejected the Lightning payment. - - Cashu releases the reserved proofs for these responses, so the token remains - reusable. Transport failures and unknown errors are deliberately excluded: - after dispatch their payment outcome may still be pending or paid. - """ - message = str(error).strip() - return message.lower() == "could not pay invoice." or "(Code: 20004)" in message - - -def _melt_insufficient_shortfall(error: Exception) -> int | None: - """ - Classify a melt failure: return the observed shortfall (in the token unit) - when the mint rejected the inputs as insufficient, or None when the failure - is unrelated to fees and must not be retried (e.g. a Lightning payment - failure, where a smaller invoice would not help). - - Cashu errors carry no structured amounts (NUT-00 defines only detail/code, - flattened to "Mint Error: (Code: )" by cashu-py), so the - classification uses the code and the shortfall must be inferred: the - "Provided: X, needed: Y" amounts are nutshell-specific free text and only - refine the shortfall when present; otherwise shrink one unit at a time. - """ - message = str(error) - code_match = _MINT_ERROR_CODE_RE.search(message) - code = code_match.group(1) if code_match is not None else None - has_shortfall_text = "not enough inputs" in message.lower() - - match code: - case "11005": # registered TransactionUnbalanced: trust the code - pass - case "11000" if has_shortfall_text: # generic nutshell error: needs the text - pass - case None if has_shortfall_text: # no code suffix: text is the only signal - pass - case _: # other codes, a bare 11000, or no signal: must not retry - return None - - amounts = _MELT_SHORTFALL_RE.search(message) - if amounts is not None: - provided, needed = int(amounts.group(1)), int(amounts.group(2)) - if needed > provided: - return needed - provided - return 1 - - def _trusted_destination_candidates( candidates: list[str] | None = None, ) -> list[str]: @@ -1030,695 +1007,6 @@ def _trusted_destination_candidates( return selected -async def _request_mint_with_fallback( - amount: int, - *, - op_name: str, - primary_wallet: Wallet | None = None, - destination_mints: list[str] | 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. - - 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 = _trusted_destination_candidates(destination_mints) - logger.info( - "Trying trusted destination mints", - extra={ - "event": "cashu_destination_candidates", - "op_name": op_name, - "amount": amount, - "unit": settings.primary_mint_unit, - "candidates": candidates, - }, - ) - tried: list[str] = [] - for candidate_index, mint_url in enumerate(candidates, start=1): - cooldown = mint_cooldown_remaining(mint_url) - if cooldown > 0: - tried.append(f"{mint_url}: cooling down") - logger.warning( - "Skipping unavailable destination mint", - extra={ - "event": "cashu_destination_skipped", - "mint_url": mint_url, - "cooldown_seconds": round(cooldown, 2), - "op_name": op_name, - "candidate_index": candidate_index, - "candidate_count": len(candidates), - }, - ) - continue - logger.info( - "Trying destination mint", - extra={ - "event": "cashu_destination_attempt", - "mint_url": mint_url, - "op_name": op_name, - "candidate_index": candidate_index, - "candidate_count": len(candidates), - }, - ) - 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, - retry_on_rate_limit=False, - load_proofs=False, - ) - quote = await run_mint_operation( - lambda: wallet.request_mint(amount), - op_name=op_name, - mint_url=mint_url, - retry_timeouts=False, - retry_on_rate_limit=False, - ) - logger.info( - "Destination mint selected", - extra={ - "event": "cashu_destination_selected", - "mint_url": mint_url, - "op_name": op_name, - "candidate_index": candidate_index, - "fallback_used": candidate_index > 1, - }, - ) - return wallet, mint_url, quote - except Exception as error: - tried.append(f"{mint_url}: {type(error).__name__}") - connection_failure = is_mint_connection_error(error) - rate_limited = is_mint_rate_limited(error) - if not connection_failure and not rate_limited: - raise - if connection_failure: - MintRateGuard.get(mint_url).apply_cooldown( - MINT_TRANSPORT_COOLDOWN_SECONDS, reason="unreachable" - ) - logger.warning( - "Destination mint failed", - extra={ - "event": "cashu_destination_failed", - "failed_mint": mint_url, - "error": str(error), - "error_type": type(error).__name__, - "connection_failure": connection_failure, - "rate_limited": rate_limited, - "tried": tried, - "op_name": op_name, - "candidate_index": candidate_index, - "candidate_count": len(candidates), - }, - ) - continue - logger.error( - "All trusted destination mints failed", - extra={ - "event": "cashu_destination_exhausted", - "op_name": op_name, - "amount": amount, - "unit": settings.primary_mint_unit, - "candidates": candidates, - "tried": tried, - }, - ) - raise MintConnectionError(f"All mints failed for {op_name}: {tried}") - - -async def _calculate_swap_amount( - amount_msat: int, - token_unit: str, - token_mint_url: str, - token_wallet: Wallet, - primary_wallet: Wallet | None, - proofs: list, - destination_mints: list[str] | None = None, -) -> int: - """ - Calculate the amount to mint on the primary mint after accounting for - melt fees and NUT-02 input fees on the foreign mint. - """ - if settings.primary_mint_unit == "sat": - receive_amount = _msats_to_sats(amount_msat) - else: - receive_amount = amount_msat - - if token_mint_url == settings.primary_mint: - logger.info( - "swap_to_trusted_mint: skipping fee estimation (same mint)", - extra={"minted_amount": receive_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_trusted_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_trusted_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, - }, - ) - - stage = "destination_fee_quote" - try: - _, _, dummy_mint_quote = await _request_mint_with_fallback( - receive_amount, - op_name="swap_fee_est_mint_quote", - primary_wallet=primary_wallet, - destination_mints=destination_mints, - ) - stage = "source_fee_quote" - dummy_melt_quote = await run_mint_operation( - 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 - input_fees = token_wallet.get_fees_for_proofs(proofs) - total_fees = fee_reserve + input_fees - minted_amount = _net_minted_amount(amount_msat, token_unit, total_fees) - - if minted_amount <= 0: - raise ValueError(f"Fees ({total_fees} {token_unit}) exceed token amount") - - logger.info( - "swap_to_trusted_mint: fee estimation result", - extra={ - "token_amount_sat": _msats_to_sats(amount_msat), - "estimated_fee": total_fees, - "estimated_fee_unit": token_unit, - "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 - - except Exception as e: - logger.error( - "Cashu swap fee estimation failed", - extra={ - "event": "cashu_swap_fee_estimation_failed", - "stage": stage, - "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): - if stage == "source_fee_quote": - logger.error( - "Source mint is unreachable; destination fallback cannot spend its proofs", - extra={ - "event": "cashu_source_mint_unreachable", - "source_mint": token_mint_url, - "stage": stage, - "fallback_possible": False, - "reason": "cashu_proofs_are_bound_to_the_issuing_mint", - }, - ) - raise SourceMintConnectionError( - "Issuing Cashu mint is unreachable" - ) from e - raise MintConnectionError("Cashu mint is unreachable") from e - raise ValueError(f"Failed to estimate fees: {e}") from e - - -async def _reconcile_ambiguous_melt( - wallet: Wallet, quote_id: str, proofs: list[Proof] -) -> bool: - """Confirm a dispatched melt is paid or conservatively mark it ambiguous. - - A PAID quote is authoritative and does not require a proof-state lookup. - Every other immediate snapshot remains unsafe to retry: an in-flight - Lightning payment can still move UNPAID/UNSPENT to PENDING or PAID after the - cancelled HTTP request returns. - """ - try: - quote = await run_mint_operation( - lambda: wallet.get_melt_quote(quote_id), - op_name="reconcile_swap_melt_quote", - mint_url=str(wallet.url), - retry_timeouts=False, - ) - except Exception as error: - raise TokenConsumedError( - "Source melt outcome is unknown; reconciliation required" - ) from error - - if quote is not None and quote.state == MeltQuoteState.paid: - return True - - try: - proof_response = await run_mint_operation( - lambda: wallet.check_proof_state(proofs), - op_name="reconcile_swap_proofs", - mint_url=str(wallet.url), - retry_timeouts=False, - ) - proof_states = [state.state.value for state in proof_response.states] - except Exception: - proof_states = [] - - quote_state = getattr(getattr(quote, "state", None), "value", "unknown") - raise TokenConsumedError( - "Source melt outcome is ambiguous; reconciliation required " - f"(quote_state={quote_state}, proof_states={proof_states})" - ) - - -async def _confirm_melt_paid( - wallet: Wallet, quote_id: str, proofs: list[Proof], response: object -) -> bool: - """Accept a melt response only when PAID is explicit or reconciled.""" - if getattr(response, "state", None) == MeltQuoteState.paid: - return True - return await _reconcile_ambiguous_melt(wallet, quote_id, proofs) - - -async def swap_to_trusted_mint( - token_obj: Token, - token_wallet: Wallet, - *, - destination_mints: list[str] | None = None, -) -> tuple[int, str, str]: - logger.info( - "Starting Cashu cross-mint swap", - extra={ - "event": "cashu_swap_started", - "source_mint": token_obj.mint, - "token_amount": token_obj.amount, - "unit": token_obj.unit, - "primary_mint": settings.primary_mint, - }, - ) - # Ensure amount is an integer - if not isinstance(token_obj.amount, int): - token_amount = int(token_obj.amount) - else: - token_amount = token_obj.amount - - if token_obj.unit == "sat": - amount_msat = _sats_to_msats(token_amount) - elif token_obj.unit == "msat": - amount_msat = token_amount - else: - raise ValueError("Invalid unit") - destination_candidates = _trusted_destination_candidates(destination_mints) - # If the token is already from an allowed destination, redeem it same-mint. - # There's no melt/Lightning fee, but the mint's NUT-02 input fee still - # applies; _redeem_same_mint accounts for it. - if token_obj.mint in destination_candidates: - logger.info( - "swap_to_trusted_mint: token already on primary mint, skipping swap", - extra={ - "mint": token_obj.mint, - "amount": token_amount, - "unit": token_obj.unit, - }, - ) - return await _redeem_same_mint(token_wallet, token_obj) - - try: - proofs = await _load_and_resolve_token_proofs( - token_wallet, - token_obj, - op_name="swap_load_source_mint", - ) - except Exception as error: - if is_mint_connection_error(error): - raise SourceMintConnectionError( - "Issuing Cashu mint is unreachable" - ) from error - raise - - primary_wallet: Wallet | None = None - - minted_amount = await _calculate_swap_amount( - amount_msat, - token_obj.unit, - token_obj.mint, - token_wallet, - primary_wallet, - proofs, - destination_candidates, - ) - - # The estimate above is non-binding: the mint may demand a higher fee on the - # real quote or reject the melt outright. Retry the quote/melt cycle with the - # amount recomputed from the fees the mint actually demands. - observed_extra_fee = 0 - attempt = 0 - dest_wallet = primary_wallet - dest_mint_url = settings.primary_mint - while True: - attempt += 1 - if minted_amount <= 0: - logger.error( - "swap_to_trusted_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, - destination_mints=destination_candidates, - ) - logger.info( - "swap_to_trusted_mint: mint quote received", - extra={ - "mint_quote_id": mint_quote.quote, - "attempt": attempt, - "dest_mint": dest_mint_url, - }, - ) - - logger.info( - "Requesting melt quote from source mint", - extra={ - "event": "cashu_source_melt_quote_attempt", - "source_mint": token_obj.mint, - "destination_mint": dest_mint_url, - "attempt": attempt, - }, - ) - try: - melt_quote = await run_mint_operation( - 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): - logger.error( - "Source mint is unreachable; destination fallback cannot spend its proofs", - extra={ - "event": "cashu_source_mint_unreachable", - "source_mint": token_obj.mint, - "destination_mint": dest_mint_url, - "stage": "source_melt_quote", - "error": str(error), - "error_type": type(error).__name__, - "attempt": attempt, - }, - ) - raise SourceMintConnectionError( - "Issuing Cashu mint is unreachable" - ) from error - raise - input_fees = token_wallet.get_fees_for_proofs(proofs) - total_needed = melt_quote.amount + melt_quote.fee_reserve + input_fees - logger.info( - "swap_to_trusted_mint: melt quote received", - extra={ - "melt_quote_id": melt_quote.quote, - "melt_amount": melt_quote.amount, - "melt_fee_reserve": melt_quote.fee_reserve, - "input_fees": input_fees, - "total_needed": total_needed, - "token_amount": token_amount, - "attempt": attempt, - }, - ) - - if total_needed > token_amount: - recomputed = _net_minted_amount( - amount_msat, - token_obj.unit, - melt_quote.fee_reserve + input_fees + observed_extra_fee, - ) - if attempt >= _MAX_SWAP_ATTEMPTS or recomputed <= 0: - logger.warning( - "swap_to_trusted_mint: insufficient token amount for melt fees", - extra={ - "token_amount": token_amount, - "melt_amount": melt_quote.amount, - "melt_fee_reserve": melt_quote.fee_reserve, - "input_fees": input_fees, - "total_needed": total_needed, - "shortfall": total_needed - token_amount, - "attempts": attempt, - }, - ) - raise ValueError( - f"Token amount ({token_amount} {token_obj.unit}) is insufficient to cover " - f"melt fees. Needed: {total_needed} {token_obj.unit} " - f"(amount: {melt_quote.amount} + fee: {melt_quote.fee_reserve} + input_fees: {input_fees})" - ) - logger.warning( - "swap_to_trusted_mint: melt quote exceeds token amount, retrying", - extra={ - "total_needed": total_needed, - "token_amount": token_amount, - "retry_minted_amount": recomputed, - "attempt": attempt, - }, - ) - minted_amount = recomputed - continue - - try: - melt_response = await run_mint_operation( - lambda: token_wallet.melt( - proofs=proofs, - invoice=mint_quote.request, - fee_reserve_sat=melt_quote.fee_reserve, - quote_id=melt_quote.quote, - ), - op_name="swap_melt", - mint_url=token_obj.mint, - retry_timeouts=False, - ) - await _confirm_melt_paid( - token_wallet, melt_quote.quote, proofs, melt_response - ) - except Exception as e: - shortfall = _melt_insufficient_shortfall(e) - if shortfall is None: - if isinstance(e, TokenConsumedError): - raise - if _melt_definitively_failed(e): - raise ValueError( - f"Failed to melt token from foreign mint {token_obj.mint}: {e}" - ) from e - if is_mint_connection_error(e): - await _reconcile_ambiguous_melt( - token_wallet, melt_quote.quote, proofs - ) - logger.info( - "Source melt reconciled as paid; minting on destination", - extra={ - "event": "cashu_source_melt_reconciled_paid", - "source_mint": token_obj.mint, - "destination_mint": dest_mint_url, - "melt_quote_id": melt_quote.quote, - }, - ) - break - raise TokenConsumedError( - "Source melt failed after dispatch; outcome requires reconciliation" - ) from e - - observed_extra_fee += shortfall - recomputed = _net_minted_amount( - amount_msat, - token_obj.unit, - melt_quote.fee_reserve + input_fees + observed_extra_fee, - ) - if attempt >= _MAX_SWAP_ATTEMPTS or recomputed <= 0: - logger.error( - "swap_to_trusted_mint: melt failed", - extra={ - "error": str(e), - "error_type": type(e).__name__, - "foreign_mint": token_obj.mint, - "token_amount": token_amount, - "melt_quote_id": melt_quote.quote, - "total_needed": total_needed, - "attempts": attempt, - }, - ) - raise ValueError( - f"Failed to melt token from foreign mint {token_obj.mint}: {e}" - ) from e - logger.warning( - "swap_to_trusted_mint: mint demanded more than quoted at melt, retrying", - extra={ - "shortfall": shortfall, - "retry_minted_amount": recomputed, - "attempt": attempt, - }, - ) - minted_amount = recomputed - continue - - break - - logger.info( - "Source melt succeeded; minting on destination", - extra={ - "event": "cashu_destination_mint_attempt", - "minted_amount": minted_amount, - "mint_quote_id": mint_quote.quote, - "dest_mint": dest_mint_url, - }, - ) - - await dest_wallet.load_proofs(reload=True) - pre_mint_balance = dest_wallet.available_balance.amount - try: - _ = await run_mint_operation( - lambda: dest_wallet.mint(minted_amount, quote_id=mint_quote.quote), - op_name="swap_mint_on_destination", - mint_url=dest_mint_url, - retry_timeouts=False, - ) - except Exception as e: - if "11003" in str(e) or "outputs already signed" in str(e).lower(): - # Previous mint call signed outputs at the mint but failed before - # bump_secret_derivation ran locally. Recover orphaned proofs and - # advance the counter so the next request derives fresh secrets. - logger.warning( - "swap_to_trusted_mint: outputs already signed — recovering orphaned proofs", - extra={ - "mint_quote_id": mint_quote.quote, - "minted_amount": minted_amount, - }, - ) - try: - for keyset_id in dest_wallet.keysets: - await dest_wallet.restore_tokens_for_keyset( - keyset_id, to=1, batch=25 - ) - await dest_wallet.load_proofs(reload=True) - post_recovery_balance = dest_wallet.available_balance.amount - balance_gained = post_recovery_balance - pre_mint_balance - logger.info( - "swap_to_trusted_mint: recovery scan completed", - extra={ - "pre_mint_balance": pre_mint_balance, - "post_recovery_balance": post_recovery_balance, - "balance_gained": balance_gained, - "expected": minted_amount, - }, - ) - if balance_gained < minted_amount: - # Recovery scan ran but did NOT restore the orphaned proofs - # (mint reports them as spent — they're stuck). Refuse to - # credit the API key balance for proofs we don't actually hold. - raise TokenConsumedError( - f"Swap recovery failed: mint signed outputs but proofs are " - f"unrecoverable (mint reports them spent). " - f"Expected {minted_amount}, recovered {balance_gained}. " - f"Local wallet DB ('.wallet/') state is corrupted — " - f"the counter for keyset is stuck at a bad index range." - ) - except TokenConsumedError: - raise - except Exception as recovery_err: - logger.error( - "swap_to_trusted_mint: recovery failed", - extra={"error": str(recovery_err)}, - ) - raise TokenConsumedError( - f"Mint on primary failed and recovery unsuccessful: {e}" - ) from e - else: - logger.error( - "swap_to_trusted_mint: mint on primary failed after successful melt", - extra={ - "error": str(e), - "error_type": type(e).__name__, - "minted_amount": minted_amount, - "mint_quote_id": mint_quote.quote, - }, - ) - # Foreign proofs already melted (spent) — non-retryable. - raise TokenConsumedError( - "Mint on primary failed after successful melt" - ) from e - - logger.info( - "Cashu cross-mint swap completed", - extra={ - "event": "cashu_swap_completed", - "source_mint": token_obj.mint, - "dest_mint": dest_mint_url, - "original_amount": token_amount, - "minted_amount": minted_amount, - "unit": settings.primary_mint_unit, - }, - ) - - return int(minted_amount), settings.primary_mint_unit, dest_mint_url - - -async def swap_to_primary_mint( - token_obj: Token, token_wallet: Wallet -) -> tuple[int, str, str]: - """Backward-compatible alias for callers using the old function name.""" - return await swap_to_trusted_mint(token_obj, token_wallet) - - async def credit_balance( cashu_token: str, key: db.ApiKey, session: db.AsyncSession ) -> int: @@ -1738,10 +1026,8 @@ async def _credit_balance_locked( ) try: - destination_mint = key.refund_mint_url or settings.primary_mint amount, unit, mint_url = await recieve_token( cashu_token, - destination_mint=destination_mint, destination_unit=key.refund_currency if isinstance(key.refund_currency, str) else None, @@ -1844,6 +1130,7 @@ async def _credit_balance_locked( "cashu_source_mint_unreachable", "cashu_mint_unreachable", "cashu_mint_rate_limited", + "cashu_mint_timeout", } log = ( logger.info diff --git a/tests/integration/test_swap_fee_retry.py b/tests/integration/test_swap_fee_retry.py deleted file mode 100644 index 8d4317c0..00000000 --- a/tests/integration/test_swap_fee_retry.py +++ /dev/null @@ -1,210 +0,0 @@ -""" -Integration tests for reactive swap fee retries via the wallet topup endpoint. - -Foreign-mint tokens are swapped to the primary mint using the foreign mint's -melt quote, whose fee_reserve is a non-binding estimate (NUT-05): the mint may -demand more when re-quoting or at melt execution. These tests cover the -endpoint behaviour in those cases: - -1. The mint demands one sat more at melt time than every quote reported - (the mint.cubabitcoin.org incident): the swap retries with a smaller - invoice and the topup succeeds, crediting the recomputed amount. -2. The real melt quote reports a higher fee_reserve than the estimate: the - swap re-quotes from the observed fee and the topup succeeds. -3. The mint escalates its fee demands on every attempt: the retry budget is - exhausted and the endpoint returns 400 with a clear error (never 500), - without ever executing a melt. -""" - -from collections.abc import Callable -from unittest.mock import AsyncMock, Mock, patch - -import pytest -from cashu.core.base import MeltQuoteState -from httpx import AsyncClient, Response - -from routstr.core.settings import settings - -# Captured at collection time, before the integration_app fixture replaces it -# with the testmint stub that bypasses swapping (see conftest.py). -from routstr.wallet import recieve_token as _real_recieve_token - -# Match the authenticated fixture's persisted refund mint: existing-key topups -# are intentionally constrained to that mint for collateral provenance. -PRIMARY_MINT = "http://localhost:3338" - - -def _make_swap_mocks( - token_amount: int, - fee_reserves: list[int], - input_fees: int = 0, - mint_url: str = "http://foreign-mint:3338", -) -> tuple[Mock, Mock, Mock]: - """Return (token, token_wallet, primary_wallet) mocks that act like a mint. - - Mint quotes pass the requested amount through their ``request`` field and - melt quotes echo that amount back, so the mocks stay consistent for - whatever amounts the implementation requests. ``fee_reserves`` supplies the - fee_reserve of each successive melt quote (the first serves the estimation - pass); requesting more quotes than provided fails the test. - """ - mock_token = Mock() - mock_token.mint = mint_url - mock_token.unit = "sat" - mock_token.amount = token_amount - mock_token.keysets = ["keyset1"] - mock_token.proofs = [Mock(amount=token_amount)] - - mock_token_wallet = Mock() - mock_token_wallet.load_mint_keysets = AsyncMock() - mock_token_wallet.activate_keyset = AsyncMock() - mock_token_wallet._expand_short_keyset_ids = AsyncMock() - mock_token_wallet.load_proofs = AsyncMock() - mock_token_wallet.get_fees_for_proofs = Mock(return_value=input_fees) - - mock_primary_wallet = Mock() - mock_primary_wallet.load_mint = AsyncMock() - mock_primary_wallet.load_proofs = AsyncMock() - mock_primary_wallet.available_balance = Mock(amount=0) - mock_primary_wallet.mint = AsyncMock(return_value=Mock()) - - fees = iter(fee_reserves) - - def _next_fee() -> int: - try: - return next(fees) - except StopIteration: - raise AssertionError( - "more melt quotes requested than fee_reserves provided" - ) from None - - mock_primary_wallet.request_mint = AsyncMock( - side_effect=lambda amount: Mock(quote=f"mint_quote_{amount}", request=amount) - ) - mock_token_wallet.melt_quote = AsyncMock( - side_effect=lambda invoice: Mock( - quote=f"melt_quote_{invoice}", amount=invoice, fee_reserve=_next_fee() - ) - ) - mock_token_wallet.melt = AsyncMock( - return_value=Mock(state=MeltQuoteState.paid) - ) - - return mock_token, mock_token_wallet, mock_primary_wallet - - -def _wallet_router(primary_wallet: Mock, token_wallet: Mock) -> Callable[..., Mock]: - """Route get_wallet calls to the primary or foreign wallet mock by URL.""" - - def fake_get_wallet( - mint_url: str, - unit: str = "sat", - load: bool = True, - **kwargs: object, - ) -> Mock: - return primary_wallet if mint_url == PRIMARY_MINT else token_wallet - - return fake_get_wallet - - -async def _post_topup( - client: AsyncClient, - mock_token: Mock, - token_wallet: Mock, - primary_wallet: Mock, -) -> Response: - """POST /v1/wallet/topup with the swap layer mocked at the mint boundary. - - The conftest's testmint stub for recieve_token is swapped back for the - real implementation so the request exercises the actual swap path. - """ - with patch("routstr.wallet.recieve_token", _real_recieve_token): - with patch( - "routstr.wallet.deserialize_token_from_string", return_value=mock_token - ): - with patch( - "routstr.wallet.get_wallet", - side_effect=_wallet_router(primary_wallet, token_wallet), - ): - with patch.object(settings, "primary_mint", PRIMARY_MINT): - with patch.object(settings, "primary_mint_unit", "sat"): - with patch.object(settings, "cashu_mints", [PRIMARY_MINT]): - return await client.post( - "/v1/wallet/topup", - params={"cashu_token": "cashuAtest_foreign_token"}, - ) - - -@pytest.mark.integration -@pytest.mark.asyncio -async def test_topup_retries_when_melt_demands_more_than_quoted( - authenticated_client: AsyncClient, -) -> None: - """A 179-sat token where every quote reports fee_reserve=1 but the mint - rejects the first melt demanding 180. The retry shrinks the invoice to 177 - and the topup credits 177 sats (177_000 msats).""" - mock_token, token_wallet, primary_wallet = _make_swap_mocks( - 179, fee_reserves=[1, 1, 1], mint_url="http://mint.cubabitcoin.org" - ) - token_wallet.melt.side_effect = [ - Exception( - "Mint Error: not enough inputs provided for melt. " - "Provided: 179, needed: 180 (Code: 11000)" - ), - Mock(state=MeltQuoteState.paid), - ] - - response = await _post_topup( - authenticated_client, mock_token, token_wallet, primary_wallet - ) - - assert response.status_code == 200 - assert response.json()["msats"] == 177_000 - assert token_wallet.melt.call_count == 2 - - -@pytest.mark.integration -@pytest.mark.asyncio -async def test_topup_retries_when_quote_fee_exceeds_estimate( - authenticated_client: AsyncClient, -) -> None: - """A 1000-sat token estimated at fee 20, but the real quote demands 23. - The retry recomputes 1000 - 23 = 977, which fits, and the topup credits - 977 sats (977_000 msats) with a single melt.""" - mock_token, token_wallet, primary_wallet = _make_swap_mocks( - 1000, fee_reserves=[20, 23, 23] - ) - - response = await _post_topup( - authenticated_client, mock_token, token_wallet, primary_wallet - ) - - assert response.status_code == 200 - assert response.json()["msats"] == 977_000 - assert token_wallet.melt.call_count == 1 - - -@pytest.mark.integration -@pytest.mark.asyncio -async def test_topup_returns_422_when_retries_exhausted( - authenticated_client: AsyncClient, -) -> None: - """A mint that escalates fee_reserve on every re-quote (1 → 10 → 25 → 50) - exhausts the retry budget: clean 422 mint_error/too-small taxonomy, melt - never executed.""" - mock_token, token_wallet, primary_wallet = _make_swap_mocks( - 1000, fee_reserves=[1, 10, 25, 50] - ) - - response = await _post_topup( - authenticated_client, mock_token, token_wallet, primary_wallet - ) - - assert response.status_code == 422 - raw_detail = response.json()["detail"] - message = ( - raw_detail["error"]["message"] if isinstance(raw_detail, dict) else raw_detail - ) - assert "too small to cover swap fees" in message - assert token_wallet.melt_quote.call_count == 4 # estimation + 3 attempts - token_wallet.melt.assert_not_called() diff --git a/tests/integration/test_topup_untrusted_mint.py b/tests/integration/test_topup_untrusted_mint.py new file mode 100644 index 00000000..99604fd3 --- /dev/null +++ b/tests/integration/test_topup_untrusted_mint.py @@ -0,0 +1,56 @@ +""" +Integration test for the wallet topup endpoint with a foreign-mint token. + +Tokens are only accepted from trusted mints (primary_mint plus cashu_mints) +and are always redeemed on the mint that issued them. A token from any other +mint is rejected offline, before any network contact with that mint, with a +dedicated error type and code. This replaces the former cross-mint swap +path, so there is no fee-retry behaviour left to exercise here. +""" + +from unittest.mock import AsyncMock, Mock, patch + +import pytest +from httpx import AsyncClient + +from routstr.core.settings import settings + +# Captured at collection time, before the integration_app fixture replaces it +# with the testmint stub (see conftest.py). +from routstr.wallet import recieve_token as _real_recieve_token + +PRIMARY_MINT = "http://localhost:3338" +FOREIGN_MINT = "http://foreign-mint:3338" + + +@pytest.mark.integration +@pytest.mark.asyncio +async def test_topup_with_foreign_mint_token_is_rejected_without_mint_contact( + authenticated_client: AsyncClient, +) -> None: + mock_token = Mock() + mock_token.mint = FOREIGN_MINT + mock_token.unit = "sat" + mock_token.amount = 1000 + mock_token.keysets = ["keyset"] + get_wallet = AsyncMock() + + with ( + patch("routstr.wallet.recieve_token", _real_recieve_token), + patch("routstr.wallet.deserialize_token_from_string", return_value=mock_token), + patch("routstr.wallet.get_wallet", get_wallet), + patch.object(settings, "primary_mint", PRIMARY_MINT), + patch.object(settings, "primary_mint_unit", "sat"), + patch.object(settings, "cashu_mints", [PRIMARY_MINT]), + ): + response = await authenticated_client.post( + "/v1/wallet/topup", + params={"cashu_token": "cashuAtest_foreign_token"}, + ) + + assert response.status_code == 400 + error = response.json()["detail"]["error"] + assert error["type"] == "untrusted_mint" + assert error["code"] == "cashu_untrusted_source_mint" + assert FOREIGN_MINT not in error["message"] + get_wallet.assert_not_awaited() diff --git a/tests/unit/test_auth_cashu.py b/tests/unit/test_auth_cashu.py index 29421231..b221fbef 100644 --- a/tests/unit/test_auth_cashu.py +++ b/tests/unit/test_auth_cashu.py @@ -88,7 +88,7 @@ async def test_failed_first_cashu_redemption_rolls_back_empty_api_key( httpx.ConnectError("All connection attempts failed"), 503, "mint_unreachable", - "Cashu mint is unreachable", + "Cashu mint is unreachable; retry later", "cashu_mint_unreachable", ), ( @@ -96,7 +96,7 @@ async def test_failed_first_cashu_redemption_rolls_back_empty_api_key( MintConnectionError("connect to http://mint:3338 refused"), 503, "mint_unreachable", - "Cashu mint is unreachable", + "Cashu mint is unreachable; retry later", "cashu_mint_unreachable", ), ( @@ -104,16 +104,16 @@ async def test_failed_first_cashu_redemption_rolls_back_empty_api_key( _value_error_wrapping_transport(), 503, "mint_unreachable", - "Cashu mint is unreachable", + "Cashu mint is unreachable; retry later", "cashu_mint_unreachable", ), ( # asyncio.TimeoutError is builtin TimeoutError on 3.11+. TimeoutError("Timed out connecting to Cashu mint http://mint:3338"), 503, - "mint_unreachable", - "Cashu mint is unreachable", - "cashu_mint_unreachable", + "mint_timeout", + "Cashu mint did not respond in time; retry later", + "cashu_mint_timeout", ), ( ValueError( @@ -134,15 +134,6 @@ async def test_failed_first_cashu_redemption_rolls_back_empty_api_key( "Token value is too small to cover swap fees", "cashu_token_swap_fees_exceed_amount", ), - ( - ValueError( - "Failed to melt token from foreign mint http://foreign:3338: boom" - ), - 422, - "mint_error", - "Failed to swap token from foreign mint", - "cashu_foreign_mint_swap_failed", - ), ( ValueError("could not decode token"), 400, diff --git a/tests/unit/test_balance.py b/tests/unit/test_balance.py index 3812b722..75045168 100644 --- a/tests/unit/test_balance.py +++ b/tests/unit/test_balance.py @@ -585,14 +585,31 @@ def _envelope(exc: HTTPException) -> dict: @pytest.mark.asyncio @pytest.mark.parametrize( - "error", + ("error", "expected_type", "expected_code", "expected_message"), [ - httpx.ConnectError("All connection attempts failed"), - MintConnectionError("connect to mint refused"), - TimeoutError("timed out connecting to mint"), + ( + httpx.ConnectError("All connection attempts failed"), + "mint_unreachable", + "cashu_mint_unreachable", + "Cashu mint is unreachable; retry later", + ), + ( + MintConnectionError("connect to mint refused"), + "mint_unreachable", + "cashu_mint_unreachable", + "Cashu mint is unreachable; retry later", + ), + ( + TimeoutError("timed out connecting to mint"), + "mint_timeout", + "cashu_mint_timeout", + "Cashu mint did not respond in time; retry later", + ), ], ) -async def test_topup_mint_unreachable_returns_503(error: Exception) -> None: +async def test_topup_mint_unreachable_returns_503( + error: Exception, expected_type: str, expected_code: str, expected_message: str +) -> None: """A down mint must surface 503 (retryable), not 400 or 500 — the token is fine, so the client should retry once the mint recovers.""" from fastapi import HTTPException @@ -610,9 +627,9 @@ async def test_topup_mint_unreachable_returns_503(error: Exception) -> None: assert exc_info.value.status_code == 503 err = _envelope(exc_info.value) - assert err["type"] == "mint_unreachable" - assert err["code"] == "cashu_mint_unreachable" - assert err["message"] == "Cashu mint is unreachable" + assert err["type"] == expected_type + assert err["code"] == expected_code + assert err["message"] == expected_message @pytest.mark.asyncio @@ -637,7 +654,7 @@ async def test_topup_unreachable_source_mint_explains_why_fallback_is_impossible err = _envelope(exc_info.value) assert err["type"] == "mint_unreachable" assert err["code"] == "cashu_source_mint_unreachable" - assert "cannot be redeemed at another mint" in err["message"] + assert "retry later" in err["message"] @pytest.mark.asyncio @@ -749,13 +766,6 @@ async def test_topup_token_consumed_returns_500() -> None: "cashu_token_swap_fees_exceed_amount", "Token value is too small to cover swap fees", ), - ( - ValueError("Failed to melt token from foreign mint http://m: boom"), - 422, - "mint_error", - "cashu_foreign_mint_swap_failed", - "Failed to swap token from foreign mint", - ), ], ) async def test_topup_fee_and_swap_failures_return_422( diff --git a/tests/unit/test_cashu_untrusted_source_mint.py b/tests/unit/test_cashu_untrusted_source_mint.py new file mode 100644 index 00000000..0129d25f --- /dev/null +++ b/tests/unit/test_cashu_untrusted_source_mint.py @@ -0,0 +1,345 @@ +"""Tokens issued by an untrusted mint are rejected before any mint contact. + +A client-supplied Cashu token names its own mint. Every redemption path used +to load that mint's keysets under ``wallet_operation_guard`` with the full +timeout-retry window, so a silent mint could hold the shared wallet lock for +minutes per request from unauthenticated endpoints. Now the mint must be +``primary_mint`` or one of ``cashu_mints``; anything else fails offline with a +dedicated error type and code. +""" + +from contextlib import ExitStack, contextmanager +from types import SimpleNamespace +from typing import AsyncGenerator, Iterator, cast +from unittest.mock import AsyncMock, patch + +import httpx +import pytest +from fastapi import HTTPException +from sqlalchemy.ext.asyncio import create_async_engine +from sqlalchemy.pool import StaticPool +from sqlmodel import SQLModel +from sqlmodel.ext.asyncio.session import AsyncSession + +from routstr.auth import validate_bearer_key +from routstr.core.settings import settings +from routstr.mint import MintCooldownError +from routstr.payment.helpers import check_token_balance +from routstr.wallet import ( + SourceMintConnectionError, + TokenConsumedError, + UntrustedSourceMintError, + classify_redemption_error, + is_mint_timeout, + is_trusted_source_mint, + recieve_token, + resolve_trusted_source_mint, +) + +PRIMARY = "http://primary:3338" +SECONDARY = "http://secondary:3338" +UNTRUSTED = "http://evil:3338" + + +@pytest.fixture +async def session() -> AsyncGenerator[AsyncSession, None]: + engine = create_async_engine( + "sqlite+aiosqlite://", + poolclass=StaticPool, + connect_args={"check_same_thread": False}, + ) + async with engine.begin() as conn: + await conn.run_sync(SQLModel.metadata.create_all) + db_session = AsyncSession(engine, expire_on_commit=False) + try: + yield db_session + finally: + await db_session.close() + await engine.dispose() + + +@contextmanager +def _trusted_mints() -> Iterator[None]: + with ExitStack() as stack: + stack.enter_context(patch.object(settings, "primary_mint", PRIMARY)) + stack.enter_context(patch.object(settings, "cashu_mints", [SECONDARY])) + yield + + +def _token(mint: str) -> SimpleNamespace: + return SimpleNamespace(mint=mint, unit="sat", amount=100, keysets=["k"]) + + +def test_is_trusted_source_mint() -> None: + with _trusted_mints(): + assert is_trusted_source_mint(PRIMARY) + assert is_trusted_source_mint(SECONDARY) + assert not is_trusted_source_mint(UNTRUSTED) + + +@pytest.mark.parametrize( + "configured,token_mint", + [ + ("https://mint.example", "https://mint.example/"), + ("https://mint.example/", "https://mint.example"), + ("https://mint.example", "https://mint.example///"), + ("https://mint.example", "HTTPS://MINT.EXAMPLE"), + ("HTTPS://Mint.Example", "https://mint.example"), + ("https://mint.example", "https://mint.example:443"), + ("https://mint.example:443", "https://mint.example"), + ("http://mint.example", "http://mint.example:80"), + (" https://mint.example/ ", "https://mint.example"), + ("https://mint.example/Bitcoin", "https://mint.example/Bitcoin/"), + ], +) +def test_trusted_mint_matching_ignores_cosmetic_url_differences( + configured: str, token_mint: str +) -> None: + with patch.object(settings, "primary_mint", configured): + with patch.object(settings, "cashu_mints", []): + assert is_trusted_source_mint(token_mint) + + +@pytest.mark.parametrize( + "token_mint", + [ + "https://mint.example@evil.example", + "https://mint.example:pw@evil.example", + "https://mint.example.evil.example", + "https://evil.example/?x=https://mint.example", + "https://evil.example#https://mint.example", + "https://mint.example:8443", + "http://mint.example", + "https://mint.example.", + "https://mint.example/bitcoin", + "https://evil.example", + ], +) +def test_trusted_mint_matching_never_folds_onto_another_host(token_mint: str) -> None: + """Normalization must not become an accept-bypass: only cosmetic spelling + differences may fold, never anything that can resolve somewhere else.""" + with patch.object(settings, "primary_mint", "https://mint.example/Bitcoin"): + with patch.object(settings, "cashu_mints", ["https://mint.example"]): + assert not is_trusted_source_mint(token_mint) + + +@pytest.mark.parametrize( + "token_mint", + [ + "https://mint.exa\tmple", + "https://mint.exa\nmple", + "https://mint.exa\rmple", + ], +) +def test_trusted_mint_matching_rejects_embedded_control_characters( + token_mint: str, +) -> None: + """``urlsplit`` deletes tab/CR/LF before parsing, so such a URL would be + checked as one string and dialled as another.""" + with patch.object(settings, "primary_mint", "https://mint.example"): + with patch.object(settings, "cashu_mints", []): + assert not is_trusted_source_mint(token_mint) + + +@pytest.mark.parametrize( + "token_mint", + ["", " ", "https://", "://mint.example", "mint.example"], +) +def test_trusted_mint_matching_rejects_degenerate_urls(token_mint: str) -> None: + with patch.object(settings, "primary_mint", "https://mint.example"): + with patch.object(settings, "cashu_mints", []): + assert not is_trusted_source_mint(token_mint) + + +def test_unset_primary_mint_never_makes_a_token_trusted() -> None: + """An unset primary mint must not turn an empty token mint into a match.""" + with patch.object(settings, "primary_mint", ""): + with patch.object(settings, "cashu_mints", []): + assert not is_trusted_source_mint("") + assert not is_trusted_source_mint("https://evil.example") + + +def test_trusted_mint_matching_keeps_path_case_sensitive() -> None: + with patch.object(settings, "primary_mint", "https://mint.minibits.cash/Bitcoin"): + with patch.object(settings, "cashu_mints", []): + assert is_trusted_source_mint("https://mint.minibits.cash/Bitcoin/") + assert not is_trusted_source_mint("https://mint.minibits.cash/bitcoin") + + +def test_trusted_mint_matching_rejects_unparseable_port() -> None: + with patch.object(settings, "primary_mint", "https://mint.example"): + with patch.object(settings, "cashu_mints", []): + assert not is_trusted_source_mint("https://mint.example:notaport") + + +def test_trusted_mint_matching_rejects_malformed_url() -> None: + with patch.object(settings, "primary_mint", "https://mint.example"): + with patch.object(settings, "cashu_mints", []): + assert not is_trusted_source_mint("https://[::1/Bitcoin") + assert resolve_trusted_source_mint("https://[::1/Bitcoin") is None + + +def test_resolve_returns_operator_spelling() -> None: + configured = "https://mint.example/Bitcoin" + with patch.object(settings, "primary_mint", configured): + with patch.object(settings, "cashu_mints", []): + assert ( + resolve_trusted_source_mint("HTTPS://MINT.EXAMPLE:443/Bitcoin///") + == configured + ) + + +@pytest.mark.asyncio +async def test_recieve_token_uses_canonical_mint_url() -> None: + variant = PRIMARY.upper() + "///" + get_wallet = AsyncMock(return_value=object()) + redeem = AsyncMock(return_value=(90, "sat", variant)) + with ( + _trusted_mints(), + patch( + "routstr.wallet.deserialize_token_from_string", + return_value=_token(variant), + ), + patch("routstr.wallet.get_wallet", get_wallet), + patch("routstr.wallet._redeem_same_mint", redeem), + ): + amount, unit, mint_url = await recieve_token("cashuAvariant") + + assert (amount, unit, mint_url) == (90, "sat", PRIMARY) + get_wallet.assert_awaited_once_with(PRIMARY, "sat", load=False) + + +def test_classification_has_dedicated_type_and_code() -> None: + classified = classify_redemption_error(UntrustedSourceMintError("x")) + assert classified == ( + "untrusted_mint", + 400, + "Cashu token was issued by a mint this node does not accept", + "cashu_untrusted_source_mint", + ) + + +@pytest.mark.asyncio +async def test_recieve_token_rejects_untrusted_mint_before_mint_contact() -> None: + """The gate runs inside the wallet lock but before ``get_wallet``, so an + untrusted token never reaches the mint over the network.""" + get_wallet = AsyncMock() + with ( + _trusted_mints(), + patch( + "routstr.wallet.deserialize_token_from_string", + return_value=_token(UNTRUSTED), + ), + patch("routstr.wallet.get_wallet", get_wallet), + ): + with pytest.raises(UntrustedSourceMintError): + await recieve_token("cashuAuntrusted") + + get_wallet.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_bearer_untrusted_mint_returns_400_with_dedicated_code( + session: AsyncSession, +) -> None: + get_wallet = AsyncMock() + with ( + _trusted_mints(), + patch( + "routstr.auth.deserialize_token_from_string", + return_value=_token(UNTRUSTED), + ), + patch( + "routstr.wallet.deserialize_token_from_string", + return_value=_token(UNTRUSTED), + ), + patch("routstr.wallet.get_wallet", get_wallet), + ): + with pytest.raises(HTTPException) as exc_info: + await validate_bearer_key("cashuAuntrusted", session) + + assert exc_info.value.status_code == 400 + detail = cast(dict[str, dict[str, str]], exc_info.value.detail) + assert detail["error"]["type"] == "untrusted_mint" + assert detail["error"]["code"] == "cashu_untrusted_source_mint" + get_wallet.assert_not_awaited() + + +def test_check_token_balance_rejects_untrusted_mint() -> None: + with ( + _trusted_mints(), + patch( + "routstr.payment.helpers.deserialize_token_from_string", + return_value=_token(UNTRUSTED), + ), + ): + with pytest.raises(HTTPException) as exc_info: + check_token_balance({"x-cashu": "cashuAuntrusted"}, {"model": "m"}, 1) + + assert exc_info.value.status_code == 400 + detail = cast(dict[str, dict[str, str]], exc_info.value.detail) + assert detail["error"]["type"] == "untrusted_mint" + assert detail["error"]["code"] == "cashu_untrusted_source_mint" + + +def test_check_token_balance_accepts_trusted_mints() -> None: + for mint in (PRIMARY, SECONDARY): + with ( + _trusted_mints(), + patch( + "routstr.payment.helpers.deserialize_token_from_string", + return_value=_token(mint), + ), + ): + check_token_balance({"x-cashu": "cashuAtrusted"}, {"model": "m"}, 1) + + +def _http_429(retry_after: str | None) -> httpx.HTTPStatusError: + request = httpx.Request("POST", "http://primary:3338/v1/swap") + headers = {"Retry-After": retry_after} if retry_after else {} + response = httpx.Response(429, request=request, headers=headers) + return httpx.HTTPStatusError("rate limited", request=request, response=response) + + +@pytest.mark.parametrize( + "error", + [_http_429("42"), _http_429(None), MintCooldownError(PRIMARY, 12.4)], +) +def test_rate_limit_asks_to_retry_later(error: Exception) -> None: + assert classify_redemption_error(error) == ( + "mint_rate_limited", + 503, + "Cashu mint is rate-limiting requests; retry later", + "cashu_mint_rate_limited", + ) + + +def test_timeout_has_its_own_code() -> None: + wrapped = SourceMintConnectionError("Issuing Cashu mint is unreachable") + wrapped.__cause__ = httpx.ReadTimeout("read timed out") + assert classify_redemption_error(wrapped) == ( + "mint_timeout", + 503, + "Cashu mint did not respond in time; retry later", + "cashu_mint_timeout", + ) + + +def test_source_mint_unreachable_asks_to_retry() -> None: + wrapped = SourceMintConnectionError("Issuing Cashu mint is unreachable") + wrapped.__cause__ = httpx.ConnectError("refused") + assert classify_redemption_error(wrapped) == ( + "mint_unreachable", + 503, + "The mint that issued this Cashu token is unreachable; retry later", + "cashu_source_mint_unreachable", + ) + + +def test_timeout_wrapped_in_consumed_token_is_not_retryable() -> None: + consumed = TokenConsumedError("credit failed after melt") + consumed.__cause__ = httpx.ReadTimeout("read timed out") + classified = classify_redemption_error(consumed) + assert classified is not None + assert classified[3] == "cashu_token_consumed" + assert not is_mint_timeout(consumed) diff --git a/tests/unit/test_coverage_payment_helpers.py b/tests/unit/test_coverage_payment_helpers.py index b6ac57ed..415b67f3 100644 --- a/tests/unit/test_coverage_payment_helpers.py +++ b/tests/unit/test_coverage_payment_helpers.py @@ -8,6 +8,8 @@ from unittest.mock import Mock, patch import pytest +from routstr.core.settings import settings + # --------------------------------------------------------------------------- # check_token_balance # --------------------------------------------------------------------------- @@ -22,6 +24,7 @@ async def test_check_token_balance_x_cashu_present() -> None: with patch("routstr.payment.helpers.deserialize_token_from_string") as mock_deser: mock_token = Mock() + mock_token.mint = settings.primary_mint mock_token.amount = 50000 mock_token.unit = "sat" mock_deser.return_value = mock_token @@ -62,6 +65,7 @@ async def test_check_token_balance_insufficient_raises() -> None: with patch("routstr.payment.helpers.deserialize_token_from_string") as mock_deser: mock_token = Mock() + mock_token.mint = settings.primary_mint mock_token.amount = 100 # 100 sat mock_token.unit = "sat" mock_deser.return_value = mock_token diff --git a/tests/unit/test_melt_reconciliation.py b/tests/unit/test_melt_reconciliation.py deleted file mode 100644 index a68cb64b..00000000 --- a/tests/unit/test_melt_reconciliation.py +++ /dev/null @@ -1,91 +0,0 @@ -from unittest.mock import AsyncMock, Mock - -import pytest -from cashu.core.base import MeltQuoteState, ProofSpentState - -from routstr.wallet import ( - TokenConsumedError, - _confirm_melt_paid, - _reconcile_ambiguous_melt, -) - - -@pytest.mark.asyncio -async def test_paid_quote_is_authoritative_when_proof_lookup_would_fail() -> None: - wallet = Mock( - url="http://source-mint:3338", - get_melt_quote=AsyncMock(return_value=Mock(state=MeltQuoteState.paid)), - check_proof_state=AsyncMock(side_effect=RuntimeError("proof API unavailable")), - ) - - assert await _reconcile_ambiguous_melt(wallet, "quote-1", [Mock()]) is True - wallet.check_proof_state.assert_not_awaited() - - -@pytest.mark.asyncio -async def test_timeout_snapshot_unpaid_unspent_remains_non_retryable() -> None: - wallet = Mock( - url="http://source-mint:3338", - get_melt_quote=AsyncMock(return_value=Mock(state=MeltQuoteState.unpaid)), - check_proof_state=AsyncMock( - return_value=Mock(states=[Mock(state=ProofSpentState.unspent)]) - ), - ) - - with pytest.raises(TokenConsumedError, match="ambiguous"): - await _reconcile_ambiguous_melt(wallet, "quote-2", [Mock()]) - - -@pytest.mark.asyncio -async def test_successful_pending_melt_response_requires_reconciliation() -> None: - wallet = Mock( - url="http://source-mint:3338", - get_melt_quote=AsyncMock(return_value=Mock(state=MeltQuoteState.pending)), - check_proof_state=AsyncMock( - return_value=Mock(states=[Mock(state=ProofSpentState.pending)]) - ), - ) - - with pytest.raises(TokenConsumedError, match="ambiguous"): - await _confirm_melt_paid( - wallet, - "quote-pending", - [Mock()], - Mock(state=MeltQuoteState.pending), - ) - - -@pytest.mark.asyncio -@pytest.mark.parametrize( - ("quote_state", "proof_state"), - [ - (MeltQuoteState.pending, ProofSpentState.pending), - (MeltQuoteState.unpaid, ProofSpentState.spent), - (MeltQuoteState.unpaid, ProofSpentState.pending), - ], -) -async def test_ambiguous_or_consumed_melt_is_never_reported_unspent( - quote_state: MeltQuoteState, proof_state: ProofSpentState -) -> None: - wallet = Mock( - url="http://source-mint:3338", - get_melt_quote=AsyncMock(return_value=Mock(state=quote_state)), - check_proof_state=AsyncMock( - return_value=Mock(states=[Mock(state=proof_state)]) - ), - ) - - with pytest.raises(TokenConsumedError, match="reconciliation required"): - await _reconcile_ambiguous_melt(wallet, "quote-3", [Mock()]) - - -@pytest.mark.asyncio -async def test_failed_melt_reconciliation_is_non_retryable() -> None: - wallet = Mock( - url="http://source-mint:3338", - get_melt_quote=AsyncMock(side_effect=RuntimeError("mint unavailable")), - check_proof_state=AsyncMock(), - ) - - with pytest.raises(TokenConsumedError, match="outcome is unknown"): - await _reconcile_ambiguous_melt(wallet, "quote-4", [Mock()]) diff --git a/tests/unit/test_messages_litellm_dispatch.py b/tests/unit/test_messages_litellm_dispatch.py index ca5a83c5..294f5c0c 100644 --- a/tests/unit/test_messages_litellm_dispatch.py +++ b/tests/unit/test_messages_litellm_dispatch.py @@ -1437,15 +1437,34 @@ async def test_dispatch_uses_url_detected_prefix_for_fireworks_custom_row() -> N ["handle_x_cashu", "handle_x_cashu_responses"], ) @pytest.mark.parametrize( - "error", + ("error", "expected_type", "expected_code", "expected_message"), [ - httpx.ConnectError("All connection attempts failed"), - MintConnectionError("Cashu mint is unreachable"), - TimeoutError("timed out connecting to mint"), + ( + httpx.ConnectError("All connection attempts failed"), + "mint_unreachable", + "cashu_mint_unreachable", + "Cashu mint is unreachable; retry later", + ), + ( + MintConnectionError("connect to http://mint:3338 refused"), + "mint_unreachable", + "cashu_mint_unreachable", + "Cashu mint is unreachable; retry later", + ), + ( + TimeoutError("timed out connecting to mint"), + "mint_timeout", + "cashu_mint_timeout", + "Cashu mint did not respond in time; retry later", + ), ], ) async def test_x_cashu_mint_unreachable_returns_503( - handler_name: str, error: Exception + handler_name: str, + error: Exception, + expected_type: str, + expected_code: str, + expected_message: str, ) -> None: """Both X-Cashu entrypoints classify a down mint as 503 mint_unreachable, not a generic 400 cashu_error.""" @@ -1467,9 +1486,9 @@ async def test_x_cashu_mint_unreachable_returns_503( assert response.status_code == 503 body = json.loads(bytes(response.body)) - assert body["error"]["type"] == "mint_unreachable" - assert body["error"]["message"] == "Cashu mint is unreachable" - assert body["error"]["code"] == "cashu_mint_unreachable" + assert body["error"]["type"] == expected_type + assert body["error"]["message"] == expected_message + assert body["error"]["code"] == expected_code if str(error) != body["error"]["message"]: assert str(error) not in body["error"]["message"] @@ -1513,13 +1532,6 @@ async def test_x_cashu_mint_unreachable_returns_503( "Token value is too small to cover swap fees", "cashu_token_swap_fees_exceed_amount", ), - ( - ValueError("Failed to melt token from foreign mint http://m: boom"), - 422, - "mint_error", - "Failed to swap token from foreign mint", - "cashu_foreign_mint_swap_failed", - ), ( ValueError("some unexpected wallet condition"), 400, diff --git a/tests/unit/test_short_keyset_ids.py b/tests/unit/test_short_keyset_ids.py index 870e76d3..7f87b4de 100644 --- a/tests/unit/test_short_keyset_ids.py +++ b/tests/unit/test_short_keyset_ids.py @@ -1,11 +1,8 @@ -from typing import cast -from unittest.mock import AsyncMock, Mock, patch +from unittest.mock import AsyncMock, Mock import httpx import pytest from cashu.core.base import ( - MeltQuoteState, - Proof, TokenV4, TokenV4Proof, TokenV4Token, @@ -16,7 +13,6 @@ from routstr.wallet import ( Wallet, _redeem_same_mint, classify_redemption_error, - swap_to_trusted_mint, ) MINT_URL = "https://mint.example" @@ -163,54 +159,3 @@ async def test_cached_keysets_do_not_mask_a_refresh_failure( assert classified is not None assert classified[1] == 503 assert classified[3] == "cashu_source_mint_unreachable" - - -@pytest.mark.asyncio -async def test_cross_mint_swap_uses_resolved_proofs_and_active_output_keyset() -> None: - token = _token(amounts=(7,)) - source_wallet = _wallet_with_keysets(FULL_V2_ID) - source_wallet.melt_quote = AsyncMock( - return_value=Mock(quote="melt-quote", amount=5, fee_reserve=2) - ) - - async def assert_melt_boundary(**kwargs: object) -> Mock: - assert kwargs["fee_reserve_sat"] == 2 - assert source_wallet.keyset_id == FULL_V2_ID - return Mock(state=MeltQuoteState.paid) - - source_wallet.melt = AsyncMock(side_effect=assert_melt_boundary) - - destination_url = "https://trusted-mint.example" - destination_wallet = Mock( - load_proofs=AsyncMock(), - available_balance=Mock(amount=0), - mint=AsyncMock(), - ) - mint_quote = Mock(quote="mint-quote", request="lnbc-test-invoice") - calculate_amount = AsyncMock(return_value=5) - - with ( - patch("routstr.wallet.settings.primary_mint", destination_url), - patch("routstr.wallet.settings.primary_mint_unit", "sat"), - patch("routstr.wallet.settings.cashu_mints", [destination_url]), - patch( - "routstr.wallet._calculate_swap_amount", - calculate_amount, - ), - patch( - "routstr.wallet._request_mint_with_fallback", - AsyncMock(return_value=(destination_wallet, destination_url, mint_quote)), - ), - ): - assert await swap_to_trusted_mint(token, source_wallet) == ( - 5, - "sat", - destination_url, - ) - - calculate_call = calculate_amount.await_args - assert calculate_call is not None - resolved = cast(list[Proof], calculate_call.args[5]) - assert resolved[0].id == FULL_V2_ID - assert source_wallet.get_fees_for_proofs.call_args.args[0] is resolved - assert source_wallet.melt.await_args.kwargs["proofs"] is resolved diff --git a/tests/unit/test_wallet.py b/tests/unit/test_wallet.py index 5ad0a4f3..3f9a3258 100644 --- a/tests/unit/test_wallet.py +++ b/tests/unit/test_wallet.py @@ -8,7 +8,6 @@ from unittest.mock import AsyncMock, MagicMock, Mock, patch import httpx import pytest -from cashu.core.base import MeltQuoteState from routstr.core.db import ApiKey from routstr.wallet import ( @@ -16,6 +15,7 @@ from routstr.wallet import ( Bolt11PaymentNotAttempted, MintConnectionError, TokenConsumedError, + UntrustedSourceMintError, _is_mint_rate_limited, classify_redemption_error, credit_balance, @@ -223,7 +223,7 @@ async def test_recieve_token_trusted_mint_deducts_input_fee() -> None: """A trusted mint that charges NUT-02 input fees. The same-mint receive (`wallet.split(..., include_fees=True)`, a NUT-03 swap - at the same mint — not swap_to_primary_mint) pays the mint's per-proof fee, + at the same mint) pays the mint's per-proof fee, so routstr only ends up with `face - input_fee` in fresh proofs. The credited amount must reflect that, otherwise routstr over-credits the user and its own wallet drifts toward insolvency. @@ -284,11 +284,13 @@ async def test_recieve_token_trusted_mint_deducts_input_fee() -> None: @pytest.mark.asyncio -async def test_recieve_token_uses_only_requested_destination_mint() -> None: +async def test_recieve_token_redeems_on_issuing_mint_never_swaps() -> None: + """A token from a secondary trusted mint stays on that mint even though a + different primary mint is configured; no cross-mint swap is attempted.""" from routstr.core.settings import settings - source = "http://foreign:3338" - destination = "http://key-mint:3338" + source = "http://secondary:3338" + primary = "http://primary:3338" token = Mock( mint=source, unit="sat", @@ -297,21 +299,19 @@ async def test_recieve_token_uses_only_requested_destination_mint() -> None: proofs=[Mock(amount=100)], ) source_wallet = Mock() - swap = AsyncMock(return_value=(99, "sat", destination)) + redeem = AsyncMock(return_value=(99, "sat", source)) with ( - patch.object(settings, "primary_mint", destination), - patch.object(settings, "cashu_mints", [destination]), + patch.object(settings, "primary_mint", primary), + patch.object(settings, "cashu_mints", [primary, source]), patch("routstr.wallet.deserialize_token_from_string", return_value=token), patch("routstr.wallet.get_wallet", AsyncMock(return_value=source_wallet)), - patch("routstr.wallet.swap_to_trusted_mint", swap), + patch("routstr.wallet._redeem_same_mint", redeem), ): - result = await recieve_token( - "cashuAtoken", destination_mint=destination, destination_unit="sat" - ) + result = await recieve_token("cashuAtoken", destination_unit="sat") - assert result == (99, "sat", destination) - swap.assert_awaited_once_with(token, source_wallet, destination_mints=[destination]) + assert result == (99, "sat", source) + redeem.assert_awaited_once_with(source_wallet, token) @pytest.mark.asyncio @@ -320,35 +320,12 @@ async def test_recieve_token_rejects_unit_mismatch_before_wallet_mutation() -> N get_wallet = AsyncMock() with ( + patch("routstr.wallet.settings.cashu_mints", ["http://key-mint:3338"]), patch("routstr.wallet.deserialize_token_from_string", return_value=token), patch("routstr.wallet.get_wallet", get_wallet), pytest.raises(ValueError, match="liability unit"), ): - await recieve_token( - "cashuAtoken", - destination_mint="http://key-mint:3338", - destination_unit="sat", - ) - - get_wallet.assert_not_awaited() - - -@pytest.mark.asyncio -async def test_recieve_token_cross_mint_output_unit_must_match() -> None: - token = Mock(mint="http://foreign:3338", unit="msat", keysets=["keyset"]) - get_wallet = AsyncMock() - - with ( - patch("routstr.wallet.deserialize_token_from_string", return_value=token), - patch("routstr.wallet.settings.primary_mint_unit", "sat"), - patch("routstr.wallet.get_wallet", get_wallet), - pytest.raises(ValueError, match="liability unit"), - ): - await recieve_token( - "cashuAtoken", - destination_mint="http://key-mint:3338", - destination_unit="msat", - ) + await recieve_token("cashuAtoken", destination_unit="sat") get_wallet.assert_not_awaited() @@ -756,7 +733,9 @@ async def test_concurrent_duplicate_token_credits_exactly_once() -> None: @pytest.mark.asyncio -async def test_credit_balance_constrains_redemption_to_key_mint() -> None: +async def test_credit_balance_redeems_on_token_mint_not_key_mint() -> None: + """Top-ups are redeemed where the token was issued; the key's bound mint is + only a refund preference, never a swap destination.""" key_mint = "http://key-mint:3338" mock_key = Mock( balance=1_000_000, @@ -772,9 +751,7 @@ async def test_credit_balance_constrains_redemption_to_key_mint() -> None: with patch("routstr.wallet.store_cashu_transaction", AsyncMock()): await credit_balance("cashuAtoken", mock_key, mock_session) - receive.assert_awaited_once_with( - "cashuAtoken", destination_mint=key_mint, destination_unit="sat" - ) + receive.assert_awaited_once_with("cashuAtoken", destination_unit="sat") @pytest.mark.asyncio @@ -848,54 +825,6 @@ async def test_credit_balance_rejects_missing_key() -> None: assert not mock_session.commit.called -@pytest.mark.asyncio -async def test_swap_to_primary_mint_insufficient_for_fees() -> None: - """Token amount is less than melt_quote.amount + melt_quote.fee_reserve. - The quote mocks are static, so every retry observes the same shortfall — - the swap must still give up and raise.""" - from routstr.wallet import swap_to_primary_mint - - mock_token = Mock() - mock_token.mint = "http://foreign:3338" - mock_token.unit = "sat" - mock_token.amount = 404 - mock_token.keysets = ["keyset1"] - mock_token.proofs = [{"amount": 404}] - - mock_token_wallet = Mock() - mock_token_wallet.load_mint_keysets = AsyncMock() - mock_token_wallet.activate_keyset = AsyncMock() - mock_token_wallet._expand_short_keyset_ids = AsyncMock() - mock_token_wallet.load_proofs = AsyncMock() - mock_token_wallet.get_fees_for_proofs = Mock(return_value=0) - - mock_primary_wallet = Mock() - mock_primary_wallet.load_mint = AsyncMock() - mock_primary_wallet.load_proofs = AsyncMock() - - mock_mint_quote = Mock() - mock_mint_quote.quote = "mint_quote_123" - mock_mint_quote.request = "lnbc1..." - mock_primary_wallet.request_mint = AsyncMock(return_value=mock_mint_quote) - - mock_melt_quote = Mock() - mock_melt_quote.quote = "melt_quote_123" - mock_melt_quote.amount = 400 - mock_melt_quote.fee_reserve = 12 # total needed: 412 > 404 - mock_token_wallet.melt_quote = AsyncMock(return_value=mock_melt_quote) - - from routstr.core.settings import settings - - with patch.object(settings, "primary_mint", "http://primary:3338"): - with patch.object(settings, "primary_mint_unit", "sat"): - with patch("routstr.wallet.get_wallet", return_value=mock_primary_wallet): - with pytest.raises(ValueError, match="insufficient to cover melt fees"): - await swap_to_primary_mint(mock_token, mock_token_wallet) - - # melt should never have been called - mock_token_wallet.melt.assert_not_called() - - @pytest.mark.asyncio async def test_recieve_token_untrusted_mint() -> None: mock_wallet = Mock() @@ -908,719 +837,21 @@ async def test_recieve_token_untrusted_mint() -> None: mock_token.amount = 1000 mock_deserialize.return_value = mock_token - mock_wallet.load_mint = AsyncMock() - mock_wallet.load_proofs = AsyncMock() - with patch("routstr.wallet.Wallet.with_db", return_value=mock_wallet): - with patch( - "routstr.wallet.swap_to_trusted_mint", - return_value=(900, "sat", "http://mint:3338"), - ): - amount, unit, mint = await recieve_token("test_token") - assert amount == 900 - assert unit == "sat" - assert mint == "http://mint:3338" - - -@pytest.mark.asyncio -async def test_swap_to_primary_mint_already_on_primary() -> None: - """Same-mint shortcut: the token is already on the primary mint. - - No cross-mint swap (no melt/mint), but the same-mint split(include_fees=True) - still burns the mint's NUT-02 input fee, so the credited amount must be face - minus the input fee — not full face value (the over-credit bug). DLEQ is - verified too, matching the trusted same-mint receive path. - """ - from routstr.core.settings import settings - from routstr.wallet import swap_to_primary_mint - - mock_token = Mock() - mock_token.mint = settings.primary_mint - mock_token.keysets = ["keyset1"] - mock_token.amount = 1000 - mock_token.unit = "sat" - mock_token.proofs = [{"amount": 1000}] - - mock_token_wallet = Mock() - mock_token_wallet.load_mint_keysets = AsyncMock() - mock_token_wallet.activate_keyset = AsyncMock() - mock_token_wallet._expand_short_keyset_ids = AsyncMock() - mock_token_wallet.load_proofs = AsyncMock() - mock_token_wallet.verify_proofs_dleq = Mock() - # Mock a 3-sat input fee from the Cashu wallet API. - mock_token_wallet.get_fees_for_proofs = Mock(return_value=3) - mock_token_wallet.split = AsyncMock(return_value=None) - mock_token_wallet.request_mint = AsyncMock() - mock_token_wallet.melt_quote = AsyncMock() - - with patch("routstr.wallet.get_wallet", AsyncMock(return_value=mock_token_wallet)): - amount, unit, mint = await swap_to_primary_mint(mock_token, mock_token_wallet) - - assert amount == 997 # 1000 face - 3 sat input fee - assert unit == "sat" - assert mint == settings.primary_mint - mock_token_wallet.verify_proofs_dleq.assert_called_once_with(mock_token.proofs) - mock_token_wallet.get_fees_for_proofs.assert_called_once_with(mock_token.proofs) - mock_token_wallet.split.assert_called_once() - mock_token_wallet.request_mint.assert_not_called() - mock_token_wallet.melt_quote.assert_not_called() - - -# --------------------------------------------------------------------------- -# Swap fee estimation and reactive retry -# -# Spec: the estimation pass subtracts only observed fees (no safety buffer). -# swap_to_primary_mint then runs the mint-quote/melt-quote/melt cycle and, when -# the foreign mint demands more than estimated (at quote or at melt time), -# retries with the amount recomputed from the observed fee — at most 3 attempts. -# Melt failures unrelated to fees are not retried. -# --------------------------------------------------------------------------- - - -def _make_swap_mocks( - token_amount: int, - fee_reserves: list[int], - input_fees: int = 0, - mint_url: str = "http://foreign-mint:3338", -) -> tuple[Mock, Mock, Mock]: - """Return (token, token_wallet, primary_wallet) mocks that act like a mint. - - Mint quotes pass the requested amount through their ``request`` field and - melt quotes echo that amount back, so the mocks stay consistent for - whatever amounts the implementation requests. ``fee_reserves`` supplies the - fee_reserve of each successive melt quote (the first serves the estimation - pass); requesting more quotes than provided fails the test. - """ - mock_token = Mock() - mock_token.mint = mint_url - mock_token.unit = "sat" - mock_token.amount = token_amount - mock_token.keysets = ["keyset1"] - mock_token.proofs = [Mock(amount=token_amount)] - - mock_token_wallet = Mock() - mock_token_wallet.load_mint_keysets = AsyncMock() - mock_token_wallet.activate_keyset = AsyncMock() - mock_token_wallet._expand_short_keyset_ids = AsyncMock() - mock_token_wallet.load_proofs = AsyncMock() - mock_token_wallet.get_fees_for_proofs = Mock(return_value=input_fees) - - mock_primary_wallet = Mock() - mock_primary_wallet.load_mint = AsyncMock() - mock_primary_wallet.load_proofs = AsyncMock() - mock_primary_wallet.available_balance = Mock(amount=0) - mock_primary_wallet.mint = AsyncMock(return_value=Mock()) - - fees = iter(fee_reserves) - - def _next_fee() -> int: - try: - return next(fees) - except StopIteration: - raise AssertionError( - "more melt quotes requested than fee_reserves provided" - ) from None - - mock_primary_wallet.request_mint = AsyncMock( - side_effect=lambda amount: Mock(quote=f"mint_quote_{amount}", request=amount) - ) - mock_token_wallet.melt_quote = AsyncMock( - side_effect=lambda invoice: Mock( - quote=f"melt_quote_{invoice}", amount=invoice, fee_reserve=_next_fee() - ) - ) - mock_token_wallet.melt = AsyncMock(return_value=Mock(state=MeltQuoteState.paid)) - - return mock_token, mock_token_wallet, mock_primary_wallet - - -@pytest.mark.asyncio -async def test_swap_to_primary_mint_success() -> None: - """No retry needed: real quote matches the estimate, full net amount minted.""" - from routstr.wallet import swap_to_primary_mint - - mock_token, mock_token_wallet, mock_primary_wallet = _make_swap_mocks( - 1000, fee_reserves=[10, 10] - ) - - from routstr.core.settings import settings - - with patch.object(settings, "primary_mint", "http://primary:3338"): - with patch.object(settings, "primary_mint_unit", "sat"): - with patch("routstr.wallet.get_wallet", return_value=mock_primary_wallet): - amount, unit, mint = await swap_to_primary_mint( - mock_token, mock_token_wallet - ) - - assert amount == 990 # 1000 - fee_reserve(10), no buffer subtracted - assert unit == "sat" - assert mint == "http://primary:3338" - assert mock_primary_wallet.request_mint.call_count == 2 - mock_primary_wallet.request_mint.assert_any_call(1000) - mock_primary_wallet.request_mint.assert_any_call(990) - assert mock_token_wallet.melt_quote.call_count == 2 - assert mock_token_wallet.melt.call_count == 1 - assert mock_primary_wallet.mint.called - - -@pytest.mark.asyncio -@pytest.mark.parametrize("fee_reserve", [1, 10, 100]) -async def test_calculate_swap_amount_subtracts_only_observed_fees( - fee_reserve: int, -) -> None: - """Estimation: minted_amount = token - fee_reserve, with no safety buffer.""" - from routstr.wallet import _calculate_swap_amount - - _, mock_token_wallet, mock_primary_wallet = _make_swap_mocks( - 1000, fee_reserves=[fee_reserve] - ) - - from routstr.core.settings import settings - - with patch.object(settings, "primary_mint", "http://primary:3338"): - with patch.object(settings, "primary_mint_unit", "sat"): - result = await _calculate_swap_amount( - amount_msat=1_000_000, - token_unit="sat", - token_mint_url="http://foreign-mint:3338", - token_wallet=mock_token_wallet, - primary_wallet=mock_primary_wallet, - proofs=[], - ) - - assert result == 1000 - fee_reserve - - -@pytest.mark.asyncio -async def test_calculate_swap_amount_includes_input_fees() -> None: - """Estimation subtracts NUT-02 input fees alongside the melt fee_reserve.""" - from routstr.wallet import _calculate_swap_amount - - _, mock_token_wallet, mock_primary_wallet = _make_swap_mocks( - 500, fee_reserves=[10], input_fees=3 - ) - - from routstr.core.settings import settings - - with patch.object(settings, "primary_mint", "http://primary:3338"): - with patch.object(settings, "primary_mint_unit", "sat"): - result = await _calculate_swap_amount( - amount_msat=500_000, - token_unit="sat", - token_mint_url="http://foreign-mint:3338", - token_wallet=mock_token_wallet, - primary_wallet=mock_primary_wallet, - proofs=[], - ) - - assert result == 487 # 500 - 10 - 3 - - -@pytest.mark.asyncio -async def test_swap_retries_when_real_quote_exceeds_estimate() -> None: - """The real melt quote demands a higher fee than the estimate (20 → 23). - Instead of failing, the swap recomputes the amount from the observed fee - and re-quotes: 1000 - 23 = 977, which fits (977 + 23 <= 1000).""" - from routstr.wallet import swap_to_primary_mint - - mock_token, mock_token_wallet, mock_primary_wallet = _make_swap_mocks( - 1000, fee_reserves=[20, 23, 23] - ) - - from routstr.core.settings import settings - - with patch.object(settings, "primary_mint", "http://primary:3338"): - with patch.object(settings, "primary_mint_unit", "sat"): - with patch("routstr.wallet.get_wallet", return_value=mock_primary_wallet): - amount, unit, mint = await swap_to_primary_mint( - mock_token, mock_token_wallet - ) - - assert amount == 977 - assert unit == "sat" - mock_primary_wallet.request_mint.assert_any_call(980) - mock_primary_wallet.request_mint.assert_any_call(977) - assert mock_token_wallet.melt_quote.call_count == 3 # estimation + 2 attempts - assert mock_token_wallet.melt.call_count == 1 - - -@pytest.mark.asyncio -async def test_swap_retries_when_melt_demands_more_than_quoted() -> None: - """The mint.cubabitcoin.org incident: every quote reports fee_reserve=1, - but the mint demands 2 sats at melt time ("Provided: 179, needed: 180"). - The swap must retry with a smaller invoice (177) so the second melt fits, - instead of failing the topup.""" - from routstr.wallet import swap_to_primary_mint - - mock_token, mock_token_wallet, mock_primary_wallet = _make_swap_mocks( - 179, fee_reserves=[1, 1, 1], mint_url="http://mint.cubabitcoin.org" - ) - mock_token_wallet.melt.side_effect = [ - Exception( - "Mint Error: not enough inputs provided for melt. " - "Provided: 179, needed: 180 (Code: 11000)" - ), - Mock(state=MeltQuoteState.paid), - ] - - from routstr.core.settings import settings - - with patch.object(settings, "primary_mint", "http://primary:3338"): - with patch.object(settings, "primary_mint_unit", "sat"): - with patch("routstr.wallet.get_wallet", return_value=mock_primary_wallet): - amount, unit, mint = await swap_to_primary_mint( - mock_token, mock_token_wallet - ) - - assert amount == 177 # 179 - 1 (estimate) - 1 (observed melt shortfall) - assert mock_token_wallet.melt.call_count == 2 - mock_primary_wallet.request_mint.assert_any_call(178) - mock_primary_wallet.request_mint.assert_any_call(177) - - -@pytest.mark.asyncio -async def test_swap_retries_on_cdk_unbalanced_error() -> None: - """cdk-based mints report insufficient melt inputs as the registered code - 11005 (TransactionUnbalanced) with their own message wording — no - Provided/needed amounts to parse. The retry must classify it by code and - fall back to shrinking by 1.""" - from routstr.wallet import swap_to_primary_mint - - mock_token, mock_token_wallet, mock_primary_wallet = _make_swap_mocks( - 179, fee_reserves=[1, 1, 1] - ) - mock_token_wallet.melt.side_effect = [ - Exception("Mint Error: Transaction unbalanced: 179, 178, 2 (Code: 11005)"), - Mock(state=MeltQuoteState.paid), - ] - - from routstr.core.settings import settings - - with patch.object(settings, "primary_mint", "http://primary:3338"): - with patch.object(settings, "primary_mint_unit", "sat"): - with patch("routstr.wallet.get_wallet", return_value=mock_primary_wallet): - amount, unit, mint = await swap_to_primary_mint( - mock_token, mock_token_wallet - ) - - assert amount == 177 - assert mock_token_wallet.melt.call_count == 2 - - -@pytest.mark.asyncio -async def test_swap_quote_retries_exhausted() -> None: - """A mint that escalates fee_reserve on every re-quote exhausts the retry - budget (3 attempts) and fails cleanly; melt is never executed.""" - from routstr.wallet import swap_to_primary_mint - - mock_token, mock_token_wallet, mock_primary_wallet = _make_swap_mocks( - 1000, fee_reserves=[1, 10, 25, 50] - ) - - from routstr.core.settings import settings - - with patch.object(settings, "primary_mint", "http://primary:3338"): - with patch.object(settings, "primary_mint_unit", "sat"): - with patch("routstr.wallet.get_wallet", return_value=mock_primary_wallet): - with pytest.raises(ValueError, match="insufficient to cover melt fees"): - await swap_to_primary_mint(mock_token, mock_token_wallet) - - assert mock_token_wallet.melt_quote.call_count == 4 # estimation + 3 attempts - mock_token_wallet.melt.assert_not_called() - - -@pytest.mark.asyncio -async def test_swap_melt_retries_exhausted() -> None: - """A mint that always demands more at melt time than it quoted exhausts - the retry budget; the last melt failure is wrapped as ValueError.""" - from routstr.wallet import swap_to_primary_mint - - mock_token, mock_token_wallet, mock_primary_wallet = _make_swap_mocks( - 5000, fee_reserves=[50, 50, 50, 50] - ) - mock_token_wallet.melt = AsyncMock( - side_effect=Exception( - "Mint Error: not enough inputs provided for melt. " - "Provided: 5000, needed: 5200 (Code: 11000)" - ) - ) - - from routstr.core.settings import settings - - with patch.object(settings, "primary_mint", "http://primary:3338"): - with patch.object(settings, "primary_mint_unit", "sat"): - with patch("routstr.wallet.get_wallet", return_value=mock_primary_wallet): - with pytest.raises(ValueError, match="Failed to melt token"): - await swap_to_primary_mint(mock_token, mock_token_wallet) - - assert mock_token_wallet.melt.call_count == 3 - - -@pytest.mark.asyncio -@pytest.mark.parametrize( - "primary_unit,token_unit,amount_msat,fees,expected", - [ - ("sat", "sat", 179_000, 2, 177), - ("msat", "sat", 179_000, 2, 177_000), - ("sat", "msat", 179_000, 2_000, 177), - ], -) -async def test_net_minted_amount_unit_conversions( - primary_unit: str, token_unit: str, amount_msat: int, fees: int, expected: int -) -> None: - """Fee subtraction converts correctly between sat and msat on either side.""" - from routstr.core.settings import settings - from routstr.wallet import _net_minted_amount - - with patch.object(settings, "primary_mint_unit", primary_unit): - assert _net_minted_amount(amount_msat, token_unit, fees) == expected - - -@pytest.mark.parametrize( - "message,expected", - [ - # nutshell: retryable with exact shortfall from the detail text - ( - "Mint Error: not enough inputs provided for melt. " - "Provided: 179, needed: 182 (Code: 11000)", - 3, - ), - # verbatim production error from issue #468, including cashu-py's - # "could not pay invoice" wrapper around the mint detail - ( - "could not pay invoice: Mint Error: not enough inputs provided " - "for melt. Provided: 179, needed: 180 (Code: 11000)", - 1, - ), - # cdk: registered TransactionUnbalanced code, no parsable amounts - ("Mint Error: Transaction unbalanced: 179, 178, 2 (Code: 11005)", 1), - # nutshell wording without a code suffix - ("not enough inputs provided for melt", 1), - # nonsensical amounts (needed <= provided) fall back to the minimal step - ( - "Mint Error: not enough inputs provided for melt. " - "Provided: 180, needed: 179 (Code: 11000)", - 1, - ), - # a generic 11000 without the shortfall text is not a fee shortfall: - # 11000 is nutshell's catch-all TransactionError, so retrying (shrinking - # the invoice) would never help and only masks the real error - ("Mint Error: Duplicate inputs provided. (Code: 11000)", None), - # spent proofs must never be retried: the funds are gone - ("Mint Error: Token already spent. (Code: 11001)", None), - # Lightning failures must never be retried: a smaller invoice won't help - ("Mint Error: Lightning payment failed. (Code: 20004)", None), - # unrecognizable errors (timeouts, bugs) must never be retried - ("Connection timeout", None), - ], -) -def test_melt_shortfall_classifier(message: str, expected: int | None) -> None: - """Retry classification across mint implementations and failure classes.""" - from routstr.wallet import _melt_insufficient_shortfall - - assert _melt_insufficient_shortfall(Exception(message)) == expected - - -@pytest.mark.asyncio -async def test_calculate_swap_amount_same_mint_short_circuit() -> None: - """When the token is already on the primary mint no fees apply and no - quotes are requested.""" - from routstr.wallet import _calculate_swap_amount - - _, mock_token_wallet, mock_primary_wallet = _make_swap_mocks(1000, fee_reserves=[]) - - from routstr.core.settings import settings - - with patch.object(settings, "primary_mint", "http://primary:3338"): - with patch.object(settings, "primary_mint_unit", "sat"): - result = await _calculate_swap_amount( - amount_msat=1_000_000, - token_unit="sat", - token_mint_url="http://primary:3338", - token_wallet=mock_token_wallet, - primary_wallet=mock_primary_wallet, - proofs=[], - ) - - assert result == 1000 - mock_primary_wallet.request_mint.assert_not_called() - mock_token_wallet.melt_quote.assert_not_called() - - -@pytest.mark.asyncio -async def test_calculate_swap_amount_msat_primary_unit() -> None: - """With an msat primary mint the dummy quote and result stay in msats.""" - from routstr.wallet import _calculate_swap_amount - - _, mock_token_wallet, mock_primary_wallet = _make_swap_mocks(179, fee_reserves=[2]) - - from routstr.core.settings import settings - - with patch.object(settings, "primary_mint", "http://primary:3338"): - with patch.object(settings, "primary_mint_unit", "msat"): - result = await _calculate_swap_amount( - amount_msat=179_000, - token_unit="sat", - token_mint_url="http://foreign-mint:3338", - token_wallet=mock_token_wallet, - primary_wallet=mock_primary_wallet, - proofs=[], - ) - - assert result == 177_000 # 179_000 msat - 2 sat fee - mock_primary_wallet.request_mint.assert_called_once_with(179_000) - - -@pytest.mark.asyncio -async def test_calculate_swap_amount_fees_exceed_token() -> None: - """Fees larger than the token itself fail fast, before any melt.""" - from routstr.wallet import _calculate_swap_amount - - _, mock_token_wallet, mock_primary_wallet = _make_swap_mocks( - 179, fee_reserves=[200] - ) - - from routstr.core.settings import settings - - with patch.object(settings, "primary_mint", "http://primary:3338"): - with patch.object(settings, "primary_mint_unit", "sat"): - with pytest.raises(ValueError, match="exceed token amount"): - await _calculate_swap_amount( - amount_msat=179_000, - token_unit="sat", - token_mint_url="http://foreign-mint:3338", - token_wallet=mock_token_wallet, - primary_wallet=mock_primary_wallet, - proofs=[], - ) - - -@pytest.mark.asyncio -async def test_calculate_swap_amount_wraps_estimation_failure() -> None: - """Estimation infrastructure failures surface as a single clear ValueError.""" - from routstr.wallet import _calculate_swap_amount - - _, mock_token_wallet, mock_primary_wallet = _make_swap_mocks(179, fee_reserves=[]) - mock_primary_wallet.request_mint = AsyncMock(side_effect=Exception("mint offline")) - - from routstr.core.settings import settings - - with patch.object(settings, "primary_mint", "http://primary:3338"): - with patch.object(settings, "primary_mint_unit", "sat"): - with pytest.raises(ValueError, match="Failed to estimate fees"): - await _calculate_swap_amount( - amount_msat=179_000, - token_unit="sat", - token_mint_url="http://foreign-mint:3338", - token_wallet=mock_token_wallet, - primary_wallet=mock_primary_wallet, - proofs=[], - ) - - -@pytest.mark.asyncio -async def test_swap_coerces_non_integer_amount() -> None: - """Token amounts arriving as floats are coerced before any arithmetic.""" - from routstr.wallet import swap_to_primary_mint - - mock_token, mock_token_wallet, mock_primary_wallet = _make_swap_mocks( - 1000, fee_reserves=[10, 10] - ) - mock_token.amount = 1000.0 - - from routstr.core.settings import settings - - with patch.object(settings, "primary_mint", "http://primary:3338"): - with patch.object(settings, "primary_mint_unit", "sat"): - with patch("routstr.wallet.get_wallet", return_value=mock_primary_wallet): - amount, unit, mint = await swap_to_primary_mint( - mock_token, mock_token_wallet - ) - - assert amount == 990 - assert isinstance(amount, int) - - -@pytest.mark.asyncio -async def test_swap_rejects_unknown_unit() -> None: - """Units other than sat/msat are rejected before any quote is requested.""" - from routstr.wallet import swap_to_primary_mint - - mock_token, mock_token_wallet, mock_primary_wallet = _make_swap_mocks( - 1000, fee_reserves=[] - ) - mock_token.unit = "usd" - - from routstr.core.settings import settings - - with patch.object(settings, "primary_mint", "http://primary:3338"): - with patch.object(settings, "primary_mint_unit", "sat"): - with patch("routstr.wallet.get_wallet", return_value=mock_primary_wallet): - with pytest.raises(ValueError, match="Invalid unit"): - await swap_to_primary_mint(mock_token, mock_token_wallet) - - mock_primary_wallet.request_mint.assert_not_called() - - -@pytest.mark.asyncio -async def test_swap_msat_token_already_on_primary() -> None: - """msat-denominated tokens on the primary mint short-circuit unchanged.""" - from routstr.wallet import swap_to_primary_mint - - mock_token, mock_token_wallet, _ = _make_swap_mocks( - 179_000, fee_reserves=[], mint_url="http://primary:3338" - ) - mock_token.unit = "msat" - mock_token_wallet.split = AsyncMock() - - from routstr.core.settings import settings - - with patch.object(settings, "primary_mint", "http://primary:3338"): - with patch.object(settings, "primary_mint_unit", "sat"): - with patch("routstr.wallet.get_wallet", return_value=mock_token_wallet): - amount, unit, mint = await swap_to_primary_mint( - mock_token, mock_token_wallet - ) - - assert (amount, unit, mint) == (179_000, "msat", "http://primary:3338") - - -# --------------------------------------------------------------------------- -# Mint-on-primary failure handling after a successful melt -# -# At this point the foreign proofs are already spent: failures here mean funds -# are in limbo, so errors must propagate (never be swallowed) and recovery must -# never credit proofs the wallet does not actually hold. -# --------------------------------------------------------------------------- - - -def _with_recovery_mocks( - mock_primary_wallet: Mock, mint_error: str, balances: list[int] -) -> None: - """Make primary mint() fail and stage available_balance per load_proofs call.""" - mock_primary_wallet.mint = AsyncMock(side_effect=Exception(mint_error)) - mock_primary_wallet.keysets = ["keyset_primary"] - balance_iter = iter(balances) - - def advance_balance(reload: bool = False) -> None: - mock_primary_wallet.available_balance = Mock(amount=next(balance_iter)) - - mock_primary_wallet.load_proofs = AsyncMock(side_effect=advance_balance) - mock_primary_wallet.restore_tokens_for_keyset = AsyncMock() - - -@pytest.mark.asyncio -async def test_swap_mint_failure_after_melt_is_token_consumed() -> None: - """A non-recoverable mint failure after melt is a non-retryable - TokenConsumedError (the melt already spent the foreign proofs), with the - original error preserved in the cause chain.""" - from routstr.wallet import swap_to_primary_mint - - mock_token, mock_token_wallet, mock_primary_wallet = _make_swap_mocks( - 1000, fee_reserves=[10, 10] - ) - _with_recovery_mocks( - mock_primary_wallet, "Mint Error: Quote is expired (Code: 20007)", [0] - ) - - from routstr.core.settings import settings - - with patch.object(settings, "primary_mint", "http://primary:3338"): - with patch.object(settings, "primary_mint_unit", "sat"): - with patch("routstr.wallet.get_wallet", return_value=mock_primary_wallet): - with pytest.raises(TokenConsumedError) as exc_info: - await swap_to_primary_mint(mock_token, mock_token_wallet) - - assert "Quote is expired" in str(exc_info.value.__cause__) - assert mock_token_wallet.melt.call_count == 1 - mock_primary_wallet.restore_tokens_for_keyset.assert_not_called() - - -@pytest.mark.asyncio -async def test_swap_recovers_orphaned_proofs_on_outputs_already_signed() -> None: - """11003 (outputs already signed): a recovery scan that restores the full - minted amount lets the swap complete normally.""" - from routstr.wallet import swap_to_primary_mint - - mock_token, mock_token_wallet, mock_primary_wallet = _make_swap_mocks( - 1000, fee_reserves=[10, 10] - ) - _with_recovery_mocks( - mock_primary_wallet, - "Mint Error: outputs already signed (Code: 11003)", - [0, 990], # pre-mint balance, post-recovery balance - ) - - from routstr.core.settings import settings - - with patch.object(settings, "primary_mint", "http://primary:3338"): - with patch.object(settings, "primary_mint_unit", "sat"): - with patch("routstr.wallet.get_wallet", return_value=mock_primary_wallet): - amount, unit, mint = await swap_to_primary_mint( - mock_token, mock_token_wallet - ) - - assert amount == 990 - mock_primary_wallet.restore_tokens_for_keyset.assert_awaited_once_with( - "keyset_primary", to=1, batch=25 - ) - - -@pytest.mark.asyncio -async def test_swap_recovery_shortfall_refuses_credit() -> None: - """When the recovery scan restores less than the minted amount, the swap - must fail rather than credit proofs the wallet does not hold.""" - from routstr.wallet import swap_to_primary_mint - - mock_token, mock_token_wallet, mock_primary_wallet = _make_swap_mocks( - 1000, fee_reserves=[10, 10] - ) - _with_recovery_mocks( - mock_primary_wallet, - "Mint Error: outputs already signed (Code: 11003)", - [0, 100], # recovery restores only 100 of the expected 990 - ) - - from routstr.core.settings import settings - - with patch.object(settings, "primary_mint", "http://primary:3338"): - with patch.object(settings, "primary_mint_unit", "sat"): - with patch("routstr.wallet.get_wallet", return_value=mock_primary_wallet): - with pytest.raises(TokenConsumedError, match="Swap recovery failed"): - await swap_to_primary_mint(mock_token, mock_token_wallet) - - -@pytest.mark.asyncio -async def test_swap_recovery_failure_wrapped() -> None: - """When the recovery scan itself fails, the error is wrapped and raised — - never swallowed.""" - from routstr.wallet import swap_to_primary_mint - - mock_token, mock_token_wallet, mock_primary_wallet = _make_swap_mocks( - 1000, fee_reserves=[10, 10] - ) - _with_recovery_mocks( - mock_primary_wallet, - "Mint Error: outputs already signed (Code: 11003)", - [0], - ) - mock_primary_wallet.restore_tokens_for_keyset = AsyncMock( - side_effect=Exception("wallet db locked") - ) - - from routstr.core.settings import settings - - with patch.object(settings, "primary_mint", "http://primary:3338"): - with patch.object(settings, "primary_mint_unit", "sat"): - with patch("routstr.wallet.get_wallet", return_value=mock_primary_wallet): - with pytest.raises(TokenConsumedError, match="recovery unsuccessful"): - await swap_to_primary_mint(mock_token, mock_token_wallet) + with_db = AsyncMock(return_value=mock_wallet) + with patch("routstr.wallet.Wallet.with_db", with_db): + with pytest.raises(UntrustedSourceMintError): + await recieve_token("test_token") + with_db.assert_not_awaited() @pytest.mark.asyncio async def test_recieve_token_rejects_multiple_keysets() -> None: """Multi-keyset tokens are rejected before touching any wallet.""" + from routstr.core.settings import settings + with patch("routstr.wallet.deserialize_token_from_string") as mock_deserialize: mock_token = Mock() + mock_token.mint = settings.primary_mint mock_token.keysets = ["keyset1", "keyset2"] mock_deserialize.return_value = mock_token @@ -1675,31 +906,6 @@ async def test_credit_balance_propagates_audit_store_failure_after_credit() -> N assert mock_session.commit.called -@pytest.mark.asyncio -async def test_swap_does_not_retry_on_payment_failure() -> None: - """Melt failures unrelated to fees (e.g. routing failure) are not retried: - a smaller invoice would not help, and the error must surface immediately.""" - from routstr.wallet import swap_to_primary_mint - - mock_token, mock_token_wallet, mock_primary_wallet = _make_swap_mocks( - 1000, fee_reserves=[10, 10] - ) - mock_token_wallet.melt = AsyncMock( - side_effect=Exception("Mint Error: Lightning payment failed. (Code: 20004)") - ) - - from routstr.core.settings import settings - - with patch.object(settings, "primary_mint", "http://primary:3338"): - with patch.object(settings, "primary_mint_unit", "sat"): - with patch("routstr.wallet.get_wallet", return_value=mock_primary_wallet): - with pytest.raises(ValueError, match="Failed to melt token"): - await swap_to_primary_mint(mock_token, mock_token_wallet) - - assert mock_token_wallet.melt.call_count == 1 - assert mock_primary_wallet.request_mint.call_count == 2 - - # --- Mint-unreachable classification (is_mint_connection_error) --------------- @@ -1720,7 +926,7 @@ def test_rate_limited_mint_is_classified_as_unreachable() -> None: assert classify_redemption_error(error) == ( "mint_rate_limited", 503, - "Cashu mint rate-limited; retry after cooldown", + "Cashu mint is rate-limiting requests; retry later", "cashu_mint_rate_limited", ) @@ -1830,38 +1036,6 @@ def test_classify_generic_valueerror_is_not_zero_value() -> None: ) -@pytest.mark.asyncio -async def test_swap_mint_transport_error_after_melt_is_not_retryable() -> None: - """A transport error minting on the primary mint (after the foreign melt - already spent the proofs) classifies as a non-retryable token_consumed 500, - never a retryable mint_unreachable 503.""" - from routstr.wallet import swap_to_primary_mint - - mock_token, mock_token_wallet, mock_primary_wallet = _make_swap_mocks( - 1000, fee_reserves=[10, 10] - ) - # Melt succeeds (proofs spent); minting on primary hits a transport error. - mock_primary_wallet.mint = AsyncMock( - side_effect=httpx.ConnectError("primary mint down") - ) - - from routstr.core.settings import settings - - with patch.object(settings, "primary_mint", "http://primary:3338"): - with patch.object(settings, "primary_mint_unit", "sat"): - with patch("routstr.wallet.get_wallet", return_value=mock_primary_wallet): - with pytest.raises(TokenConsumedError) as exc_info: - await swap_to_primary_mint(mock_token, mock_token_wallet) - - classified = classify_redemption_error(exc_info.value) - assert classified is not None - _type, status, _msg, code = classified - assert status == 500 - assert code == "cashu_token_consumed" - assert is_mint_connection_error(exc_info.value) is False - assert mock_token_wallet.melt.call_count == 1 - - @pytest.mark.asyncio async def test_credit_balance_db_transport_error_is_token_consumed() -> None: """A transport-like DB failure after the token is redeemed must be @@ -1882,64 +1056,6 @@ async def test_credit_balance_db_transport_error_is_token_consumed() -> None: assert is_mint_connection_error(exc_info.value) is False -@pytest.mark.asyncio -async def test_swap_fee_estimation_transport_error_raises_mint_connection_error() -> ( - None -): - """A transport failure while estimating fees is surfaced as - MintConnectionError (→ 503), not a generic fee ValueError (→ 422).""" - from routstr.wallet import swap_to_primary_mint - - mock_token, mock_token_wallet, mock_primary_wallet = _make_swap_mocks( - 1000, fee_reserves=[10] - ) - mock_primary_wallet.request_mint = AsyncMock( - side_effect=httpx.ConnectError("All connection attempts failed") - ) - - from routstr.core.settings import settings - - with patch.object(settings, "primary_mint", "http://primary:3338"): - with patch.object(settings, "primary_mint_unit", "sat"): - with patch("routstr.wallet.get_wallet", return_value=mock_primary_wallet): - with pytest.raises(MintConnectionError): - await swap_to_primary_mint(mock_token, mock_token_wallet) - - mock_token_wallet.melt.assert_not_called() - - -@pytest.mark.asyncio -async def test_swap_melt_transport_error_is_never_reported_reusable() -> None: - """A timed-out melt remains ambiguous even when an immediate snapshot says - UNPAID/UNSPENT, so callers must not receive the original token for retry.""" - from routstr.wallet import swap_to_primary_mint - - mock_token, mock_token_wallet, mock_primary_wallet = _make_swap_mocks( - 1000, fee_reserves=[10, 10] - ) - mock_token_wallet.melt = AsyncMock(side_effect=httpx.ConnectTimeout("timed out")) - from cashu.core.base import MeltQuoteState, ProofSpentState - - mock_token_wallet.get_melt_quote = AsyncMock( - return_value=Mock(state=MeltQuoteState.unpaid) - ) - mock_token_wallet.check_proof_state = AsyncMock( - return_value=Mock( - states=[Mock(state=ProofSpentState.unspent) for _ in mock_token.proofs] - ) - ) - - from routstr.core.settings import settings - - with patch.object(settings, "primary_mint", "http://primary:3338"): - with patch.object(settings, "primary_mint_unit", "sat"): - with patch("routstr.wallet.get_wallet", return_value=mock_primary_wallet): - with pytest.raises(TokenConsumedError, match="ambiguous"): - await swap_to_primary_mint(mock_token, mock_token_wallet) - - assert mock_token_wallet.melt.call_count == 1 - - @pytest.mark.asyncio async def test_execute_bolt11_payment_rejects_unpaid_melt_state() -> None: plan = MagicMock() @@ -2551,78 +1667,6 @@ async def test_lightning_mint_fallback_for_topups() -> None: mock_secondary_wallet.request_mint.assert_called_once() -@pytest.mark.asyncio -async def test_swap_falls_back_when_primary_wallet_cannot_load() -> None: - from routstr.core.settings import settings - from routstr.wallet import swap_to_primary_mint - - primary = "http://primary:3338" - secondary = "http://secondary:3338" - foreign = "http://foreign:3338" - - token = Mock( - mint=foreign, - unit="sat", - amount=1000, - keysets=["keyset1"], - proofs=[Mock(amount=1000)], - ) - source_wallet = Mock( - load_mint_keysets=AsyncMock(), - activate_keyset=AsyncMock(), - _expand_short_keyset_ids=AsyncMock(), - load_proofs=AsyncMock(), - get_fees_for_proofs=Mock(return_value=0), - melt_quote=AsyncMock( - return_value=Mock(quote="melt_q", amount=990, fee_reserve=10) - ), - melt=AsyncMock(return_value=Mock(state=MeltQuoteState.paid)), - ) - - mint_quote = Mock(quote="mint_q_secondary", request="lnbc1secondary") - secondary_wallet = Mock( - load_mint=AsyncMock(), - load_proofs=AsyncMock(), - available_balance=Mock(amount=0), - keysets=["ks_secondary"], - restore_tokens_for_keyset=AsyncMock(), - request_mint=AsyncMock(return_value=mint_quote), - mint=AsyncMock(return_value=Mock()), - ) - - async def get_wallet(mint: str, *args: object, **kwargs: object) -> Mock: - if mint == primary: - raise httpx.ConnectError("primary down") - return secondary_wallet - - mock_get = AsyncMock(side_effect=get_wallet) - with ( - patch.object(settings, "primary_mint", primary), - patch.object(settings, "primary_mint_unit", "sat"), - patch.object(settings, "cashu_mints", [primary, secondary]), - patch.object(settings, "mint_max_concurrency", 0), - patch.object(settings, "mint_operation_timeout_seconds", 0), - patch("asyncio.sleep", AsyncMock()), - patch("routstr.wallet.get_wallet", side_effect=mock_get), - patch("routstr.wallet.logger.warning") as warning, - patch("routstr.wallet.logger.info") as info, - ): - amount, unit, mint_url = await swap_to_primary_mint(token, source_wallet) - - assert (amount, unit, mint_url) == (990, "sat", secondary) - secondary_wallet.mint.assert_awaited_once() - assert mock_get.await_args_list[0].args[0] == primary - assert any(call.args[0] == secondary for call in mock_get.await_args_list) - events = { - call.kwargs["extra"]["event"] - for call in [*warning.call_args_list, *info.call_args_list] - if "extra" in call.kwargs and "event" in call.kwargs["extra"] - } - assert "cashu_destination_failed" in events - assert "cashu_destination_selected" in events - assert "cashu_swap_completed" in events - - def test_raise_on_error_request_identifies_cashu_mint_error() -> None: from routstr.mint import MintError from routstr.wallet import Wallet @@ -2733,139 +1777,6 @@ async def test_lightning_mint_fallback_rejects_zero_amount() -> None: 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: int) -> None: - 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_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 - 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.mint.time.monotonic", return_value=10), - patch("routstr.mint.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."""