From ecd46975b47b5c7ca635200d16f1bf5659e173f6 Mon Sep 17 00:00:00 2001 From: Jeroen Ubbink Date: Wed, 17 Jun 2026 17:10:37 +0200 Subject: [PATCH 1/3] fix: deduct mint NUT-02 input fee when crediting trusted-mint topups recieve_token swaps the incoming proofs at the same mint with include_fees=True (paying the mint's NUT-02 per-proof input fee) but credited the full face value. On every topup from a fee-charging trusted mint, routstr over-credited the user by the fee and its own wallet drifted toward insolvency. Subtract get_fees_for_proofs(proofs) from the credited amount, mirroring the foreign-mint swap path which already accounts for it. Adds a fee-charging trusted-mint unit test (credited == face - input_fee). Co-Authored-By: Claude Opus 4.8 --- routstr/wallet.py | 8 ++++- tests/unit/test_wallet.py | 61 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 68 insertions(+), 1 deletion(-) diff --git a/routstr/wallet.py b/routstr/wallet.py index 69ff6f82..0fe984b8 100644 --- a/routstr/wallet.py +++ b/routstr/wallet.py @@ -51,9 +51,15 @@ async def recieve_token( await wallet.load_mint(keyset_id=token_obj.keysets[0]) wallet.verify_proofs_dleq(token_obj.proofs) + # Same-mint receive (not swap_to_primary_mint): split() re-mints the incoming + # proofs into fresh ones we own so the sender can't double-spend them. With + # include_fees=True the mint deducts its NUT-02 per-proof input fee, so we end + # up holding only `amount - input_fees`. Credit that, not the face value, or + # routstr over-credits the user and its wallet drifts insolvent. + input_fees = wallet.get_fees_for_proofs(token_obj.proofs) await wallet.split(proofs=token_obj.proofs, amount=0, include_fees=True) - return token_obj.amount, token_obj.unit, token_obj.mint + return token_obj.amount - input_fees, token_obj.unit, token_obj.mint async def send(amount: int, unit: str, mint_url: str | None = None) -> tuple[int, str]: diff --git a/tests/unit/test_wallet.py b/tests/unit/test_wallet.py index 245a624a..e536baf9 100644 --- a/tests/unit/test_wallet.py +++ b/tests/unit/test_wallet.py @@ -39,6 +39,8 @@ async def test_recieve_token_valid() -> None: mock_wallet = Mock() mock_wallet.split = AsyncMock() + # Fee-free trusted mint (e.g. Minibits): nothing deducted. + mock_wallet.get_fees_for_proofs = Mock(return_value=0) from routstr.core.settings import settings @@ -61,6 +63,65 @@ async def test_recieve_token_valid() -> None: assert mint == "http://mint:3338" +@pytest.mark.asyncio +async def test_recieve_token_trusted_mint_deducts_input_fee() -> None: + """A trusted mint that charges NUT-02 input fees. + + The same-mint receive (`wallet.split(..., include_fees=True)`, a NUT-03 swap + at the same mint — not swap_to_primary_mint) pays the mint's per-proof fee, + so routstr only ends up with `face - input_fee` in fresh proofs. The credited + amount must reflect that, otherwise routstr over-credits the user and its own + wallet drifts toward insolvency. + """ + token_data = { + "token": [ + { + "mint": "http://mint:3338", + "proofs": [ + {"amount": 1000, "id": "test", "secret": "secret", "C": "curve"} + ], + } + ], + "unit": "sat", + } + token_json = json.dumps(token_data) + token_b64 = base64.urlsafe_b64encode(token_json.encode()).decode() + token_str = f"cashuA{token_b64}" + + mock_wallet = Mock() + mock_wallet.split = AsyncMock() + # 21 proofs @ 100 ppk -> (21*100 + 999) // 1000 = 3 sat input fee + mock_wallet.get_fees_for_proofs = Mock(return_value=3) + + from routstr.core.settings import settings + + with patch.object(settings, "cashu_mints", ["http://mint:3338"]): + with patch("routstr.wallet.deserialize_token_from_string") as mock_deserialize: + mock_token = Mock() + mock_token.keysets = ["keyset1"] + mock_token.mint = "http://mint:3338" + mock_token.unit = "sat" + mock_token.amount = 1000 + mock_token.proofs = [{"amount": 1000}] + mock_deserialize.return_value = mock_token + + mock_wallet.load_mint = AsyncMock() + mock_wallet.load_proofs = AsyncMock() + # Patch get_wallet directly so the module-level `_wallets` cache + # (keyed by mint URL) can't hand back a wallet from another test. + with patch( + "routstr.wallet.get_wallet", + AsyncMock(return_value=mock_wallet), + ): + amount, unit, mint = await recieve_token(token_str) + assert amount == 997 # 1000 face - 3 sat input fee paid on swap + assert unit == "sat" + assert mint == "http://mint:3338" + mock_wallet.get_fees_for_proofs.assert_called_once_with( + mock_token.proofs + ) + + @pytest.mark.asyncio async def test_send_token() -> None: mock_wallet = Mock() From 7969a8da55da01a6868ee98181640b2d2f5fad44 Mon Sep 17 00:00:00 2001 From: Jeroen Ubbink Date: Thu, 18 Jun 2026 12:26:44 +0200 Subject: [PATCH 2/3] test: clarify mocked input-fee comment in trusted-mint test The comment described "21 proofs @ 100 ppk" arithmetic, but the mocked token has one proof and get_fees_for_proofs is hard-mocked to 3, so the math wasn't exercised. Describe what the mock actually does. Co-Authored-By: Claude Opus 4.8 --- tests/unit/test_wallet.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/unit/test_wallet.py b/tests/unit/test_wallet.py index e536baf9..e972232a 100644 --- a/tests/unit/test_wallet.py +++ b/tests/unit/test_wallet.py @@ -90,7 +90,7 @@ async def test_recieve_token_trusted_mint_deducts_input_fee() -> None: mock_wallet = Mock() mock_wallet.split = AsyncMock() - # 21 proofs @ 100 ppk -> (21*100 + 999) // 1000 = 3 sat input fee + # Mock a 3-sat input fee from the Cashu wallet API. mock_wallet.get_fees_for_proofs = Mock(return_value=3) from routstr.core.settings import settings From 75ed865a5f0cd202d97820b2d2c9ec4558b696a0 Mon Sep 17 00:00:00 2001 From: Jeroen Ubbink Date: Thu, 18 Jun 2026 15:48:49 +0200 Subject: [PATCH 3/3] fix: deduct input fee in swap_to_primary_mint same-mint shortcut MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The same-mint shortcut in swap_to_primary_mint did a same-mint split(include_fees=True) — which burns the mint's NUT-02 per-proof input fee — but returned the full token amount, over-crediting the user (the same bug already fixed for the trusted-mint receive path). It also skipped DLEQ verification that the trusted path performs. Extract the shared same-mint redeem into _redeem_same_mint (load mint, verify DLEQ, split, credit amount - input_fees) and delegate from both recieve_token and the shortcut, so the two paths can't drift again. This shortcut is reachable when PRIMARY_MINT_URL is set outside CASHU_MINTS. Co-Authored-By: Claude Opus 4.8 --- routstr/wallet.py | 43 +++++++++++++++++++++++---------------- tests/unit/test_wallet.py | 24 +++++++++++++++++++--- 2 files changed, 46 insertions(+), 21 deletions(-) diff --git a/routstr/wallet.py b/routstr/wallet.py index 0fe984b8..815da134 100644 --- a/routstr/wallet.py +++ b/routstr/wallet.py @@ -35,6 +35,24 @@ async def get_balance(unit: str) -> int: return wallet.available_balance.amount +async def _redeem_same_mint( + wallet: Wallet, token_obj: Token +) -> tuple[int, str, str]: # amount, unit, mint_url + """Redeem proofs at their own issuing mint (no cross-mint swap). + + split() re-mints the incoming proofs into fresh ones we own so the sender + can't double-spend them. With include_fees=True the mint deducts its NUT-02 + per-proof input fee, so we end up holding only `amount - input_fees`. Credit + that, not the face value, or routstr over-credits the user and its wallet + drifts insolvent. + """ + await wallet.load_mint(keyset_id=token_obj.keysets[0]) + wallet.verify_proofs_dleq(token_obj.proofs) + input_fees = wallet.get_fees_for_proofs(token_obj.proofs) + await wallet.split(proofs=token_obj.proofs, amount=0, include_fees=True) + return int(token_obj.amount) - input_fees, token_obj.unit, token_obj.mint + + async def recieve_token( token: str, ) -> tuple[int, str, str]: # amount, unit, mint_url @@ -48,18 +66,7 @@ async def recieve_token( if token_obj.mint not in settings.cashu_mints: return await swap_to_primary_mint(token_obj, wallet) - await wallet.load_mint(keyset_id=token_obj.keysets[0]) - - wallet.verify_proofs_dleq(token_obj.proofs) - # Same-mint receive (not swap_to_primary_mint): split() re-mints the incoming - # proofs into fresh ones we own so the sender can't double-spend them. With - # include_fees=True the mint deducts its NUT-02 per-proof input fee, so we end - # up holding only `amount - input_fees`. Credit that, not the face value, or - # routstr over-credits the user and its wallet drifts insolvent. - input_fees = wallet.get_fees_for_proofs(token_obj.proofs) - await wallet.split(proofs=token_obj.proofs, amount=0, include_fees=True) - - return token_obj.amount - input_fees, token_obj.unit, token_obj.mint + return await _redeem_same_mint(wallet, token_obj) async def send(amount: int, unit: str, mint_url: str | None = None) -> tuple[int, str]: @@ -219,10 +226,9 @@ async def swap_to_primary_mint( amount_msat = token_amount else: raise ValueError("Invalid unit") - primary_wallet = await get_wallet(settings.primary_mint, settings.primary_mint_unit) - - # If the token is already from the primary mint, we don't need to swap - # and we definitely don't want to calculate or pay fees. + # If the token is already from the primary mint, we don't need a cross-mint + # swap — redeem it same-mint. There's no melt/Lightning fee, but the mint's + # NUT-02 input fee still applies; _redeem_same_mint accounts for it. if token_obj.mint == settings.primary_mint: logger.info( "swap_to_primary_mint: token already on primary mint, skipping swap", @@ -232,8 +238,9 @@ async def swap_to_primary_mint( "unit": token_obj.unit, }, ) - await token_wallet.split(proofs=token_obj.proofs, amount=0, include_fees=True) - return token_amount, token_obj.unit, token_obj.mint + return await _redeem_same_mint(token_wallet, token_obj) + + primary_wallet = await get_wallet(settings.primary_mint, settings.primary_mint_unit) minted_amount = await _calculate_swap_amount( amount_msat, diff --git a/tests/unit/test_wallet.py b/tests/unit/test_wallet.py index e972232a..40a4df6c 100644 --- a/tests/unit/test_wallet.py +++ b/tests/unit/test_wallet.py @@ -120,6 +120,10 @@ async def test_recieve_token_trusted_mint_deducts_input_fee() -> None: mock_wallet.get_fees_for_proofs.assert_called_once_with( mock_token.proofs ) + # DLEQ is verified before re-minting the incoming proofs. + mock_wallet.verify_proofs_dleq.assert_called_once_with( + mock_token.proofs + ) @pytest.mark.asyncio @@ -315,19 +319,31 @@ 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: + """Same-mint shortcut: the token is already on the primary mint. + + No cross-mint swap (no melt/mint), but the same-mint split(include_fees=True) + still burns the mint's NUT-02 input fee, so the credited amount must be face + minus the input fee — not full face value (the over-credit bug). DLEQ is + verified too, matching the trusted same-mint receive path. + """ from routstr.core.settings import settings from routstr.wallet import swap_to_primary_mint mock_token = Mock() mock_token.mint = settings.primary_mint + mock_token.keysets = ["keyset1"] mock_token.amount = 1000 mock_token.unit = "sat" - mock_token.proofs = [] + mock_token.proofs = [{"amount": 1000}] mock_token_wallet = Mock() + mock_token_wallet.load_mint = AsyncMock() + mock_token_wallet.load_proofs = AsyncMock() + mock_token_wallet.verify_proofs_dleq = Mock() + # Mock a 3-sat input fee from the Cashu wallet API. + mock_token_wallet.get_fees_for_proofs = Mock(return_value=3) mock_token_wallet.split = AsyncMock(return_value=None) mock_token_wallet.request_mint = AsyncMock() mock_token_wallet.melt_quote = AsyncMock() @@ -335,9 +351,11 @@ async def test_swap_to_primary_mint_already_on_primary() -> None: with patch("routstr.wallet.get_wallet", AsyncMock(return_value=mock_token_wallet)): amount, unit, mint = await swap_to_primary_mint(mock_token, mock_token_wallet) - assert amount == 1000 + assert amount == 997 # 1000 face - 3 sat input fee assert unit == "sat" assert mint == settings.primary_mint + mock_token_wallet.verify_proofs_dleq.assert_called_once_with(mock_token.proofs) + mock_token_wallet.get_fees_for_proofs.assert_called_once_with(mock_token.proofs) mock_token_wallet.split.assert_called_once() mock_token_wallet.request_mint.assert_not_called() mock_token_wallet.melt_quote.assert_not_called()