diff --git a/routstr/auth.py b/routstr/auth.py index 588fc2d1..a9baa9e6 100644 --- a/routstr/auth.py +++ b/routstr/auth.py @@ -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: diff --git a/routstr/redemption_cache.py b/routstr/redemption_cache.py new file mode 100644 index 00000000..e7054b5b --- /dev/null +++ b/routstr/redemption_cache.py @@ -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() diff --git a/tests/conftest.py b/tests/conftest.py index 8112487d..8702b6e3 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -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() diff --git a/tests/unit/test_redemption_negative_cache.py b/tests/unit/test_redemption_negative_cache.py new file mode 100644 index 00000000..659ec9f8 --- /dev/null +++ b/tests/unit/test_redemption_negative_cache.py @@ -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", + } + }