From 9d5e14903ba99cf9cf73d8dc93b6d87954fc3427 Mon Sep 17 00:00:00 2001 From: 9qeklajc <9qeklajc> Date: Wed, 13 May 2026 00:02:25 +0200 Subject: [PATCH] fix swapping --- routstr/balance.py | 19 ++++++++-- routstr/wallet.py | 80 +++++++++++++++++++++++++++++---------- tests/unit/test_wallet.py | 3 ++ 3 files changed, 78 insertions(+), 24 deletions(-) diff --git a/routstr/balance.py b/routstr/balance.py index 7128916d..4c1d194a 100644 --- a/routstr/balance.py +++ b/routstr/balance.py @@ -379,15 +379,26 @@ async def refund_wallet_endpoint( # Minting failed — restore the debited balance await _restore_balance(session, key.hashed_key, pre_debit_balance, pre_debit_reserved) error_msg = str(e) + logger.error( + "refund_wallet_endpoint: mint/send failed", + extra={ + "error": error_msg, + "error_type": type(e).__name__, + "hashed_key": key.hashed_key, + "remaining_balance": remaining_balance, + "refund_currency": key.refund_currency, + "refund_mint_url": key.refund_mint_url, + "has_refund_address": bool(key.refund_address), + }, + ) if ( "mint" in error_msg.lower() or "connection" in error_msg.lower() - or isinstance(e, Exception) - and "ConnectError" in str(type(e)) + or "ConnectError" in str(type(e)) ): - raise HTTPException(status_code=503, detail="Mint service unavailable") + raise HTTPException(status_code=503, detail=f"Mint service unavailable: {error_msg}") else: - raise HTTPException(status_code=500, detail="Refund failed") + raise HTTPException(status_code=500, detail=f"Refund failed: {error_msg}") await _refund_cache_set(bearer_value, result) diff --git a/routstr/wallet.py b/routstr/wallet.py index ea56a565..a425dbac 100644 --- a/routstr/wallet.py +++ b/routstr/wallet.py @@ -61,12 +61,20 @@ async def send(amount: int, unit: str, mint_url: str | None = None) -> tuple[int wallet, mint_url or settings.primary_mint, unit ) + # Reserve proofs only after serialization succeeds — if serialize_proofs or + # swap_to_send fails mid-way, proofs stay unreserved so dashboard balance + # doesn't go negative. send_proofs, _ = await wallet.select_to_send( - proofs, amount, set_reserved=True, include_fees=False - ) - token = await wallet.serialize_proofs( - send_proofs, include_dleq=False, legacy=False, memo=None + proofs, amount, set_reserved=False, include_fees=False ) + try: + token = await wallet.serialize_proofs( + send_proofs, include_dleq=False, legacy=False, memo=None + ) + except Exception: + await wallet.set_reserved_for_send(send_proofs, reserved=False) + raise + await wallet.set_reserved_for_send(send_proofs, reserved=True) return amount, token @@ -81,10 +89,11 @@ async def _calculate_swap_amount( token_mint_url: str, token_wallet: Wallet, primary_wallet: Wallet, + proofs: list, ) -> int: """ Calculate the amount to mint on the primary mint after accounting for - potential swap fees (melt fees) on the foreign mint. + melt fees and NUT-02 input fees on the foreign mint. """ if settings.primary_mint_unit == "sat": receive_amount = amount_msat // 1000 @@ -111,10 +120,11 @@ async def _calculate_swap_amount( dummy_melt_quote = await token_wallet.melt_quote(dummy_mint_quote.request) fee_reserve = dummy_melt_quote.fee_reserve + input_fees = token_wallet.get_fees_for_proofs(proofs) if token_unit == "sat": - fee_msat = fee_reserve * 1000 + fee_msat = (fee_reserve + input_fees) * 1000 else: - fee_msat = fee_reserve + fee_msat = fee_reserve + input_fees amount_msat_after_fee = amount_msat - fee_msat @@ -124,13 +134,14 @@ async def _calculate_swap_amount( minted_amount = int(amount_msat_after_fee) if minted_amount <= 0: - raise ValueError(f"Fees ({fee_reserve} {token_unit}) exceed token amount") + raise ValueError(f"Fees ({fee_reserve + input_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, + "input_fees": input_fees, "minted_amount": minted_amount, "minted_unit": settings.primary_mint_unit, }, @@ -191,6 +202,7 @@ async def swap_to_primary_mint( token_obj.mint, token_wallet, primary_wallet, + token_obj.proofs, ) mint_quote = await primary_wallet.request_mint(minted_amount) @@ -200,13 +212,15 @@ async def swap_to_primary_mint( ) melt_quote = await token_wallet.melt_quote(mint_quote.request) - total_needed = melt_quote.amount + melt_quote.fee_reserve + 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, }, @@ -219,6 +233,7 @@ async def swap_to_primary_mint( "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, }, @@ -226,7 +241,7 @@ async def swap_to_primary_mint( 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})" + f"(amount: {melt_quote.amount} + fee: {melt_quote.fee_reserve} + input_fees: {input_fees})" ) try: @@ -260,16 +275,41 @@ async def swap_to_primary_mint( 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 + if "11003" in str(e) or "outputs already signed" in str(e).lower(): + # Previous mint call signed outputs at the mint but failed before + # bump_secret_derivation ran locally. Recover orphaned proofs and + # advance the counter so the next request derives fresh secrets. + logger.warning( + "swap_to_primary_mint: outputs already signed — recovering orphaned proofs", + extra={"mint_quote_id": mint_quote.quote, "minted_amount": minted_amount}, + ) + try: + for keyset_id in primary_wallet.keysets: + await primary_wallet.restore_tokens_for_keyset(keyset_id, to=1, batch=25) + await primary_wallet.load_proofs(reload=True) + logger.info( + "swap_to_primary_mint: recovery succeeded", + extra={"balance": primary_wallet.available_balance.amount}, + ) + except Exception as recovery_err: + logger.error( + "swap_to_primary_mint: recovery failed", + extra={"error": str(recovery_err)}, + ) + raise ValueError( + f"Mint on primary failed and recovery unsuccessful: {e}" + ) from e + else: + 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", diff --git a/tests/unit/test_wallet.py b/tests/unit/test_wallet.py index 35a5694b..78bb71c2 100644 --- a/tests/unit/test_wallet.py +++ b/tests/unit/test_wallet.py @@ -123,6 +123,7 @@ async def test_swap_to_primary_mint_insufficient_for_fees() -> None: 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() @@ -166,6 +167,7 @@ async def test_swap_to_primary_mint_melt_error_wrapped() -> None: 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() @@ -261,6 +263,7 @@ async def test_swap_to_primary_mint_success() -> None: 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()