Skip to main content

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;