decdn_node/dht/chain_origin_directory.rs
1//! Chain-backed [`OriginDirectory`] implementation.
2//!
3//! Resolves a request's **namespace** to the set of currently-active operator
4//! `NodeId`s authorised as origins for it. The resolution chain (ADR 022 §
5//! FIND\_VALUE Flow "Origin discovery", ADR 011 § Origin Assignment
6//! Authority) is:
7//!
8//! `OriginAssignment.getOrigins(namespaceId)` → `[operator...]`
9//! → drop operators the local [`ContentDenylist`] denies (the origin ∪
10//! operator blacklist union the watcher syncs from `ContentBlacklist`,
11//! plus the operator's own `[content] denied_origins`)
12//! → capacity-bond reverse projection (`operator → NodeId`, binding only)
13//! → keep operators the shared [`StakerSet`] reports `active`
14//!
15//! The blacklist drop is this node protecting itself: `getOrigins` is a routing
16//! hint the contract does not filter (ADR 011 § Interaction with
17//! `ContentBlacklist`), so the consumer applies the same deny-set its delivery
18//! gate already holds rather than trusting the seat set to be blacklist-clean.
19//!
20//! The bare hash carries no origin information (ADR 002 § Retrieval by
21//! namespace): the request supplies the namespace its content is published
22//! under. `namespaceId == 0` has no authorized origins, so it resolves to no
23//! origins here and is never fetched or cached.
24//!
25//! Namespace creation is permissionless and free, so this directory does not
26//! mirror the whole chain-side namespace set up front: it resolves each
27//! namespace lazily, on first request, and caches the result (positive or
28//! negative) behind a split TTL ([`crate::dht::lazy_origin_cache`]). A live
29//! cache hit — including a cached "no origins" — resolves with no RPC; a
30//! cold miss issues one `getOrigins(namespace)` call. Chain reads therefore
31//! scale with the namespaces this node is actually asked to serve, not the
32//! permissionless global namespace count.
33//!
34//! The cache stores only the raw operator address set; it never stores
35//! resolved `NodeId`s or liveness. Both the `operator → NodeId` binding (the
36//! shared capacity-bond reverse projection) and staker liveness (the shared
37//! [`StakerSet`]) are read live at resolution time, so a TTL-anchored cache
38//! entry never goes stale on the parts of the answer that can change without
39//! a new `getOrigins` call.
40//!
41//! A `getOrigins` RPC failure fails closed: the lookup resolves no origins
42//! for that request, and — because a transient RPC failure must not be frozen
43//! for a TTL — the result is not cached, so the next request retries.
44
45use std::collections::HashMap;
46use std::sync::{Arc, RwLock};
47use std::time::Duration;
48
49use alloy::primitives::{Address, U256};
50use alloy::providers::Provider;
51use anyhow::{Context, Result};
52use tracing::warn;
53
54use crate::chain_events::timed;
55use crate::content_deny::ContentDenylist;
56use crate::dht::lazy_origin_cache::{LazyOriginCache, resolve_active};
57use crate::dht::origin::OriginDirectory;
58use crate::dht::routing::NodeId;
59use crate::dht::staker_set::StakerSet;
60use crate::metrics::Metrics;
61use decdn_common::redact::sanitize_err_chain;
62use decdn_incentive::origin_assignment::OriginAssignment;
63
64/// Contract handle(s) the directory needs. Bundled so production construction
65/// has one call site; tests inject [`OriginChainReads`] directly via
66/// `StubReads` instead of this type.
67struct Contracts<P: Provider + Clone> {
68 origin: OriginAssignment::OriginAssignmentInstance<P>,
69}
70
71/// The chain read this directory performs, behind a trait so lookup is
72/// unit-testable without a provider and so [`ChainOriginDirectory`] can hold
73/// it as `Arc<dyn OriginChainReads + Send + Sync>` (object-safe via
74/// `#[async_trait]`). The production implementation is [`Contracts`]; tests
75/// supply a scripted stub.
76#[async_trait::async_trait]
77trait OriginChainReads {
78 /// Authoritative current operator set for `namespace` (`getOrigins`).
79 async fn get_origins(&self, namespace: U256) -> Result<Vec<Address>>;
80}
81
82#[async_trait::async_trait]
83impl<P> OriginChainReads for Contracts<P>
84where
85 P: Provider + Clone + Send + Sync,
86{
87 async fn get_origins(&self, namespace: U256) -> Result<Vec<Address>> {
88 // Bounded here, in the production impl: a stalled provider must fail
89 // this single lookup rather than hang the caller's DHT-miss fallback
90 // path indefinitely. `StubReads` needs no timeout.
91 timed(None, "getOrigins", self.origin.getOrigins(namespace).call())
92 .await
93 .with_context(|| format!("getOrigins(namespace={namespace})"))
94 }
95}
96
97/// Chain-backed origin directory: a lazy TTL cache over `getOrigins`, resolving
98/// operators to active `NodeId`s against the shared capacity-bond reverse
99/// projection. No background watcher and no whole-directory mirror — a single
100/// `getOrigins(ns)` RPC fires only on a cold-namespace miss, and chain reads
101/// therefore scale with the namespaces this node is actually asked to serve,
102/// not the permissionless global namespace count.
103pub struct ChainOriginDirectory {
104 reads: Arc<dyn OriginChainReads + Send + Sync>,
105 cache: LazyOriginCache,
106 operator_to_node: Arc<RwLock<HashMap<Address, NodeId>>>,
107 staker_set: Arc<dyn StakerSet>,
108 /// The node's live origin deny-set — the same union the delivery gate
109 /// consults. A blacklisted operator is dropped from every resolved origin
110 /// set, so this node never routes an origin-pull to one. Read live (never
111 /// cached), so a governance blacklist takes effect without waiting out the
112 /// `getOrigins` TTL.
113 content_deny: Arc<ContentDenylist>,
114 metrics: Arc<Metrics>,
115}
116
117impl std::fmt::Debug for ChainOriginDirectory {
118 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
119 f.debug_struct("ChainOriginDirectory")
120 .finish_non_exhaustive()
121 }
122}
123
124impl ChainOriginDirectory {
125 /// Build a directory over the given `OriginAssignment` contract. No RPC:
126 /// the cache is lazy and populates on the first lookup miss for each
127 /// namespace.
128 #[allow(clippy::too_many_arguments)]
129 pub fn new<P>(
130 provider: P,
131 origin_assignment_addr: Address,
132 operator_to_node: Arc<RwLock<HashMap<Address, NodeId>>>,
133 staker_set: Arc<dyn StakerSet>,
134 content_deny: Arc<ContentDenylist>,
135 cache_capacity: usize,
136 positive_ttl: Duration,
137 negative_ttl: Duration,
138 metrics: Arc<Metrics>,
139 ) -> Self
140 where
141 P: Provider + Clone + Send + Sync + 'static,
142 {
143 let reads = Arc::new(Contracts {
144 origin: OriginAssignment::new(origin_assignment_addr, provider),
145 });
146 Self {
147 reads,
148 cache: LazyOriginCache::new(cache_capacity, positive_ttl, negative_ttl),
149 operator_to_node,
150 staker_set,
151 content_deny,
152 metrics,
153 }
154 }
155
156 /// Resolve a cached/fetched operator address set to active origin
157 /// `NodeId`s, first dropping any operator the local deny-set blacklists.
158 /// Both the blacklist filter and liveness are applied live at read time, so
159 /// a TTL-anchored cache entry never keeps routing to an operator that has
160 /// since been blacklisted or gone inactive.
161 fn resolve(&self, operators: &[Address]) -> Vec<NodeId> {
162 let allowed: Vec<Address> = operators
163 .iter()
164 .copied()
165 .filter(|op| !self.content_deny.is_origin_denied(op))
166 .collect();
167 resolve_active(&allowed, &self.operator_to_node, self.staker_set.as_ref())
168 }
169}
170
171#[async_trait::async_trait]
172impl OriginDirectory for ChainOriginDirectory {
173 async fn lookup_origins(&self, namespace_id: U256) -> Vec<NodeId> {
174 // Namespace 0 (NO_NAMESPACE) authorizes nothing by construction — never
175 // fetch or cache it (ADR 002 §Namespace 0).
176 if namespace_id == U256::ZERO {
177 return Vec::new();
178 }
179 // Live TTL hit (positive OR negative) → resolve from the cached operator
180 // set with no RPC.
181 if let Some(operators) = self.cache.get(&namespace_id) {
182 return self.resolve(&operators);
183 }
184 // Cold miss: one on-demand getOrigins. On RPC error, fail closed
185 // (resolve nothing) and DO NOT cache — a transient failure must not be
186 // frozen for a TTL, and the next request retries.
187 let operators = match self.reads.get_origins(namespace_id).await {
188 Ok(ops) => ops,
189 Err(err) => {
190 self.metrics.origin_directory_get_origins_failure();
191 warn!(
192 error = %sanitize_err_chain(&err),
193 %namespace_id,
194 "getOrigins lookup failed; resolving no origins for this request"
195 );
196 return Vec::new();
197 }
198 };
199 // Cache the authoritative set (empty → negative entry, short TTL).
200 self.cache.insert(namespace_id, operators.clone());
201 self.metrics.origin_directory_cache_size(self.cache.len());
202 self.resolve(&operators)
203 }
204}
205
206#[cfg(test)]
207#[allow(
208 clippy::unwrap_used,
209 clippy::expect_used,
210 clippy::panic,
211 clippy::indexing_slicing
212)]
213mod tests;