decdn_node/dht/origin.rs
1//! On-chain origin-directory fallback (ADR 022 §FIND\_VALUE Flow
2//! "Origin discovery" and §Bootstrap "the on-chain origin directory
3//! provides the deterministic fallback").
4//!
5//! When a DHT lookup for a `namespaceId != 0` request returns no providers, or
6//! the probed holders do not cover every block of the blob, the requester reads
7//! the deterministic chain-derived directory keyed on the request's namespace:
8//!
9//! `OriginAssignment.getOrigins(namespace_id)` → `[operator...]`
10//! → `CapacityBond.nodeIdOf(operator) → (NodeId, active)`
11//! → filter `active == true`
12//!
13//! The bare hash carries no origin information (ADR 002 §Retrieval by
14//! namespace): a request supplies the namespace its content is published under,
15//! and origin authorization is a namespace-level property. `namespaceId == 0`
16//! has no authorized origins, so the directory resolves nothing for it.
17//!
18//! This trait abstracts the directory so callers consume a single
19//! `lookup_origins(namespace_id) -> Vec<NodeId>` call without caring whether the
20//! implementation resolves from chain (production), from a fixed in-memory map
21//! (tests), or resolves nothing at all (the fallback installed when the chain
22//! contracts aren't configured).
23//!
24//! Three implementations exist:
25//!
26//! - [`crate::dht::ChainOriginDirectory`] — the production path, and the only
27//! one that can resolve anything. It yields the same candidate set the chain
28//! above specifies, but does not perform that literal call sequence: the
29//! `namespace → operators` view is fetched lazily on a cold-namespace miss
30//! and cached with a split TTL, and the `active` filter is applied from the
31//! shared `StakerSet` at lookup time rather than taken from the `nodeIdOf`
32//! tuple. A live cache hit resolves with no RPC; a miss issues one
33//! on-demand `getOrigins` call.
34//! - [`EmptyOriginDirectory`] (this module) — resolves nothing. What the
35//! runtime installs when the chain contracts aren't configured.
36//! - [`StaticOriginDirectory`] (this module) — a fixed in-memory map, for tests
37//! and any caller wiring a known origin set without a live chain. No config
38//! key populates it; it has no production call site.
39//!
40//! The runtime picks between them at bring-up: it constructs a
41//! `ChainOriginDirectory` when `blockchain.origin_assignment_address` is set,
42//! and otherwise installs an [`EmptyOriginDirectory`] (see `crate::runtime`).
43//! With that fallback in place every lookup miss yields no origin candidates —
44//! see [`EmptyOriginDirectory`] for what that means for each consumer.
45
46use std::collections::HashMap;
47
48use alloy::primitives::U256;
49
50use crate::dht::routing::NodeId;
51
52/// Read-only view of the on-chain origin directory, keyed by the namespace a
53/// request is published under (ADR 002 §Retrieval by namespace).
54///
55/// Implementations MUST be cheap to clone (typically `Arc<inner>`); consumers
56/// hold a long-lived `Arc<dyn OriginDirectory>` and invoke `lookup_origins` on
57/// a lookup miss, or on a ranged pull whose probed holders do not cover every
58/// block (ADR 022 §FIND\_VALUE Flow). Both are uncommon: under normal operation
59/// the DHT holds an entry for every actively-serving authorized origin.
60///
61/// `async` because the chain-backed implementation may issue one on-demand
62/// `getOrigins` RPC on a cold-namespace miss; `Empty`/`Static` resolve
63/// synchronously.
64#[async_trait::async_trait]
65pub trait OriginDirectory: Send + Sync + std::fmt::Debug {
66 /// Return the operator `NodeId`s authorised as origins for `namespace_id`,
67 /// already filtered to only currently-active stakers (the `active == true`
68 /// filter from `CapacityBond.nodeIdOf` per ADR 022 §FIND\_VALUE Flow "Origin
69 /// discovery").
70 ///
71 /// Returns an empty vector when the namespace has no authorized origins —
72 /// including `namespace_id == 0` (no namespace: cache/DHT-only, no origins
73 /// per ADR 002 §Namespace 0).
74 async fn lookup_origins(&self, namespace_id: U256) -> Vec<NodeId>;
75}
76
77/// [`OriginDirectory`] that resolves nothing.
78///
79/// The shape the runtime installs when the on-chain `OriginAssignment` address
80/// is not configured. The chain-backed [`crate::dht::ChainOriginDirectory`] is
81/// the only directory with a production population path (ADR 022 §FIND\_VALUE
82/// Flow describes the fallback as the *on-chain* origin directory throughout),
83/// so "no chain config" means "no origin directory" rather than "an
84/// operator-supplied one".
85///
86/// Every consumer degrades to a hard deny on a node without a chain address:
87/// the FIND\_VALUE fallback resolves nothing, so a `namespaceId != 0` DHT miss
88/// reports the blob unavailable on the network.
89#[derive(Debug, Default, Clone, Copy)]
90pub struct EmptyOriginDirectory;
91
92#[async_trait::async_trait]
93impl OriginDirectory for EmptyOriginDirectory {
94 async fn lookup_origins(&self, _namespace_id: U256) -> Vec<NodeId> {
95 Vec::new()
96 }
97}
98
99/// Static, in-memory [`OriginDirectory`] over a fixed namespace → origin-`NodeId`
100/// map. Used by tests and any caller wiring a known origin set without a live
101/// chain.
102///
103/// No config key populates this map and the runtime never constructs one — the
104/// chain-backed [`crate::dht::ChainOriginDirectory`] is the production path,
105/// and [`EmptyOriginDirectory`] is what stands in when it is unconfigured.
106/// Mirrors [`crate::dht::StaticNodeAddressDirectory`], the same `Chain*` /
107/// `Static*` pairing one module over.
108#[derive(Debug)]
109pub struct StaticOriginDirectory {
110 /// Map from namespace id to the list of authorised origin `NodeId`s.
111 /// Taken as-is from the caller, so `lookup_origins` is a single
112 /// `HashMap::get` clone-and-return.
113 origins: HashMap<U256, Vec<NodeId>>,
114}
115
116impl StaticOriginDirectory {
117 /// Build from a `namespace → origins` map.
118 #[must_use]
119 pub const fn new(origins: HashMap<U256, Vec<NodeId>>) -> Self {
120 Self { origins }
121 }
122}
123
124#[async_trait::async_trait]
125impl OriginDirectory for StaticOriginDirectory {
126 async fn lookup_origins(&self, namespace_id: U256) -> Vec<NodeId> {
127 self.origins.get(&namespace_id).cloned().unwrap_or_default()
128 }
129}
130
131#[cfg(test)]
132#[allow(
133 clippy::unwrap_used,
134 clippy::expect_used,
135 clippy::panic,
136 clippy::indexing_slicing
137)]
138mod tests;