From 901a6a9ba226e3de0876d6373490fd988e8398c1 Mon Sep 17 00:00:00 2001 From: 9qeklajc Date: Mon, 16 Mar 2026 21:40:43 +0100 Subject: [PATCH] fix swapping the primary mint --- routstr/balance.py | 18 ++++++- routstr/wallet.py | 108 ++++++++++++++++++++++++++++++++++---- tests/unit/test_wallet.py | 86 ++++++++++++++++++++++++++++++ 3 files changed, 201 insertions(+), 11 deletions(-) diff --git a/routstr/balance.py b/routstr/balance.py index b738a6c3..9f86f452 100644 --- a/routstr/balance.py +++ b/routstr/balance.py @@ -154,9 +154,23 @@ async def topup_wallet_endpoint( raise HTTPException(status_code=400, detail="Token already spent") elif "invalid" in error_msg.lower() or "decode" in error_msg.lower(): raise HTTPException(status_code=400, detail="Invalid token format") + elif "insufficient" in error_msg.lower() or "melt fee" in error_msg.lower(): + raise HTTPException( + status_code=400, + detail=f"Token value is too small to cover swap fees. {error_msg}", + ) + elif "failed to melt" in error_msg.lower(): + raise HTTPException( + status_code=400, + detail=f"Failed to swap foreign mint token. {error_msg}", + ) else: - raise HTTPException(status_code=400, detail="Failed to redeem token") - except Exception: + raise HTTPException(status_code=400, detail=f"Failed to redeem token: {error_msg}") + except Exception as e: + logger.error( + "topup_wallet_endpoint: unhandled error", + extra={"error": str(e), "error_type": type(e).__name__}, + ) raise HTTPException(status_code=500, detail="Internal server error") return {"msats": amount_msats} diff --git a/routstr/wallet.py b/routstr/wallet.py index 71ea18ac..1ebf8e2a 100644 --- a/routstr/wallet.py +++ b/routstr/wallet.py @@ -62,11 +62,12 @@ async def swap_to_primary_mint( token_obj: Token, token_wallet: Wallet ) -> tuple[int, str, str]: logger.info( - "swap_to_primary_mint", + "swap_to_primary_mint: starting", extra={ - "mint": token_obj.mint, - "amount": token_obj.amount, + "foreign_mint": token_obj.mint, + "token_amount": token_obj.amount, "unit": token_obj.unit, + "primary_mint": settings.primary_mint, }, ) # Ensure amount is an integer @@ -89,16 +90,105 @@ async def swap_to_primary_mint( 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, + }, + ) + 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}, + ) melt_quote = await token_wallet.melt_quote(mint_quote.request) - _ = await token_wallet.melt( - proofs=token_obj.proofs, - invoice=mint_quote.request, - fee_reserve_sat=melt_quote.fee_reserve, - quote_id=melt_quote.quote, + total_needed = melt_quote.amount + melt_quote.fee_reserve + 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, + "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", + extra={ + "token_amount": token_amount, + "melt_amount": melt_quote.amount, + "melt_fee_reserve": melt_quote.fee_reserve, + "total_needed": total_needed, + "shortfall": total_needed - token_amount, + }, + ) + 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})" + ) + + 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 + + logger.info( + "swap_to_primary_mint: melt succeeded, minting on primary", + extra={"minted_amount": minted_amount, "mint_quote_id": mint_quote.quote}, + ) + + try: + _ = await primary_wallet.mint(minted_amount, quote_id=mint_quote.quote) + except Exception as e: + logger.error( + "swap_to_primary_mint: mint on primary failed after successful melt", + extra={ + "error": str(e), + "error_type": type(e).__name__, + "minted_amount": minted_amount, + "mint_quote_id": mint_quote.quote, + }, + ) + raise + + logger.info( + "swap_to_primary_mint: completed successfully", + extra={ + "foreign_mint": token_obj.mint, + "primary_mint": settings.primary_mint, + "original_amount": token_amount, + "minted_amount": minted_amount, + "unit": settings.primary_mint_unit, + }, ) - _ = await primary_wallet.mint(minted_amount, quote_id=mint_quote.quote) return int(minted_amount), settings.primary_mint_unit, settings.primary_mint diff --git a/tests/unit/test_wallet.py b/tests/unit/test_wallet.py index f9a49434..35ef085e 100644 --- a/tests/unit/test_wallet.py +++ b/tests/unit/test_wallet.py @@ -108,6 +108,92 @@ async def test_credit_balance() -> None: assert mock_session.refresh.called +@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.""" + from routstr.wallet import swap_to_primary_mint + + mock_token = Mock() + mock_token.mint = "http://foreign:3338" + mock_token.unit = "sat" + mock_token.amount = 404 + mock_token.keysets = ["keyset1"] + mock_token.proofs = [{"amount": 404}] + + 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() + + mock_mint_quote = Mock() + mock_mint_quote.quote = "mint_quote_123" + 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_123" + mock_melt_quote.amount = 400 + mock_melt_quote.fee_reserve = 12 # total needed: 412 > 404 + mock_token_wallet.melt_quote = AsyncMock(return_value=mock_melt_quote) + + 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) + + # melt should never have been called + 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_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()