Skip to main content

decdn_node/dht/
lazy_origin_cache.rs

1//! Lazy, bounded, TTL-split cache of `getOrigins(namespaceId)` results.
2//!
3//! `ChainOriginDirectory` resolves a namespace to its authorized origin
4//! operators lazily, on first request, rather than by watching every
5//! `getOrigins` event up front. Namespace creation is permissionless and
6//! free, so an attacker can mint namespace IDs at will; if every request for
7//! an unknown namespace forced a fresh `getOrigins` RPC, a burst of
8//! attacker-chosen namespaces would turn into a proportional burst of chain
9//! calls. This cache exists to break that link: it remembers a namespace's
10//! result — including "this namespace has no origins" — for a bounded time,
11//! so repeated lookups against the same (possibly bogus) namespace cost one
12//! RPC, not one per request.
13//!
14//! A non-empty `getOrigins` result is a POSITIVE entry and lives for
15//! `positive_ttl`. An empty result (no origins, including a namespace that
16//! does not exist on chain) is a NEGATIVE entry and lives for the shorter
17//! `negative_ttl` — the actual `DoS` bound: it caps how often an attacker can
18//! force a real RPC per bogus namespace, while keeping genuinely-empty
19//! namespaces from squatting on cache space or masking a legitimate
20//! `getOrigins` call for very long once one is registered.
21//!
22//! The cache stores the raw operator ADDRESS set, never resolved `NodeId`s.
23//! Resolving addresses to live `NodeId`s (via the capacity-bond registry's
24//! reverse projection) and filtering by `StakerSet` liveness both happen at
25//! READ time, in `resolve_active` — so a TTL-anchored cache entry never goes
26//! stale on the parts of the answer that change without a new `getOrigins`
27//! call: operator staking status and operator→node bindings.
28//!
29//! Modeled directly on `super::probe_cache`: an `IndexMap` doubles as the
30//! store and the LRU order (index 0 = most-recently-used), the TTL is
31//! anchored on `Instant` at insert and never refreshed on read, and the lock
32//! is poison-tolerant.
33
34use std::collections::HashMap;
35use std::sync::{Mutex, RwLock};
36use std::time::{Duration, Instant};
37
38use alloy::primitives::{Address, U256};
39use indexmap::IndexMap;
40use tracing::warn;
41
42use crate::dht::routing::NodeId;
43use crate::dht::staker_set::StakerSet;
44
45/// One cached `getOrigins(namespaceId)` result.
46struct Entry {
47    /// Authorized operator addresses as returned by `getOrigins`. Empty for a
48    /// negative entry (namespace has no origins / does not exist). Resolved
49    /// to live `NodeId`s at read time, never stored resolved — liveness and
50    /// the operator→`NodeId` binding both move without a re-fetch.
51    operators: Vec<Address>,
52    /// Absolute expiry, anchored at insert, never refreshed on read. Uses the
53    /// positive TTL when `operators` is non-empty, the negative TTL when
54    /// empty.
55    expiry: Instant,
56}
57
58struct Inner {
59    /// `namespaceId → entry`. `IndexMap` collapses the LRU ordering (index 0
60    /// = most-recently-used) into the same store, mirroring
61    /// [`super::probe_cache`].
62    entries: IndexMap<U256, Entry>,
63    /// Hard cap on live namespaces. Clamped to ≥ 1 in the constructor.
64    cap: usize,
65    /// TTL applied to a non-empty (positive) entry on insert.
66    positive_ttl: Duration,
67    /// TTL applied to an empty (negative) entry on insert. Shorter than
68    /// `positive_ttl` — this is the `DoS` bound described in the module docs.
69    negative_ttl: Duration,
70}
71
72/// Bounded LRU of `namespaceId → authorized operator set` with split TTLs.
73/// Pure: no chain access. `ChainOriginDirectory` owns one and feeds it the
74/// `getOrigins` result on a miss.
75pub(crate) struct LazyOriginCache {
76    inner: Mutex<Inner>,
77}
78
79impl LazyOriginCache {
80    /// Build a cache with an explicit capacity and split TTLs. `cap` is
81    /// clamped to ≥ 1.
82    #[must_use]
83    pub(crate) fn new(cap: usize, positive_ttl: Duration, negative_ttl: Duration) -> Self {
84        let cap = cap.max(1);
85        Self {
86            inner: Mutex::new(Inner {
87                entries: IndexMap::with_capacity(cap),
88                cap,
89                positive_ttl,
90                negative_ttl,
91            }),
92        }
93    }
94
95    /// The cached operator set for `ns`, iff an entry exists and its TTL
96    /// hasn't elapsed.
97    ///
98    /// `Some(vec![])` is a live NEGATIVE hit — a cached "no origins" — and is
99    /// distinct from `None`, which means a miss or an expired entry (either
100    /// way, the caller must issue a fresh `getOrigins` RPC). A live hit bumps
101    /// the entry to the front of the LRU ordering without refreshing its
102    /// expiry; an expired entry is evicted before returning `None`.
103    #[must_use]
104    pub(crate) fn get(&self, ns: &U256) -> Option<Vec<Address>> {
105        let now = Instant::now();
106        let mut guard = self.lock();
107        let entry = guard.entries.shift_remove(ns)?;
108        if entry.expiry <= now {
109            return None;
110        }
111        let operators = entry.operators.clone();
112        guard.entries.shift_insert(0, *ns, entry);
113        Some(operators)
114    }
115
116    /// Insert / replace the entry for `ns`, moving it to the front of the LRU
117    /// ordering. Picks the positive or negative TTL by `operators.is_empty()`
118    /// and evicts the least-recently-used namespace on cap overflow.
119    pub(crate) fn insert(&self, ns: U256, operators: Vec<Address>) {
120        let mut guard = self.lock();
121        let ttl = if operators.is_empty() {
122            guard.negative_ttl
123        } else {
124            guard.positive_ttl
125        };
126        let expiry = Instant::now() + ttl;
127        let entry = Entry { operators, expiry };
128        guard.entries.shift_insert(0, ns, entry);
129        if guard.entries.len() > guard.cap {
130            guard.entries.pop();
131        }
132    }
133
134    /// Current namespace count. Includes expired entries that haven't been
135    /// swept yet — call [`Self::get`] first if a precise live count is
136    /// needed.
137    #[must_use]
138    pub(crate) fn len(&self) -> usize {
139        self.lock().entries.len()
140    }
141
142    /// Poison-tolerant lock acquisition, mirroring
143    /// [`super::probe_cache::PositiveProbeCache::lock`].
144    fn lock(&self) -> std::sync::MutexGuard<'_, Inner> {
145        match self.inner.lock() {
146            Ok(g) => g,
147            Err(poisoned) => {
148                warn!("LazyOriginCache mutex poisoned; recovering inner state");
149                poisoned.into_inner()
150            }
151        }
152    }
153}
154
155/// Resolve an authorized operator address set to currently-active origin
156/// `NodeId`s: map each operator through the capacity-bond reverse
157/// projection, keep only operators the `StakerSet` reports active, sort +
158/// dedup for a deterministic order. Mirrors the old `DirectoryCache::resolve`
159/// split, but the operator→`NodeId` map is the shared registry projection,
160/// not a per-directory `nodeIdOf` cache.
161///
162/// An operator with no reverse binding is dropped — there is no chain
163/// fallback here; a missing binding means the registry hasn't observed that
164/// operator's node registration (yet, or ever), and this function is pure /
165/// chain-free by design.
166pub(crate) fn resolve_active(
167    operators: &[Address],
168    operator_to_node: &RwLock<HashMap<Address, NodeId>>,
169    staker_set: &dyn StakerSet,
170) -> Vec<NodeId> {
171    let mut nodes: Vec<NodeId> = crate::dht::chain_projection::with_read(
172        operator_to_node,
173        "chain operator reverse map",
174        |rev| {
175            operators
176                .iter()
177                .filter_map(|op| rev.get(op).copied())
178                .filter(|nid| staker_set.is_active(nid))
179                .collect()
180        },
181    );
182    nodes.sort_unstable();
183    nodes.dedup();
184    nodes
185}
186
187#[cfg(test)]
188#[allow(
189    clippy::unwrap_used,
190    clippy::expect_used,
191    clippy::panic,
192    clippy::indexing_slicing
193)]
194mod tests;