mirror of
https://github.com/Routstr/routstr-core.git
synced 2026-10-05 12:28:22 +00:00
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:
@@ -29,6 +29,11 @@ from .payment.cost_calculation import (
|
|||||||
MaxCostData,
|
MaxCostData,
|
||||||
calculate_cost,
|
calculate_cost,
|
||||||
)
|
)
|
||||||
|
from .redemption_cache import (
|
||||||
|
TERMINAL_REDEMPTION_CODES,
|
||||||
|
CachedRedemptionFailure,
|
||||||
|
redemption_negative_cache,
|
||||||
|
)
|
||||||
from .wallet import (
|
from .wallet import (
|
||||||
classify_redemption_error,
|
classify_redemption_error,
|
||||||
credit_balance,
|
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(
|
async def validate_bearer_key(
|
||||||
bearer_key: str,
|
bearer_key: str,
|
||||||
session: AsyncSession,
|
session: AsyncSession,
|
||||||
@@ -379,6 +428,16 @@ async def _validate_bearer_key_locked(
|
|||||||
|
|
||||||
return existing_key
|
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(
|
logger.info(
|
||||||
"Creating new Cashu token entry",
|
"Creating new Cashu token entry",
|
||||||
extra={
|
extra={
|
||||||
@@ -458,6 +517,7 @@ async def _validate_bearer_key_locked(
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
await session.rollback()
|
await session.rollback()
|
||||||
|
_maybe_cache_terminal_redemption_failure(hashed_key, credit_error)
|
||||||
raise redemption_error_to_http_exception(credit_error) from credit_error
|
raise redemption_error_to_http_exception(credit_error) from credit_error
|
||||||
|
|
||||||
if msats <= 0:
|
if msats <= 0:
|
||||||
|
|||||||
@@ -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()
|
||||||
@@ -8,8 +8,25 @@ absent one) override this per-test via ``monkeypatch``.
|
|||||||
|
|
||||||
import os
|
import os
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
# Valid Fernet keys; KEY_A is the suite default, KEY_B is for wrong-key tests.
|
# 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 = "l_Tkp-7xmjcQ-IFhr6qhILrU8HPRbEmYMrfSbo_5srU="
|
||||||
TEST_SECRET_KEY_ALT = "_Teyrky_iToeDK51Tj1FsI9MJ340_cqKGmeher-a7MQ="
|
TEST_SECRET_KEY_ALT = "_Teyrky_iToeDK51Tj1FsI9MJ340_cqKGmeher-a7MQ="
|
||||||
|
|
||||||
os.environ.setdefault("ROUTSTR_SECRET_KEY", TEST_SECRET_KEY)
|
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",
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user