diff --git a/routstr/wallet.py b/routstr/wallet.py index 30f36ded..4a7b7797 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 @@ -129,6 +130,72 @@ 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 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 = 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) + 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 + + async def _calculate_swap_amount( amount_msat: int, token_unit: str, @@ -167,26 +234,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, @@ -251,67 +310,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 40a4df6c..7d3875e8 100644 --- a/tests/unit/test_wallet.py +++ b/tests/unit/test_wallet.py @@ -208,7 +208,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() @@ -250,50 +252,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() @@ -361,54 +319,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 @@ -419,17 +403,659 @@ 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 +@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_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: + 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