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 <noreply@anthropic.com>
This commit is contained in:
Jeroen Ubbink
2026-06-11 15:08:38 +02:00
co-authored by Claude Fable 5
parent a16bc1220c
commit 7870db9af1
3 changed files with 639 additions and 151 deletions
+163 -65
View File
@@ -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: <detail> (Code: <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",
+194
View File
@@ -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()
+282 -86
View File
@@ -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