From b76ff62f1cf5d2e40ab144ed0a9daee100acefba Mon Sep 17 00:00:00 2001 From: 9qeklajc Date: Mon, 23 Mar 2026 20:56:04 +0100 Subject: [PATCH] improve mint fee calculation --- routstr/wallet.py | 95 +++++++++++++++++++++++++++++++-------- tests/unit/test_wallet.py | 74 ++++++++++++++++++++++++++++++ 2 files changed, 150 insertions(+), 19 deletions(-) diff --git a/routstr/wallet.py b/routstr/wallet.py index 76372f75..a18199c0 100644 --- a/routstr/wallet.py +++ b/routstr/wallet.py @@ -1,5 +1,4 @@ import asyncio -import math import time from typing import TypedDict @@ -60,6 +59,76 @@ async def send_token(amount: int, unit: str, mint_url: str | None = None) -> str return token +async def _calculate_swap_amount( + amount_msat: int, + token_unit: str, + token_mint_url: str, + token_wallet: Wallet, + primary_wallet: Wallet, +) -> int: + """ + Calculate the amount to mint on the primary mint after accounting for + potential swap fees (melt fees) on the foreign mint. + """ + if settings.primary_mint_unit == "sat": + receive_amount = amount_msat // 1000 + else: + receive_amount = amount_msat + + if token_mint_url == settings.primary_mint: + logger.info( + "swap_to_primary_mint: skipping fee estimation (same mint)", + extra={"minted_amount": receive_amount}, + ) + return int(receive_amount) + + logger.info( + "swap_to_primary_mint: estimating fees", + extra={ + "dummy_amount": receive_amount, + "unit": settings.primary_mint_unit, + }, + ) + + try: + dummy_mint_quote = await primary_wallet.request_mint(receive_amount) + dummy_melt_quote = await token_wallet.melt_quote(dummy_mint_quote.request) + + fee_reserve = dummy_melt_quote.fee_reserve + if token_unit == "sat": + fee_msat = fee_reserve * 1000 + else: + fee_msat = fee_reserve + + 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) + + if minted_amount <= 0: + raise ValueError(f"Fees ({fee_reserve} {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, + "minted_amount": minted_amount, + "minted_unit": settings.primary_mint_unit, + }, + ) + return minted_amount + + except Exception as e: + logger.error( + "swap_to_primary_mint: fee estimation failed", + extra={"error": str(e)}, + ) + raise ValueError(f"Failed to estimate fees: {e}") from e + + async def swap_to_primary_mint( token_obj: Token, token_wallet: Wallet ) -> tuple[int, str, str]: @@ -84,26 +153,14 @@ async def swap_to_primary_mint( amount_msat = token_amount else: raise ValueError("Invalid unit") - if token_obj.mint != settings.primary_mint: - estimated_fee_sat = math.ceil(max(amount_msat // 1000 * 0.01, 2)) + 1 - else: - estimated_fee_sat = 0 - amount_msat_after_fee = amount_msat - estimated_fee_sat * 1000 primary_wallet = await get_wallet(settings.primary_mint, settings.primary_mint_unit) - if settings.primary_mint_unit == "sat": - minted_amount = int(amount_msat_after_fee // 1000) - else: - minted_amount = int(amount_msat_after_fee) - - logger.info( - "swap_to_primary_mint: fee estimation", - extra={ - "token_amount_sat": amount_msat // 1000, - "estimated_fee_sat": estimated_fee_sat, - "minted_amount": minted_amount, - "minted_unit": settings.primary_mint_unit, - }, + minted_amount = await _calculate_swap_amount( + amount_msat, + token_obj.unit, + token_obj.mint, + token_wallet, + primary_wallet, ) mint_quote = await primary_wallet.request_mint(minted_amount) diff --git a/tests/unit/test_wallet.py b/tests/unit/test_wallet.py index 35ef085e..6635b24f 100644 --- a/tests/unit/test_wallet.py +++ b/tests/unit/test_wallet.py @@ -217,3 +217,77 @@ async def test_recieve_token_untrusted_mint() -> None: assert amount == 900 assert unit == "sat" assert mint == "http://mint:3338" + + +@pytest.mark.asyncio +async def test_swap_to_primary_mint_success() -> None: + """Test successful swap with dynamic fee calculation.""" + from routstr.wallet import swap_to_primary_mint + + mock_token = Mock() + mock_token.mint = "http://foreign:3338" + mock_token.unit = "sat" + mock_token.amount = 1000 + mock_token.keysets = ["keyset1"] + mock_token.proofs = [{"amount": 1000}] + + mock_token_wallet = Mock() + mock_token_wallet.load_mint = AsyncMock() + mock_token_wallet.load_proofs = AsyncMock() + + mock_primary_wallet = Mock() + mock_primary_wallet.load_mint = AsyncMock() + mock_primary_wallet.load_proofs = AsyncMock() + + # Mocks for the estimation phase + # 1. request_mint(dummy_amount=1000) -> invoice_dummy + # 2. melt_quote(invoice_dummy) -> fee=10 + + # 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 + + 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] + ) + + 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] + ) + + mock_token_wallet.melt = AsyncMock(return_value="melted_proofs") + mock_primary_wallet.mint = AsyncMock(return_value="minted_proofs") + + from routstr.core.settings import settings + + with patch.object(settings, "primary_mint", "http://primary:3338"): + with patch.object(settings, "primary_mint_unit", "sat"): + with patch("routstr.wallet.get_wallet", return_value=mock_primary_wallet): + amount, unit, mint = await swap_to_primary_mint( + mock_token, mock_token_wallet + ) + + assert amount == 990 # 1000 - 10 + assert unit == "sat" + assert mint == "http://primary:3338" + + # 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