prevent zero balance token

This commit is contained in:
9qeklajc
2026-06-01 22:47:09 +02:00
parent 9aa3408905
commit 9633483fa4
3 changed files with 52 additions and 0 deletions
+5
View File
@@ -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)
+14
View File
@@ -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},
+33
View File
@@ -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."""