Skip to main content

decdn_node/dht/
node_address.rs

1//! Node-id → operator Ethereum address resolver (ADR 003 §Node Registry, #831).
2//!
3//! The node-to-node cache-miss pull path discovers upstream providers by iroh
4//! [`NodeId`] (DHT `FIND_VALUE` / origin directory), but paying one requires
5//! its bonded operator address: [`crate::buyer_channel::BuyerPoolService`]
6//! opens the USDC pool *to* that address, and the pull's open stage
7//! (`open_progressive_pull`) verifies the delivery `slash_sig`
8//! recovers *to* it. This module resolves that binding from the same
9//! `CapacityBond` data [`crate::dht::chain_staker_set::ChainStakerSet`] already
10//! reads — `getRegisteredNodes()` returns `NodeInfo { nodeId, ethAddress, .. }` and
11//! `NodeRegistered(nodeId, ethAddress, ..)` carries both — so no new contract
12//! surface is needed.
13//!
14//! This module owns the **projection** only. Because both views come from one
15//! contract, [`crate::dht::capacity_bond_registry`] does a single
16//! `getRegisteredNodes` enumeration and runs a single `eth_getLogs` loop feeding
17//! both, building this via `ChainNodeAddressDirectory::from_parts` (#1110).
18//!
19//! The binding is set at `registerNode` and cleared at `deregisterNode`; it is
20//! unaffected by bond / unbonding / ejection transitions (those flip
21//! `isActive`, which the *staker set* tracks separately). So the registry's
22//! bindings arms follow only `NodeRegistered` (insert) and `NodeDeregistered`
23//! (remove) — notably `NodeAutoEjected` deactivates without clearing a binding —
24//! and the enumeration applies no `isActive` filter: an operator mid-unbonding is
25//! momentarily inactive but still payable.
26//!
27//! # Failure model
28//!
29//! Bootstrap: the bindings projection is derived from page data the
30//! (unconditional; fatal on a deterministic fault or once its boot retries are
31//! exhausted) staker-set
32//! enumeration already read, so it has no RPC of its
33//! own and cannot fail independently — see `capacity_bond_registry`'s
34//! §Fatality. It is simply not built when
35//! `cache.node_to_node_pull_through_enabled` is off.
36//!
37//! Watcher RPC failure mid-run → the shared loop logs at `warn!`, backs off
38//! (1s → 60s cap), and re-polls. The cursor is retained across the backoff, so
39//! the next `eth_getLogs` tick re-scans `[cursor, head]` and re-applies any
40//! binding event that landed during the outage — no backoff-gap drift. A missing
41//! binding fails the pull *closed* — the orchestrator skips a provider it cannot
42//! resolve rather than guessing an address it would then pay.
43
44use std::collections::HashMap;
45use std::sync::{Arc, RwLock};
46
47use alloy::primitives::Address;
48
49use crate::dht::chain_projection::{ChainProjection, mutate_gauged};
50use crate::dht::routing::NodeId;
51use crate::metrics::Metrics;
52
53/// Names the projection in the poison-recovery `warn!` and gauge helpers.
54const LABEL: &str = "ChainNodeAddressDirectory bindings";
55
56/// Read-only resolver from a provider's iroh [`NodeId`] to its bonded operator
57/// Ethereum [`Address`]. Implementations MUST be cheap to clone (typically
58/// `Arc<inner>`); the pull orchestrator holds a long-lived
59/// `Arc<dyn NodeAddressResolver>` and calls [`Self::address_of`] once per
60/// candidate it intends to pay.
61pub trait NodeAddressResolver: Send + Sync + std::fmt::Debug {
62    /// The bonded operator address for `node_id`, or `None` if the node is not
63    /// currently registered. `None` means the caller MUST NOT attempt a paid
64    /// pull from it — there is no address to open a channel to or to verify the
65    /// `slash_sig` against.
66    fn address_of(&self, node_id: &NodeId) -> Option<Address>;
67
68    /// Reverse lookup: a registered [`NodeId`] currently bound to `address`, or
69    /// `None` if no registered node binds it (deregistered / never seen).
70    ///
71    /// The buyer-channel reconcile (#972) keys channels by the provider's
72    /// operator *address* but must dial the provider by `NodeId` to request a
73    /// cooperative-close waiver. An operator may run several nodes under one
74    /// address; any is dialable for this purpose — they share the operator key
75    /// that signs the waiver — so the first match is returned. `None` is the
76    /// "unreachable / gone" signal: the caller leaves the channel for the
77    /// expiry-reclaim sweep rather than dialing.
78    fn node_id_for(&self, address: &Address) -> Option<NodeId>;
79}
80
81/// Static, in-memory [`NodeAddressResolver`] from a known map. Used by tests and
82/// any caller wiring a fixed binding set without a live chain.
83#[derive(Debug, Clone)]
84pub struct StaticNodeAddressDirectory {
85    map: Arc<HashMap<NodeId, Address>>,
86}
87
88impl StaticNodeAddressDirectory {
89    /// Build from a fixed `NodeId → Address` map.
90    #[must_use]
91    pub fn new(map: HashMap<NodeId, Address>) -> Self {
92        Self { map: Arc::new(map) }
93    }
94}
95
96impl NodeAddressResolver for StaticNodeAddressDirectory {
97    fn address_of(&self, node_id: &NodeId) -> Option<Address> {
98        self.map.get(node_id).copied()
99    }
100
101    fn node_id_for(&self, address: &Address) -> Option<NodeId> {
102        self.map
103            .iter()
104            .find_map(|(node_id, addr)| (addr == address).then_some(*node_id))
105    }
106}
107
108/// Chain-backed [`NodeAddressResolver`]. Cheap to clone via the shared inner
109/// [`Arc`]; the background loop that keeps the cache fresh is the single shared
110/// multiplexed-poller task the runtime owns, so a node-restart cycle never leaks
111/// chain-poll tasks.
112#[derive(Debug)]
113pub struct ChainNodeAddressDirectory {
114    proj: ChainProjection<HashMap<NodeId, Address>>,
115}
116
117impl ChainNodeAddressDirectory {
118    /// Assemble from the bindings state owned by `capacity_bond_registry`, which
119    /// does the enumeration and registers the shared poller route.
120    pub(super) const fn from_parts(bindings: Arc<RwLock<HashMap<NodeId, Address>>>) -> Self {
121        Self {
122            proj: ChainProjection::from_parts(bindings, LABEL),
123        }
124    }
125}
126
127impl NodeAddressResolver for ChainNodeAddressDirectory {
128    fn address_of(&self, node_id: &NodeId) -> Option<Address> {
129        self.proj.read(|bindings| bindings.get(node_id).copied())
130    }
131
132    fn node_id_for(&self, address: &Address) -> Option<NodeId> {
133        // O(n) scan of the binding set — the reconcile sweep calls this hourly
134        // for a handful of channels, so a reverse index isn't worth maintaining.
135        // Add a reverse map only if a node ever tracks thousands of buyer
136        // channels.
137        self.proj.read(|bindings| {
138            bindings
139                .iter()
140                .find_map(|(node_id, addr)| (addr == address).then_some(*node_id))
141        })
142    }
143}
144
145/// Insert/update `node_id → address`, republishing the size gauge only when the
146/// set actually grew (a re-registration that overwrites an existing binding
147/// with the same key does not change cardinality).
148pub(super) fn set_binding(
149    bindings: &RwLock<HashMap<NodeId, Address>>,
150    metrics: &Arc<Metrics>,
151    node_id: NodeId,
152    address: Address,
153) {
154    mutate_gauged(
155        bindings,
156        LABEL,
157        // `insert` returns the prior value: `None` means a new key (cardinality
158        // grew); `Some` means an overwrite (same key, possibly rotated address)
159        // that leaves the size — and thus the gauge — unchanged.
160        |map| map.insert(node_id, address).is_none().then_some(map.len()),
161        |size| metrics.node_address_directory_size(size),
162    );
163}
164
165/// Remove `node_id`'s binding, republishing the size gauge only on a real
166/// removal (removing an absent key is a no-op).
167pub(super) fn remove_binding(
168    bindings: &RwLock<HashMap<NodeId, Address>>,
169    metrics: &Arc<Metrics>,
170    node_id: &NodeId,
171) {
172    mutate_gauged(
173        bindings,
174        LABEL,
175        |map| map.remove(node_id).is_some().then_some(map.len()),
176        |size| metrics.node_address_directory_size(size),
177    );
178}
179
180#[cfg(test)]
181#[allow(
182    clippy::unwrap_used,
183    clippy::expect_used,
184    clippy::panic,
185    clippy::indexing_slicing
186)]
187mod tests;