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;