Skip to main content

r402_extensions/siwx/
paid.rs

1//! In-memory store of paid addresses and used nonces.
2//!
3//! Key is configured origin + request path. Only
4//! [`SettleResponse::Success`](r402_protocol::payment::SettleResponse) is
5//! recorded. v1 has no Redis and no TTL eviction.
6//!
7//! [`PaidAddressStore::consume_nonce`] is insert-if-absent under one lock.
8//! HTTP verifies first, then consumes. Invalid proofs never insert. A
9//! failed consume after a successful verify is treated as replay and is
10//! not undone.
11
12use std::collections::{HashMap, HashSet};
13use std::sync::{Arc, Mutex};
14
15/// Addresses that completed a successful settlement, plus consumed nonces.
16pub trait PaidAddressStore: Send + Sync {
17    /// Whether `address` already paid for `store_key`.
18    fn contains(&self, store_key: &str, address: &str) -> bool;
19
20    /// Records a successful settlement. Failures and verifies are ignored.
21    fn record_success(&self, store_key: &str, address: &str);
22
23    /// Whether `nonce` is already in the used set.
24    fn has_used_nonce(&self, nonce: &str) -> bool;
25
26    /// Marks `nonce` as used. Idempotent.
27    fn record_nonce(&self, nonce: &str);
28
29    /// Inserts `nonce` if absent. `true` means this caller consumed it.
30    ///
31    /// Must be atomic. Callers must not delete a nonce after a failed
32    /// consume (replay) or a later failure.
33    #[must_use]
34    fn consume_nonce(&self, nonce: &str) -> bool;
35}
36
37#[derive(Debug, Default)]
38struct Inner {
39    /// `store_key` → paid addresses (EVM addresses stored lowercase).
40    paid: HashMap<String, HashSet<String>>,
41    nonces: HashSet<String>,
42}
43
44/// Process-local [`PaidAddressStore`].
45///
46/// [`Clone`] shares the map so the HTTP gate and settle-success path see
47/// the same addresses and nonces.
48#[derive(Debug, Clone, Default)]
49pub struct InMemoryPaidAddressStore {
50    inner: Arc<Mutex<Inner>>,
51}
52
53impl InMemoryPaidAddressStore {
54    /// Empty store.
55    #[must_use]
56    pub fn new() -> Self {
57        Self::default()
58    }
59
60    fn lock(&self) -> std::sync::MutexGuard<'_, Inner> {
61        self.inner
62            .lock()
63            .unwrap_or_else(std::sync::PoisonError::into_inner)
64    }
65}
66
67impl PaidAddressStore for InMemoryPaidAddressStore {
68    fn contains(&self, store_key: &str, address: &str) -> bool {
69        let key = normalize_address(address);
70        self.lock()
71            .paid
72            .get(store_key)
73            .is_some_and(|set| set.contains(&key))
74    }
75
76    fn record_success(&self, store_key: &str, address: &str) {
77        let key = normalize_address(address);
78        let mut inner = self.lock();
79        inner
80            .paid
81            .entry(store_key.to_owned())
82            .or_default()
83            .insert(key);
84    }
85
86    fn has_used_nonce(&self, nonce: &str) -> bool {
87        self.lock().nonces.contains(nonce)
88    }
89
90    fn record_nonce(&self, nonce: &str) {
91        let _ = self.lock().nonces.insert(nonce.to_owned());
92    }
93
94    fn consume_nonce(&self, nonce: &str) -> bool {
95        self.lock().nonces.insert(nonce.to_owned())
96    }
97}
98
99/// EVM `0x` addresses compare case-insensitively. Solana base58 is left as-is.
100fn normalize_address(address: &str) -> String {
101    if address
102        .get(..2)
103        .is_some_and(|p| p.eq_ignore_ascii_case("0x"))
104    {
105        address.to_ascii_lowercase()
106    } else {
107        address.to_owned()
108    }
109}