From 9633483fa434056ce9081eaa7f7e1ef1c09c7b80 Mon Sep 17 00:00:00 2001 From: 9qeklajc Date: Mon, 1 Jun 2026 22:47:09 +0200 Subject: [PATCH] prevent zero balance token --- routstr/auth.py | 5 +++++ routstr/wallet.py | 14 ++++++++++++++ tests/unit/test_wallet.py | 33 +++++++++++++++++++++++++++++++++ 3 files changed, 52 insertions(+) diff --git a/routstr/auth.py b/routstr/auth.py index c08ed555..b3f353d2 100644 --- a/routstr/auth.py +++ b/routstr/auth.py @@ -341,6 +341,11 @@ async def validate_bearer_key( "Token redemption returned zero or negative amount", extra={"msats": msats, "key_hash": hashed_key[:8] + "..."}, ) + # Defense-in-depth: credit_balance now refuses to commit on a + # zero/negative redemption, but if a row was nonetheless + # persisted, drop it so we never leave an orphan zero-balance key. + await session.delete(new_key) + await session.commit() raise Exception("Token redemption failed") await session.refresh(new_key) diff --git a/routstr/wallet.py b/routstr/wallet.py index 2b8308b8..69ff6f82 100644 --- a/routstr/wallet.py +++ b/routstr/wallet.py @@ -403,6 +403,20 @@ async def credit_balance( "credit_balance: Converted to msat", extra={"amount_msat": amount} ) + # Guard against zero/negative redemptions (empty or dust tokens, or + # swap-to-primary-mint amounts that net to <= 0 after fees). Raising here + # — before the UPDATE/commit below — leaves any freshly-created, still + # uncommitted ApiKey row to be rolled back when the request session + # closes, instead of persisting an orphan key with balance 0. + if amount <= 0: + logger.error( + "credit_balance: Redeemed amount is zero or negative; refusing to credit", + extra={"amount": amount, "unit": unit, "mint_url": mint_url}, + ) + raise ValueError( + f"Redeemed token amount must be positive, got {amount} msats" + ) + logger.info( "credit_balance: Updating balance", extra={"old_balance": key.balance, "credit_amount": amount}, diff --git a/tests/unit/test_wallet.py b/tests/unit/test_wallet.py index 78bb71c2..245a624a 100644 --- a/tests/unit/test_wallet.py +++ b/tests/unit/test_wallet.py @@ -108,6 +108,39 @@ async def test_credit_balance() -> None: assert mock_session.refresh.called +@pytest.mark.asyncio +async def test_credit_balance_rejects_zero_amount() -> None: + """A zero/dust redemption must raise BEFORE any commit, so no orphan + zero-balance key (balance 0, total_spent 0, total_requests 0) is persisted.""" + token_data = { + "token": [{"mint": "http://mint:3338", "proofs": [{"amount": 0}]}], + "unit": "sat", + } + token_json = json.dumps(token_data) + token_b64 = base64.urlsafe_b64encode(token_json.encode()).decode() + token_str = f"cashuA{token_b64}" + + mock_key = Mock() + mock_key.balance = 0 + mock_key.hashed_key = "test_hash" + mock_session = AsyncMock() + + from routstr.core.settings import settings + + with patch.object(settings, "cashu_mints", ["http://mint:3338"]): + with patch( + "routstr.wallet.recieve_token", + return_value=(0, "sat", "http://mint:3338"), + ): + with pytest.raises(ValueError, match="must be positive"): + await credit_balance(token_str, mock_key, mock_session) + + # Critically: no balance UPDATE and no commit happened, so the caller's + # uncommitted key row rolls back instead of persisting as an orphan. + assert not mock_session.exec.called + assert not mock_session.commit.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."""