Skip to main content

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}