feat: negative cache for terminal Cashu redemption failures

A dead token (already spent, malformed, zero value) presented as a bearer
key triggers a full mint redemption attempt on every request, because the
failed attempt rolls back and leaves no api_keys row behind. Polling
clients that never back off turn one dead token into thousands of
pointless mint calls per day (~800/day observed against minibits alone),
hammering mints that are already degraded.

Add an in-memory bounded TTL+LRU cache keyed by token hash that remembers
terminal redemption failures (already spent, invalid token, zero value,
fees exceed amount). Repeat presentations are rejected locally with the
exact same sanitized error envelope the mint-backed failure produced.

Transient classifications (mint unreachable, rate limited) are never
cached so a brief mint outage cannot poison valid tokens. The cache is
deliberately memory-only: persisting a row per failed token would let an
attacker fill the database with garbage tokens for free.
This commit is contained in:
9qeklajc
2026-08-17 23:14:40 +02:00
parent 87787bd976
commit 9beead5ece
4 changed files with 332 additions and 0 deletions
+60
View File
@@ -29,6 +29,11 @@ from .payment.cost_calculation import (
MaxCostData,
calculate_cost,
)
from .redemption_cache import (
TERMINAL_REDEMPTION_CODES,
CachedRedemptionFailure,
redemption_negative_cache,
)
from .wallet import (
classify_redemption_error,
credit_balance,
@@ -166,6 +171,50 @@ def redemption_error_to_http_exception(error: Exception) -> HTTPException:
)
def _cached_failure_to_http_exception(
failure: CachedRedemptionFailure,
) -> HTTPException:
"""Rebuild the exact error envelope the original mint-backed failure produced."""
return HTTPException(
status_code=failure.status_code,
detail={
"error": {
"message": failure.message,
"type": failure.error_type,
"code": failure.code,
}
},
)
def _maybe_cache_terminal_redemption_failure(hashed_key: str, error: Exception) -> None:
"""Record a redemption failure in the negative cache if it can never succeed.
Transient classifications (mint unreachable, rate-limited) are never
cached — only codes in TERMINAL_REDEMPTION_CODES, which are permanent
properties of the token itself.
"""
classified = classify_redemption_error(error)
if classified is None:
return
error_type, status_code, message, code = classified
if code not in TERMINAL_REDEMPTION_CODES:
return
redemption_negative_cache.put(
hashed_key,
CachedRedemptionFailure(
status_code=status_code,
error_type=error_type,
message=message,
code=code,
),
)
logger.info(
"Cached terminal redemption failure; further attempts rejected locally",
extra={"key_hash": hashed_key[:8] + "...", "code": code},
)
async def validate_bearer_key(
bearer_key: str,
session: AsyncSession,
@@ -379,6 +428,16 @@ async def _validate_bearer_key_locked(
return existing_key
if cached_failure := redemption_negative_cache.get(hashed_key):
logger.info(
"Rejecting known-dead Cashu token from negative cache",
extra={
"key_hash": hashed_key[:8] + "...",
"code": cached_failure.code,
},
)
raise _cached_failure_to_http_exception(cached_failure)
logger.info(
"Creating new Cashu token entry",
extra={
@@ -458,6 +517,7 @@ async def _validate_bearer_key_locked(
},
)
await session.rollback()
_maybe_cache_terminal_redemption_failure(hashed_key, credit_error)
raise redemption_error_to_http_exception(credit_error) from credit_error
if msats <= 0:
+109
View File
@@ -0,0 +1,109 @@
"""In-memory negative cache for terminally failed Cashu token redemptions.
A dead token (already spent, malformed, zero value) presented as a bearer key
triggers a full redemption attempt against the issuing mint on *every* request,
because no ``api_keys`` row survives the failed attempt. Polling clients that
never back off turn one dead token into thousands of pointless mint calls per
day. This cache remembers terminal redemption failures by token hash so
repeated presentations are rejected locally with the same error the mint
attempt would have produced.
Only failures whose classification code is in :data:`TERMINAL_REDEMPTION_CODES`
are cached — transient failures (mint unreachable, rate-limited, cooldown) must
never be cached, or a brief mint outage would poison valid tokens.
The cache is deliberately in-memory (bounded LRU with TTL) rather than a
database row: persisting a row per failed token would let an attacker fill the
database with garbage tokens for free.
"""
import time
from collections import OrderedDict
from dataclasses import dataclass
from typing import Callable
# Redemption ``code`` values that can never succeed on retry. A token that was
# already spent, failed to decode, or redeemed to zero value stays that way
# forever; swap fees exceeding the token amount only changes if the mint
# lowers its fees, which the TTL covers.
TERMINAL_REDEMPTION_CODES: frozenset[str] = frozenset(
{
"cashu_token_already_spent",
"invalid_cashu_token",
"cashu_token_zero_value",
"cashu_token_swap_fees_exceed_amount",
}
)
DEFAULT_MAX_ENTRIES = 10_000
DEFAULT_TTL_SECONDS = 24 * 60 * 60
@dataclass(frozen=True)
class CachedRedemptionFailure:
"""Sanitized classification of a terminal redemption failure.
Mirrors the ``(type, status, message, code)`` tuple produced by
``classify_redemption_error`` so a cache hit yields a byte-identical error
envelope to the original mint-backed failure.
"""
status_code: int
error_type: str
message: str
code: str
class RedemptionNegativeCache:
"""Bounded TTL+LRU cache keyed by the SHA-256 hash of the bearer token.
Not thread-safe by design: all access happens on the asyncio event loop.
"""
def __init__(
self,
max_entries: int = DEFAULT_MAX_ENTRIES,
ttl_seconds: float = DEFAULT_TTL_SECONDS,
clock: Callable[[], float] = time.monotonic,
) -> None:
if max_entries <= 0:
raise ValueError("max_entries must be positive")
if ttl_seconds <= 0:
raise ValueError("ttl_seconds must be positive")
self._max_entries = max_entries
self._ttl_seconds = ttl_seconds
self._clock = clock
self._entries: OrderedDict[str, tuple[float, CachedRedemptionFailure]] = (
OrderedDict()
)
def get(self, hashed_key: str) -> CachedRedemptionFailure | None:
entry = self._entries.get(hashed_key)
if entry is None:
return None
expires_at, failure = entry
if self._clock() >= expires_at:
del self._entries[hashed_key]
return None
self._entries.move_to_end(hashed_key)
return failure
def put(self, hashed_key: str, failure: CachedRedemptionFailure) -> None:
if hashed_key in self._entries:
del self._entries[hashed_key]
elif len(self._entries) >= self._max_entries:
self._entries.popitem(last=False)
self._entries[hashed_key] = (self._clock() + self._ttl_seconds, failure)
def discard(self, hashed_key: str) -> None:
self._entries.pop(hashed_key, None)
def clear(self) -> None:
self._entries.clear()
def __len__(self) -> int:
return len(self._entries)
# Process-wide singleton used by the bearer-auth path.
redemption_negative_cache = RedemptionNegativeCache()
+17
View File
@@ -8,8 +8,25 @@ absent one) override this per-test via ``monkeypatch``.
import os
import pytest
# Valid Fernet keys; KEY_A is the suite default, KEY_B is for wrong-key tests.
TEST_SECRET_KEY = "l_Tkp-7xmjcQ-IFhr6qhILrU8HPRbEmYMrfSbo_5srU="
TEST_SECRET_KEY_ALT = "_Teyrky_iToeDK51Tj1FsI9MJ340_cqKGmeher-a7MQ="
os.environ.setdefault("ROUTSTR_SECRET_KEY", TEST_SECRET_KEY)
@pytest.fixture(autouse=True)
def _isolate_redemption_negative_cache():
"""Clear the process-wide negative cache between tests.
The cache deliberately persists terminal redemption failures across
requests; without this fixture a test that burns a token would poison
every later test reusing the same token string.
"""
from routstr.redemption_cache import redemption_negative_cache
redemption_negative_cache.clear()
yield
redemption_negative_cache.clear()
@@ -0,0 +1,146 @@
"""Unit tests for the terminal-redemption negative cache."""
import pytest
from fastapi import HTTPException
from routstr.auth import (
_cached_failure_to_http_exception,
_maybe_cache_terminal_redemption_failure,
)
from routstr.redemption_cache import (
TERMINAL_REDEMPTION_CODES,
CachedRedemptionFailure,
RedemptionNegativeCache,
redemption_negative_cache,
)
FAILURE = CachedRedemptionFailure(
status_code=400,
error_type="token_already_spent",
message="Cashu token already spent",
code="cashu_token_already_spent",
)
class FakeClock:
def __init__(self) -> None:
self.now = 0.0
def __call__(self) -> float:
return self.now
@pytest.fixture(autouse=True)
def _clean_singleton():
redemption_negative_cache.clear()
yield
redemption_negative_cache.clear()
class TestRedemptionNegativeCache:
def test_get_returns_none_for_unknown_key(self) -> None:
cache = RedemptionNegativeCache()
assert cache.get("deadbeef") is None
def test_put_then_get_roundtrip(self) -> None:
cache = RedemptionNegativeCache()
cache.put("deadbeef", FAILURE)
assert cache.get("deadbeef") == FAILURE
def test_entry_expires_after_ttl(self) -> None:
clock = FakeClock()
cache = RedemptionNegativeCache(ttl_seconds=100, clock=clock)
cache.put("deadbeef", FAILURE)
clock.now = 99.9
assert cache.get("deadbeef") == FAILURE
clock.now = 100.0
assert cache.get("deadbeef") is None
assert len(cache) == 0
def test_lru_eviction_at_capacity(self) -> None:
cache = RedemptionNegativeCache(max_entries=2)
cache.put("a", FAILURE)
cache.put("b", FAILURE)
# Touch "a" so "b" becomes the least recently used entry.
assert cache.get("a") is not None
cache.put("c", FAILURE)
assert cache.get("b") is None
assert cache.get("a") is not None
assert cache.get("c") is not None
def test_reput_refreshes_expiry(self) -> None:
clock = FakeClock()
cache = RedemptionNegativeCache(ttl_seconds=100, clock=clock)
cache.put("deadbeef", FAILURE)
clock.now = 90.0
cache.put("deadbeef", FAILURE)
clock.now = 150.0
assert cache.get("deadbeef") == FAILURE
def test_discard_removes_entry(self) -> None:
cache = RedemptionNegativeCache()
cache.put("deadbeef", FAILURE)
cache.discard("deadbeef")
assert cache.get("deadbeef") is None
cache.discard("deadbeef") # idempotent
def test_invalid_construction_args_rejected(self) -> None:
with pytest.raises(ValueError):
RedemptionNegativeCache(max_entries=0)
with pytest.raises(ValueError):
RedemptionNegativeCache(ttl_seconds=0)
class TestMaybeCacheTerminalRedemptionFailure:
def test_already_spent_error_is_cached(self) -> None:
_maybe_cache_terminal_redemption_failure(
"deadbeef", Exception("Mint Error: Token already spent. (Code: 11001)")
)
cached = redemption_negative_cache.get("deadbeef")
assert cached is not None
assert cached.code == "cashu_token_already_spent"
assert cached.status_code == 400
def test_transient_mint_unreachable_is_not_cached(self) -> None:
import httpx
_maybe_cache_terminal_redemption_failure(
"deadbeef", httpx.ConnectError("connection refused")
)
assert redemption_negative_cache.get("deadbeef") is None
def test_unclassified_error_is_not_cached(self) -> None:
_maybe_cache_terminal_redemption_failure(
"deadbeef", RuntimeError("some internal fault")
)
assert redemption_negative_cache.get("deadbeef") is None
def test_generic_value_error_is_not_cached(self) -> None:
# cashu_token_redemption_failed is deliberately NOT terminal — a
# generic ValueError can wrap transient faults.
_maybe_cache_terminal_redemption_failure(
"deadbeef", ValueError("something went wrong during redemption")
)
assert redemption_negative_cache.get("deadbeef") is None
def test_terminal_codes_are_a_closed_set(self) -> None:
assert TERMINAL_REDEMPTION_CODES == {
"cashu_token_already_spent",
"invalid_cashu_token",
"cashu_token_zero_value",
"cashu_token_swap_fees_exceed_amount",
}
class TestCachedFailureToHttpException:
def test_envelope_matches_classify_taxonomy(self) -> None:
exc = _cached_failure_to_http_exception(FAILURE)
assert isinstance(exc, HTTPException)
assert exc.status_code == 400
assert exc.detail == {
"error": {
"message": "Cashu token already spent",
"type": "token_already_spent",
"code": "cashu_token_already_spent",
}
}