mirror of
https://github.com/Routstr/routstr-core.git
synced 2026-10-05 20:28:23 +00:00
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.
110 lines
3.8 KiB
Python
110 lines
3.8 KiB
Python
"""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()
|