From 7870db9af171797eada4eaf2243ae25a498dbbf5 Mon Sep 17 00:00:00 2001 From: Jeroen Ubbink Date: Wed, 10 Jun 2026 16:48:02 +0200 Subject: [PATCH 1/4] fix: retry foreign mint swaps with observed fees instead of trusting fee_reserve A foreign mint's melt fee_reserve is a non-binding estimate (NUT-05): the mint may demand more on the real quote or reject the melt outright (e.g. mint.cubabitcoin.org charging input fees beyond its quote). Instead of padding the estimate with a safety buffer that strands funds at the foreign mint on every swap, retry the mint-quote/melt-quote/melt cycle (max 3 attempts) with the amount recomputed from the fees the mint actually demands. Melt failures are classified by the NUT-00 error code (11005 registered TransactionUnbalanced as sent by cdk, 11000 nutshell's generic TransactionError): fee-related rejections retry, others (e.g. 20004 Lightning payment failed) surface immediately. Nutshell's Provided/needed amounts refine the retry step when present; otherwise shrink by 1. Co-Authored-By: Claude Fable 5 --- routstr/wallet.py | 228 ++++++++++---- tests/integration/test_swap_fee_retry.py | 194 ++++++++++++ tests/unit/test_wallet.py | 368 +++++++++++++++++------ 3 files changed, 639 insertions(+), 151 deletions(-) create mode 100644 tests/integration/test_swap_fee_retry.py diff --git a/routstr/wallet.py b/routstr/wallet.py index 69ff6f82..71b6cab7 100644 --- a/routstr/wallet.py +++ b/routstr/wallet.py @@ -1,4 +1,5 @@ import asyncio +import re import time import typing from typing import TypedDict @@ -116,6 +117,62 @@ async def send_token(amount: int, unit: str, mint_url: str | None = None) -> str return token +# 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 across implementations: 11005 is the registered +# "Transaction is not balanced" code (used by cdk); 11000 is nutshell's +# generic TransactionError, which is unregistered but what nutshell sends. +_RETRYABLE_MELT_CODES = frozenset({"11000", "11005"}) + + +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 = fees * 1000 if token_unit == "sat" else fees + remaining_msat = amount_msat - fee_msat + if settings.primary_mint_unit == "sat": + return int(remaining_msat // 1000) + return int(remaining_msat) + + +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) + if code_match is not None: + if code_match.group(1) not in _RETRYABLE_MELT_CODES: + return None + elif "not enough inputs" not in message.lower(): + 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 + + async def _calculate_swap_amount( amount_msat: int, token_unit: str, @@ -154,26 +211,18 @@ async def _calculate_swap_amount( fee_reserve = dummy_melt_quote.fee_reserve input_fees = token_wallet.get_fees_for_proofs(proofs) - if token_unit == "sat": - fee_msat = (fee_reserve + input_fees) * 1000 - else: - fee_msat = fee_reserve + input_fees - - amount_msat_after_fee = amount_msat - fee_msat - - if settings.primary_mint_unit == "sat": - minted_amount = int(amount_msat_after_fee // 1000) - else: - minted_amount = int(amount_msat_after_fee) + 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 ({fee_reserve + input_fees} {token_unit}) exceed token amount") + raise ValueError(f"Fees ({total_fees} {token_unit}) exceed token amount") logger.info( "swap_to_primary_mint: fee estimation result", extra={ "token_amount_sat": amount_msat // 1000, - "estimated_fee_sat": fee_msat // 1000, + "estimated_fee": total_fees, + "estimated_fee_unit": token_unit, "input_fees": input_fees, "minted_amount": minted_amount, "minted_unit": settings.primary_mint_unit, @@ -238,67 +287,116 @@ async def swap_to_primary_mint( token_obj.proofs, ) - mint_quote = await primary_wallet.request_mint(minted_amount) - logger.info( - "swap_to_primary_mint: mint quote received", - extra={"mint_quote_id": mint_quote.quote}, - ) + # 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 + while True: + attempt += 1 + mint_quote = await primary_wallet.request_mint(minted_amount) + logger.info( + "swap_to_primary_mint: mint quote received", + extra={"mint_quote_id": mint_quote.quote, "attempt": attempt}, + ) - melt_quote = await token_wallet.melt_quote(mint_quote.request) - input_fees = token_wallet.get_fees_for_proofs(token_obj.proofs) - total_needed = melt_quote.amount + melt_quote.fee_reserve + input_fees - logger.info( - "swap_to_primary_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, - }, - ) - - if total_needed > token_amount: - logger.warning( - "swap_to_primary_mint: insufficient token amount for melt fees", + melt_quote = await token_wallet.melt_quote(mint_quote.request) + input_fees = token_wallet.get_fees_for_proofs(token_obj.proofs) + total_needed = melt_quote.amount + melt_quote.fee_reserve + input_fees + logger.info( + "swap_to_primary_mint: melt quote received", extra={ - "token_amount": token_amount, + "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, - "shortfall": total_needed - token_amount, + "token_amount": token_amount, + "attempt": 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})" - ) - try: - _ = await token_wallet.melt( - proofs=token_obj.proofs, - invoice=mint_quote.request, - fee_reserve_sat=melt_quote.fee_reserve, - quote_id=melt_quote.quote, - ) - except Exception as e: - logger.error( - "swap_to_primary_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, - }, - ) - raise ValueError( - f"Failed to melt token from foreign mint {token_obj.mint}: {e}" - ) from e + 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_primary_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_primary_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: + _ = await token_wallet.melt( + proofs=token_obj.proofs, + invoice=mint_quote.request, + fee_reserve_sat=melt_quote.fee_reserve, + quote_id=melt_quote.quote, + ) + except Exception as e: + shortfall = _melt_insufficient_shortfall(e) + recomputed = 0 + if shortfall is not None: + observed_extra_fee += shortfall + recomputed = _net_minted_amount( + amount_msat, + token_obj.unit, + melt_quote.fee_reserve + input_fees + observed_extra_fee, + ) + if shortfall is None or attempt >= _MAX_SWAP_ATTEMPTS or recomputed <= 0: + logger.error( + "swap_to_primary_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_primary_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( "swap_to_primary_mint: melt succeeded, minting on primary", diff --git a/tests/integration/test_swap_fee_retry.py b/tests/integration/test_swap_fee_retry.py new file mode 100644 index 00000000..bf1537b0 --- /dev/null +++ b/tests/integration/test_swap_fee_retry.py @@ -0,0 +1,194 @@ +""" +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 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 + +PRIMARY_MINT = "http://primary: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 = 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()) + + 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) -> 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(), + ] + + 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_400_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 400 with an actionable message, 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 == 400 + assert "too small to cover swap fees" in response.json()["detail"] + assert token_wallet.melt_quote.call_count == 4 # estimation + 3 attempts + token_wallet.melt.assert_not_called() diff --git a/tests/unit/test_wallet.py b/tests/unit/test_wallet.py index 245a624a..80d7163b 100644 --- a/tests/unit/test_wallet.py +++ b/tests/unit/test_wallet.py @@ -143,7 +143,9 @@ async def test_credit_balance_rejects_zero_amount() -> None: @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.""" + """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() @@ -185,50 +187,6 @@ async def test_swap_to_primary_mint_insufficient_for_fees() -> None: mock_token_wallet.melt.assert_not_called() -@pytest.mark.asyncio -async def test_swap_to_primary_mint_melt_error_wrapped() -> None: - """Melt failure from cashu lib is wrapped as ValueError.""" - from routstr.wallet import swap_to_primary_mint - - mock_token = Mock() - mock_token.mint = "http://foreign:3338" - mock_token.unit = "sat" - mock_token.amount = 5000 - mock_token.keysets = ["keyset1"] - mock_token.proofs = [{"amount": 5000}] - - mock_token_wallet = Mock() - mock_token_wallet.load_mint = 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_456" - 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_456" - mock_melt_quote.amount = 4940 - mock_melt_quote.fee_reserve = 50 # total 4990 < 5000, passes fee check - mock_token_wallet.melt_quote = AsyncMock(return_value=mock_melt_quote) - mock_token_wallet.melt = AsyncMock( - side_effect=Exception("Provided: 5000, needed: 5100 (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) - - @pytest.mark.asyncio async def test_recieve_token_untrusted_mint() -> None: mock_wallet = Mock() @@ -254,7 +212,6 @@ async def test_recieve_token_untrusted_mint() -> None: assert mint == "http://mint:3338" -@pytest.mark.asyncio @pytest.mark.asyncio async def test_swap_to_primary_mint_already_on_primary() -> None: from routstr.core.settings import settings @@ -282,54 +239,80 @@ async def test_swap_to_primary_mint_already_on_primary() -> None: mock_token_wallet.melt_quote.assert_not_called() -async def test_swap_to_primary_mint_success() -> None: - """Test successful swap with dynamic fee calculation.""" - from routstr.wallet import swap_to_primary_mint +# --------------------------------------------------------------------------- +# 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 = "http://foreign:3338" + mock_token.mint = mint_url mock_token.unit = "sat" - mock_token.amount = 1000 + mock_token.amount = token_amount mock_token.keysets = ["keyset1"] - mock_token.proofs = [{"amount": 1000}] + mock_token.proofs = [Mock(amount=token_amount)] mock_token_wallet = Mock() mock_token_wallet.load_mint = AsyncMock() mock_token_wallet.load_proofs = AsyncMock() - mock_token_wallet.get_fees_for_proofs = Mock(return_value=0) + 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()) - # Mocks for the estimation phase - # 1. request_mint(dummy_amount=1000) -> invoice_dummy - # 2. melt_quote(invoice_dummy) -> fee=10 + fees = iter(fee_reserves) - # Mocks for the execution phase - # 3. request_mint(minted_amount=990) -> invoice_real - # 4. melt_quote(invoice_real) -> amount=990, fee=10 - # 5. melt() -> success - # 6. mint() -> success + def _next_fee() -> int: + try: + return next(fees) + except StopIteration: + raise AssertionError( + "more melt quotes requested than fee_reserves provided" + ) from None - mock_mint_quote_dummy = Mock(quote="dummy_quote", request="lnbc_dummy") - mock_mint_quote_real = Mock(quote="real_quote", request="lnbc_real") - - # side_effect for request_mint to return dummy then real mock_primary_wallet.request_mint = AsyncMock( - side_effect=[mock_mint_quote_dummy, mock_mint_quote_real] + side_effect=lambda amount: Mock(quote=f"mint_quote_{amount}", request=amount) ) - - mock_melt_quote_dummy = Mock(amount=1000, fee_reserve=10) - mock_melt_quote_real = Mock(amount=990, fee_reserve=10) - - # side_effect for melt_quote mock_token_wallet.melt_quote = AsyncMock( - side_effect=[mock_melt_quote_dummy, mock_melt_quote_real] + side_effect=lambda invoice: Mock( + quote=f"melt_quote_{invoice}", amount=invoice, fee_reserve=_next_fee() + ) ) + mock_token_wallet.melt = AsyncMock(return_value=Mock()) - mock_token_wallet.melt = AsyncMock(return_value="melted_proofs") - mock_primary_wallet.mint = AsyncMock(return_value="minted_proofs") + 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 @@ -340,17 +323,230 @@ async def test_swap_to_primary_mint_success() -> None: mock_token, mock_token_wallet ) - assert amount == 990 # 1000 - 10 - assert unit == "sat" - assert mint == "http://primary:3338" + 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 - # Verify call order/counts - assert mock_primary_wallet.request_mint.call_count == 2 - # First call with full amount for estimation - mock_primary_wallet.request_mint.assert_any_call(1000) - # Second call with calculated amount - mock_primary_wallet.request_mint.assert_any_call(990) - assert mock_token_wallet.melt_quote.call_count == 2 - assert mock_token_wallet.melt.called - 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(), + ] + + 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(), + ] + + 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 +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 From cbdbe4b89992965fe319e8918dc50b792b6dae15 Mon Sep 17 00:00:00 2001 From: Jeroen Ubbink Date: Thu, 11 Jun 2026 14:52:09 +0200 Subject: [PATCH 2/4] test: cover all branches of the foreign-mint swap path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every statement and branch of the topup path (recieve_token, swap_to_primary_mint and its helpers, credit_balance) is now covered, including the previously untested mint-failure/recovery block: recovery must never credit proofs the wallet does not hold, and post-melt failures must propagate unwrapped since the foreign proofs are already spent. On testing internals: orchestration and side effects are tested only through the public functions, with mocks at the external mint boundary (the cashu Wallet object) — no internal code is ever mocked. The two pure leaf helpers (_melt_insufficient_shortfall, _net_minted_amount) are tested directly instead: their input spaces (seven error formats across mint implementations, sat/msat conversions both ways) would cost ~30 lines of quote/melt mock choreography per case through the swap loop, for the same assertion a parametrize row makes. The same-mint branch of _calculate_swap_amount is unreachable from public callers today; its test documents the contract for the planned pre-flight fee estimation, which will call it directly. Co-Authored-By: Claude Fable 5 --- tests/unit/test_wallet.py | 418 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 418 insertions(+) diff --git a/tests/unit/test_wallet.py b/tests/unit/test_wallet.py index 80d7163b..1b581eac 100644 --- a/tests/unit/test_wallet.py +++ b/tests/unit/test_wallet.py @@ -527,6 +527,424 @@ async def test_swap_melt_retries_exhausted() -> None: 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, + ), + # 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, + ), + # 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_propagates_unwrapped() -> None: + """A non-recoverable mint failure after melt propagates as-is (a 500, not a + client error): the melt already spent the foreign proofs.""" + 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(Exception, match="Quote is expired") as exc_info: + await swap_to_primary_mint(mock_token, mock_token_wallet) + + assert type(exc_info.value) is Exception # original error, not wrapped + 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(ValueError, 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(ValueError, match="recovery unsuccessful"): + await swap_to_primary_mint(mock_token, mock_token_wallet) + + +@pytest.mark.asyncio +async def test_recieve_token_rejects_multiple_keysets() -> None: + """Multi-keyset tokens are rejected before touching any wallet.""" + with patch("routstr.wallet.deserialize_token_from_string") as mock_deserialize: + mock_token = Mock() + mock_token.keysets = ["keyset1", "keyset2"] + mock_deserialize.return_value = mock_token + + with pytest.raises(ValueError, match="Multiple keysets"): + await recieve_token("cashuAmultikeyset") + + +@pytest.mark.asyncio +async def test_credit_balance_msat_unit_not_converted() -> None: + """msat-denominated redemptions are credited as-is, without a 1000x.""" + mock_key = Mock() + mock_key.balance = 0 + mock_key.hashed_key = "test_hash" + mock_session = AsyncMock() + + from routstr.core.settings import settings + + with patch.object(settings, "cashu_mints", ["http://mint:3338"]): + with patch( + "routstr.wallet.recieve_token", + return_value=(1_000_000, "msat", "http://mint:3338"), + ): + amount = await credit_balance("cashuAtest", mock_key, mock_session) + + assert amount == 1_000_000 + assert mock_session.commit.called + + +@pytest.mark.asyncio +async def test_credit_balance_survives_audit_store_failure() -> None: + """A failure writing the CashuTransaction history record must not undo the + already-committed balance credit. (The silent swallow is a known + audit-trail gap slated for its own fix — this test pins the financial + invariant that the user keeps their credit, not the swallow itself.)""" + mock_key = Mock() + mock_key.balance = 0 + mock_key.hashed_key = "test_hash" + mock_session = AsyncMock() + + from routstr.core.settings import settings + + with patch.object(settings, "cashu_mints", ["http://mint:3338"]): + with patch( + "routstr.wallet.recieve_token", + return_value=(1000, "sat", "http://mint:3338"), + ): + with patch( + "routstr.wallet.store_cashu_transaction", + side_effect=Exception("history table locked"), + ): + amount = await credit_balance("cashuAtest", mock_key, mock_session) + + assert amount == 1_000_000 + 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: From eb6dc5189c566bcb575075fce69f17befe7a3686 Mon Sep 17 00:00:00 2001 From: Jeroen Ubbink Date: Thu, 11 Jun 2026 15:02:10 +0200 Subject: [PATCH 3/4] test: pin the verbatim production error from issue #468 as retryable The reported error carries cashu-py's "could not pay invoice" wrapper around the mint detail; the classifier must still find the code and the shortfall inside it. Co-Authored-By: Claude Fable 5 --- tests/unit/test_wallet.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/tests/unit/test_wallet.py b/tests/unit/test_wallet.py index 1b581eac..a6f9cef7 100644 --- a/tests/unit/test_wallet.py +++ b/tests/unit/test_wallet.py @@ -556,6 +556,13 @@ async def test_net_minted_amount_unit_conversions( "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 From e4b26dd27b967af668bc7fd657ee8efb2080d137 Mon Sep 17 00:00:00 2001 From: Jeroen Ubbink Date: Tue, 23 Jun 2026 09:38:04 +0200 Subject: [PATCH 4/4] fix: require shortfall text for generic 11000 melt errors 11000 is nutshell's generic, unregistered TransactionError covering many unrelated failures, so trusting the code alone made any 11000 retryable. Gate the generic code on the 'not enough inputs' detail text; keep trusting the registered 11005 (TransactionUnbalanced) on the code alone. Behavior is unchanged for every existing case; a bare non-shortfall 11000 now surfaces immediately instead of burning the retry budget. Co-Authored-By: Claude Opus 4.8 --- routstr/wallet.py | 26 ++++++++++++++++++-------- tests/unit/test_wallet.py | 4 ++++ 2 files changed, 22 insertions(+), 8 deletions(-) diff --git a/routstr/wallet.py b/routstr/wallet.py index 71b6cab7..3f789d20 100644 --- a/routstr/wallet.py +++ b/routstr/wallet.py @@ -127,10 +127,12 @@ _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 across implementations: 11005 is the registered -# "Transaction is not balanced" code (used by cdk); 11000 is nutshell's -# generic TransactionError, which is unregistered but what nutshell sends. -_RETRYABLE_MELT_CODES = frozenset({"11000", "11005"}) +# 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: @@ -160,11 +162,19 @@ def _melt_insufficient_shortfall(error: Exception) -> int | None: """ message = str(error) code_match = _MINT_ERROR_CODE_RE.search(message) - if code_match is not None: - if code_match.group(1) not in _RETRYABLE_MELT_CODES: + 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 - elif "not enough inputs" not in message.lower(): - return None + amounts = _MELT_SHORTFALL_RE.search(message) if amounts is not None: provided, needed = int(amounts.group(1)), int(amounts.group(2)) diff --git a/tests/unit/test_wallet.py b/tests/unit/test_wallet.py index a6f9cef7..afaf05b0 100644 --- a/tests/unit/test_wallet.py +++ b/tests/unit/test_wallet.py @@ -573,6 +573,10 @@ async def test_net_minted_amount_unit_conversions( "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