r402_core/cache.rs
1//! Shared cache primitives used by facilitator implementations.
2//!
3//! This module is only compiled with the `cache` feature flag. The types
4//! are intentionally minimal wrappers over `moka::sync::Cache` tuned for
5//! x402 use cases (bounded capacity, short TTL, `Send + Sync + Clone`).
6
7#![cfg(feature = "cache")]
8#![cfg_attr(docsrs, doc(cfg(feature = "cache")))]
9
10use std::fmt::{self, Debug, Formatter};
11use std::time::Duration;
12
13use moka::sync::Cache;
14
15/// Outcome of a cache reservation call.
16///
17/// Returned by [`TtlSet::reserve`] to signal whether the key was newly
18/// inserted (`No`, caller may proceed) or already present (`Yes`, caller
19/// must abort).
20#[must_use]
21#[derive(Debug, Copy, Clone, PartialEq, Eq)]
22pub enum Duplicate {
23 /// Key already present in the cache; caller should reject the request.
24 Yes,
25 /// Key was newly inserted; caller may proceed.
26 No,
27}
28
29/// A Send+Sync cache of string keys with bounded capacity and TTL expiry.
30///
31/// Designed for **deduplication / idempotency** workloads: the `reserve`
32/// method atomically inserts the key if absent, returning a [`Duplicate`]
33/// discriminator. No value type is associated with each key — only presence
34/// matters.
35///
36/// Backed by [`moka::sync::Cache`] internally; cloning shares the same
37/// underlying storage thanks to moka's internal `Arc`.
38#[derive(Clone)]
39pub struct TtlSet {
40 inner: Cache<String, ()>,
41}
42
43impl TtlSet {
44 /// Constructs a cache with the given TTL and maximum capacity.
45 #[must_use]
46 pub fn new(ttl: Duration, max_capacity: u64) -> Self {
47 let inner = Cache::builder()
48 .time_to_live(ttl)
49 .max_capacity(max_capacity)
50 .build();
51 Self { inner }
52 }
53
54 /// Reserves a key: returns [`Duplicate::No`] if newly inserted,
55 /// [`Duplicate::Yes`] if already present.
56 ///
57 /// This operation is lock-free and coalesces concurrent inserts of the
58 /// same key (exactly one insertion wins; others observe `Yes`).
59 pub fn reserve(&self, key: impl Into<String>) -> Duplicate {
60 let key = key.into();
61 if self.inner.contains_key(&key) {
62 return Duplicate::Yes;
63 }
64 // moka's `get_with_by_ref` coalesces concurrent callers; we use the
65 // "entry and compute" API via `get_with` which is atomic under
66 // moka's internal scheduler.
67 let mut was_new = false;
68 let () = self.inner.get_with(key, || {
69 was_new = true;
70 });
71 if was_new {
72 Duplicate::No
73 } else {
74 Duplicate::Yes
75 }
76 }
77
78 /// Returns `true` if `key` is currently present in the cache.
79 #[must_use]
80 pub fn contains(&self, key: &str) -> bool {
81 self.inner.contains_key(key)
82 }
83
84 /// Returns the approximate current entry count (used for observability).
85 #[must_use]
86 pub fn entry_count(&self) -> u64 {
87 self.inner.entry_count()
88 }
89}
90
91impl Debug for TtlSet {
92 fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
93 f.debug_struct("TtlSet")
94 .field("entries", &self.inner.entry_count())
95 .finish()
96 }
97}
98
99/// Spec-recommended TTL for cross-chain settlement deduplication.
100///
101/// Per x402 v2 §Duplicate Settlement Mitigation, facilitators SHOULD retain
102/// a short-lived cache keyed by the unique settlement identifier (EIP-3009
103/// nonce, Permit2 nonce, or base64 SVM transaction). The 2-minute window
104/// covers:
105///
106/// - Solana blockhash lifetime (≈ 60 s) plus a safety multiplier of 2;
107/// - Typical EVM finality (12 blocks × ≈ 12 s) plus margin so two requests
108/// re-using the same EIP-3009 / Permit2 nonce are caught even when the
109/// first settlement is mid-confirmation.
110pub const DEFAULT_SETTLEMENT_TTL: Duration = Duration::from_mins(2);
111
112/// Default upper bound on tracked settlement identifiers.
113///
114/// At ≈ 32 bytes per key (a 32-byte EIP-3009 nonce hex string) plus moka
115/// overhead, the worst-case footprint is in the low MB range — small
116/// enough that the bound mostly protects against unbounded growth from
117/// adversarial replay storms rather than from steady-state traffic.
118pub const DEFAULT_SETTLEMENT_CAPACITY: u64 = 10_000;
119
120/// Chain-agnostic deduplication cache for facilitator settlement requests.
121///
122/// Wraps [`TtlSet`] with the canonical x402 v2 TTL ([`DEFAULT_SETTLEMENT_TTL`])
123/// and capacity ([`DEFAULT_SETTLEMENT_CAPACITY`]). Used by every chain
124/// crate to defend against duplicate / replayed `/settle` calls within
125/// the on-chain replay window:
126///
127/// - **EVM exact** — keyed by `chain:nonce_hex` (the EIP-3009 nonce binds
128/// one-shot to a single token + payer pair).
129/// - **EVM upto** — keyed by `chain:permit2_nonce_hex`.
130/// - **SVM exact** — keyed by the base64 transaction payload.
131///
132/// The cache is **in-memory only**. A facilitator deployment that spans
133/// multiple processes must either pin requests to a single worker or
134/// replace this cache with a Redis-backed implementation by wiring its
135/// own [`TtlSet`]-equivalent through the same trait surface.
136#[derive(Debug, Clone)]
137pub struct SettlementCache {
138 inner: TtlSet,
139}
140
141impl SettlementCache {
142 /// Constructs a cache with the spec-recommended TTL and capacity.
143 #[must_use]
144 pub fn new() -> Self {
145 Self {
146 inner: TtlSet::new(DEFAULT_SETTLEMENT_TTL, DEFAULT_SETTLEMENT_CAPACITY),
147 }
148 }
149
150 /// Constructs a cache with custom TTL and capacity.
151 #[must_use]
152 pub fn with_params(ttl: Duration, capacity: u64) -> Self {
153 Self {
154 inner: TtlSet::new(ttl, capacity),
155 }
156 }
157
158 /// Atomically reserves the key. Returns [`Duplicate::No`] when newly
159 /// inserted (caller may proceed) or [`Duplicate::Yes`] when the key
160 /// is already present (caller must abort with `DuplicateSettlement`).
161 ///
162 /// Increments the
163 /// [`r402_settlement_cache_reserve_total`](crate::metrics::SETTLEMENT_CACHE_RESERVE_TOTAL)
164 /// counter with `outcome=inserted|duplicate` when the `metrics`
165 /// feature is enabled.
166 #[must_use = "callers MUST honour the Duplicate outcome to enforce idempotency"]
167 pub fn reserve(&self, key: impl Into<String>) -> Duplicate {
168 let outcome = self.inner.reserve(key);
169 #[cfg(feature = "metrics")]
170 {
171 let label = match outcome {
172 Duplicate::No => "inserted",
173 Duplicate::Yes => "duplicate",
174 };
175 ::metrics::counter!(
176 crate::metrics::SETTLEMENT_CACHE_RESERVE_TOTAL,
177 "outcome" => label,
178 )
179 .increment(1);
180 }
181 outcome
182 }
183
184 /// Returns the approximate current entry count (observability hook).
185 #[must_use]
186 pub fn entry_count(&self) -> u64 {
187 self.inner.entry_count()
188 }
189}
190
191impl Default for SettlementCache {
192 fn default() -> Self {
193 Self::new()
194 }
195}
196
197#[cfg(test)]
198mod tests {
199 use super::*;
200
201 fn cache() -> TtlSet {
202 TtlSet::new(Duration::from_mins(1), 1024)
203 }
204
205 #[test]
206 fn reserve_fresh_key_is_new() {
207 let cache = cache();
208 assert_eq!(cache.reserve("a"), Duplicate::No);
209 }
210
211 #[test]
212 fn reserve_same_key_twice_is_duplicate() {
213 let cache = cache();
214 assert_eq!(cache.reserve("a"), Duplicate::No);
215 assert_eq!(cache.reserve("a"), Duplicate::Yes);
216 }
217
218 #[test]
219 fn distinct_keys_are_independent() {
220 let cache = cache();
221 assert_eq!(cache.reserve("a"), Duplicate::No);
222 assert_eq!(cache.reserve("b"), Duplicate::No);
223 assert_eq!(cache.reserve("a"), Duplicate::Yes);
224 assert_eq!(cache.reserve("b"), Duplicate::Yes);
225 }
226
227 #[test]
228 fn settlement_cache_default_is_2_minute_ttl() {
229 // Verify the spec-recommended TTL is wired correctly so future
230 // refactors can't silently lengthen / shorten it.
231 assert_eq!(DEFAULT_SETTLEMENT_TTL, Duration::from_mins(2));
232 }
233
234 #[test]
235 fn settlement_cache_reserves_then_dedups() {
236 let cache = SettlementCache::new();
237 assert_eq!(cache.reserve("0xabc"), Duplicate::No);
238 assert_eq!(cache.reserve("0xabc"), Duplicate::Yes);
239 }
240
241 #[test]
242 fn settlement_cache_independent_keys() {
243 let cache = SettlementCache::new();
244 assert_eq!(cache.reserve("eip155:8453:0xnonce_a"), Duplicate::No);
245 assert_eq!(cache.reserve("eip155:8453:0xnonce_b"), Duplicate::No);
246 assert_eq!(cache.reserve("eip155:8453:0xnonce_a"), Duplicate::Yes);
247 assert_eq!(cache.reserve("eip155:8453:0xnonce_b"), Duplicate::Yes);
248 }
249}