Skip to main content

decdn_node/dht/
capacity_bond_registry.rs

1//! One `CapacityBond` watcher feeding five registry projections.
2//!
3//! [`ChainStakerSet`] (membership: `NodeId → active?`), [`ChainNodeAddressDirectory`]
4//! (bindings: `NodeId → operator address`), the region map (`NodeId → regionHint`),
5//! the operator reverse map (`operator address → NodeId`), and the
6//! [`DialAddrDirectory`] (`NodeId → registry multiaddrs`, as iroh direct-dial
7//! addresses) are all derived from the same `CapacityBond` contract. This module
8//! runs one enumeration and one `eth_getLogs` loop over the shared
9//! `CapacityBond` address, demuxing to all five projections; node-address's two
10//! topics are a strict *subset* of the route's eight. `RegionUpdated` feeds the
11//! region map alone, and `NodeMultiaddrUpdated` the dial-address directory alone.
12//!
13//! Bindings is gated on `cache.node_to_node_pull_through_enabled`: when
14//! pull-through is off, the bindings projection is not built at all. Regions,
15//! the operator reverse map, and the dial-address directory are always built,
16//! regardless of pull-through — the ADR-030 selection penalty needs the region
17//! map unconditionally, the chain-backed origin directory needs the reverse map
18//! to resolve operators locally with no `nodeIdOf` RPC, and every node dial
19//! (DHT included) resolves direct addresses through the dial-address directory.
20//!
21//! # What actually differs between the projections
22//!
23//! Mostly the **enumeration**. The live event arms are close to a clean union:
24//! `NodeRegistered` inserts into active, bindings (if built), regions (if
25//! non-empty), the reverse map, and the dial-address directory (if any address
26//! decodes), all unfiltered — staker-set's live arm
27//! applies no `isActive` check either (its filter is bootstrap-only). It is
28//! tempting to read "staker-set is the filtered view, the rest are the
29//! unfiltered ones" as a live-path difference and encode it in the sink; that
30//! would be wrong, and would drop registrations from the active set.
31//!
32//! At bootstrap `active` and `bindings` genuinely diverge:
33//!
34//! - `active` takes the per-entry `active[i]` that `getRegisteredNodes` computes
35//!   on-chain (`isActive(operator)`: registered AND bond ≥ minBond AND no
36//!   unbonding AND not ejected) alongside the `_registeredAddrs` page.
37//! - `bindings` applies no filter: an operator mid-unbonding has `isActive =
38//!   false` but is still payable, so its binding must survive.
39//! - `regions`, the reverse map, and the dial-address directory are likewise
40//!   unfiltered: liveness is applied separately at read time via the shared
41//!   `StakerSet`, and an inactive node stays dialable for channel settlement.
42//!
43//! # Fatality
44//!
45//! The single `getRegisteredNodes` bootstrap is fatal: staker-set requires it.
46//! A transient RPC failure retries on the shared boot budget; a deterministic
47//! fault or an exhausted budget propagates. The bindings projection (pull-through is
48//! opportunistic, so it would otherwise be non-fatal) has **no RPC of its own**:
49//! it is derived from page data already in hand and cannot fail independently.
50//! The same is true of regions, the reverse map, and the dial-address directory.
51//! A node either boots with all five projections or fails at the one shared read, so pull-through is
52//! never lost on its own.
53
54use std::collections::{BTreeMap, HashMap, HashSet};
55use std::future::Future;
56use std::net::SocketAddr;
57use std::sync::{Arc, RwLock};
58use std::time::{Duration, Instant};
59
60use alloy::eips::BlockId;
61use alloy::primitives::{Address, B256, U256};
62use alloy::providers::Provider;
63use alloy::rpc::types::Log;
64use alloy::sol_types::SolEvent;
65use anyhow::{Context, Result};
66use decdn_incentive::node_register::decode_dial_addrs;
67use decdn_protocol::Region;
68use tracing::{debug, info, warn};
69
70use crate::chain_events::boot_retry::{BootFault, BootRetry};
71use crate::chain_events::multiplexed_poller::{Route, SinkSource};
72use crate::chain_events::resumable_watcher::{CursorStart, LogSink, clear_cadence_on_recovery};
73use crate::chain_events::shared_head::{HeadSource, snapshot_block};
74use crate::chain_events::timed;
75use crate::dht::chain_projection::{with_read, with_write};
76use crate::dht::chain_staker_set::{ChainStakerSet, StakerChange, apply_change};
77use crate::dht::dial_addrs::DialAddrDirectory;
78use crate::dht::node_address::{
79    ChainNodeAddressDirectory, NodeAddressResolver, remove_binding, set_binding,
80};
81use crate::dht::routing::NodeId;
82use crate::dht::staker_set::StakerSet;
83use crate::metrics::{Metrics, metric_hook};
84use decdn_common::redact::sanitize_err_chain;
85use decdn_incentive::capacity_bond::CapacityBond;
86
87/// Page size for the paginated `getRegisteredNodes` read. Matches ADR 019 § Step
88/// 3.3's worked-example limit.
89const PAGE_SIZE: u64 = 100;
90
91/// Both projections, plus the shared watcher that keeps them current.
92///
93/// `node_addresses` is `None` when `cache.node_to_node_pull_through_enabled` is
94/// off: the bindings projection is not built at all, so nothing ever moves
95/// `node_address_directory_size` off `0`.
96///
97/// The gauge is still *exported* in that state — every `DecdnMetrics` field
98/// registers unconditionally as one group — so a pull-through-off node (the
99/// default) publishes a permanent `0`, which is indistinguishable from a
100/// pull-through-on node whose directory has collapsed. Any alert on this gauge
101/// must therefore be scoped to nodes with pull-through on. Making it genuinely
102/// absent needs a split group or a labelled family; see #1231.
103#[derive(Debug)]
104pub struct RegistryHandles {
105    /// The bonded-operator set the DHT admits records from.
106    pub staker_set: Arc<dyn StakerSet>,
107    /// `NodeId` → dial address, present only where pull-through is on.
108    pub node_addresses: Option<Arc<dyn NodeAddressResolver>>,
109    /// `NodeId → regionHint`, as the canonical [`decdn_protocol::Region`] code;
110    /// an empty or invalid hint has no entry. Read by the ADR-030 region-latency penalty on the selection/pull path.
111    /// Always present, unlike `node_addresses`.
112    pub regions: Arc<RwLock<HashMap<NodeId, String>>>,
113    /// `operator address → bound NodeId`, always built (like `regions`, unlike
114    /// the pull-through-gated `bindings`) so the chain-backed origin directory
115    /// can resolve operators locally with no `nodeIdOf` RPC.
116    pub operator_to_node: Arc<RwLock<HashMap<Address, NodeId>>>,
117    /// `NodeId → registry-published direct dial addresses`, always built. The
118    /// runtime adds its [`DialAddrDirectory::lookup`] to the node's endpoint.
119    pub dial_addrs: DialAddrDirectory,
120    /// The membership/binding [`Route`] the runtime registers on the shared
121    /// multiplexed poller. The poller stops *after* `router.shutdown` because
122    /// this route's staker-set projection gates DHT admission during drain.
123    /// `pub(crate)`, since [`Route`] is crate-private and only the runtime wires
124    /// it.
125    pub(crate) route: Route,
126}
127
128/// The chain reads the live watcher performs, behind a trait so the event
129/// handlers are unit-testable without a provider.
130///
131/// Spelled as RPITIT with an explicit `+ Send` (rather than `async fn`, whose
132/// desugaring carries no `Send` bound) because the sink is handed to
133/// `tokio::spawn`, which requires the whole `apply` future to be `Send`. Same
134/// shape as [`LogSink`] itself, for the same reason. `Sync` because `&self` is
135/// held across the `node_id_of` await inside `apply`.
136pub(crate) trait RegistryChainReads: Send + Sync {
137    /// `nodeIdOf(operator)` → `(nodeId, active)`; `None` when the operator has no
138    /// binding (`bytes32(0)`).
139    fn node_id_of(
140        &self,
141        operator: Address,
142    ) -> impl Future<Output = Result<Option<(NodeId, bool)>>> + Send;
143
144    /// The block an enumeration pins its pages to
145    /// (`shared_head::snapshot_block`): the lag margin below the head.
146    fn snapshot_block(&self) -> impl Future<Output = Result<u64>> + Send;
147
148    /// Re-enumerate the projections from chain state at block `at`, as the
149    /// bootstrap does.
150    ///
151    /// This is the self-healing leg. Every other input to these projections is
152    /// an event, and an event can be missed — an orphaned log at the unstable
153    /// tip stays applied because `eth_getLogs` never reports a removal, and a
154    /// tick lost to RPC backoff advances nothing but is not replayed. Neither
155    /// shows up as an error, so without a periodic authoritative re-read the
156    /// only repair for a drifted set is a process restart.
157    fn full_snapshot(&self, at: u64) -> impl Future<Output = Result<RegistrySnapshot>> + Send;
158}
159
160/// The five registry projections one `getRegisteredNodes` enumeration derives.
161#[derive(Debug, Clone, Default)]
162pub(crate) struct RegistrySnapshot {
163    /// `isActive`-filtered membership.
164    pub(crate) active: HashSet<NodeId>,
165    /// `NodeId → operator address`, unfiltered.
166    pub(crate) bindings: HashMap<NodeId, Address>,
167    /// `operator address → NodeId`, the inverse of `bindings`.
168    pub(crate) operator_to_node: HashMap<Address, NodeId>,
169    /// `NodeId → canonical regionHint`; an empty or invalid hint has no entry.
170    pub(crate) regions: HashMap<NodeId, String>,
171    /// `NodeId → decoded registry multiaddrs`; a node with none has no entry.
172    pub(crate) dial_addrs: HashMap<NodeId, Vec<SocketAddr>>,
173}
174
175/// Production [`RegistryChainReads`] over the live contract.
176struct ContractReads<P: Provider + Clone> {
177    registry: CapacityBond::CapacityBondInstance<P>,
178    /// The shared head source the snapshot block derives from.
179    head: Arc<dyn HeadSource>,
180}
181
182impl<P: Provider + Clone> RegistryChainReads for ContractReads<P> {
183    async fn snapshot_block(&self) -> Result<u64> {
184        snapshot_block(
185            self.registry.provider(),
186            &*self.head,
187            *self.registry.address(),
188        )
189        .await
190    }
191
192    async fn full_snapshot(&self, at: u64) -> Result<RegistrySnapshot> {
193        bootstrap_registry(&self.registry, BlockId::number(at)).await
194    }
195
196    async fn node_id_of(&self, operator: Address) -> Result<Option<(NodeId, bool)>> {
197        // Bounded explicitly: the poller's per-call timeout covers only its own
198        // merged `get_logs`, so a sink's follow-up read stays unbounded unless
199        // it wraps itself (the `blacklist_watcher::scope_check` precedent). That
200        // matters here because this single loop feeds both the staker-set and the
201        // bindings projection: one stalled `nodeIdOf` would wedge both.
202        // A timeout surfaces as `Err`, which `on_operator_change`
203        // already counts and skips without tripping the stream backoff.
204        let resolved = timed(None, "nodeIdOf", self.registry.nodeIdOf(operator).call()).await?;
205        let node_id = resolved.nodeId.0;
206        if node_id == [0u8; 32] {
207            return Ok(None);
208        }
209        Ok(Some((node_id.into(), resolved.active)))
210    }
211}
212
213/// Applies the five `CapacityBond` membership/binding events to both projections
214/// in one pass — one `decode_log_data` per log feeds both.
215///
216/// Never returns `Err`: an undecodable log is logged and skipped (a deterministic
217/// re-scan of a permanently-undecodable log would hot-loop the cursor), and an
218/// operator-indexed event whose `nodeIdOf` fails is counted and skipped without
219/// tripping the stream backoff.
220pub(crate) struct RegistrySink<R> {
221    pub(crate) reads: R,
222    pub(crate) active: Arc<RwLock<HashSet<NodeId>>>,
223    /// `None` when pull-through is off — see [`RegistryHandles::node_addresses`].
224    pub(crate) bindings: Option<Arc<RwLock<HashMap<NodeId, Address>>>>,
225    /// `NodeId → regionHint`, canonical codes only; an empty or invalid
226    /// `regionHint` is absence (see [`canonical_region`]).
227    /// Always built (not gated on pull-through): the ADR-030 selection penalty
228    /// reads it whether or not node-to-node pull is on.
229    pub(crate) regions: Arc<RwLock<HashMap<NodeId, String>>>,
230    /// `operator address → bound NodeId`. Always built (not gated on
231    /// pull-through, like `regions`) and unfiltered: an ejected or unbonding
232    /// operator keeps its reverse entry, since liveness is applied separately
233    /// at read time via the shared `StakerSet`.
234    pub(crate) operator_to_node: Arc<RwLock<HashMap<Address, NodeId>>>,
235    /// `NodeId → registry multiaddrs`. Always built and unfiltered, like
236    /// `regions`.
237    pub(crate) dial_addrs: DialAddrDirectory,
238    pub(crate) metrics: Arc<Metrics>,
239    /// How often [`RegistryChainReads::full_snapshot`] re-derives both
240    /// projections. Deliberately a constant rather than a config knob: it is a
241    /// correctness backstop, not a tuning surface, and an operator who set it
242    /// wrong would silently lose the only drift repair short of a restart.
243    pub(crate) resync_interval: Duration,
244    /// When the last re-enumeration ran. Seeded to "just ran" at bootstrap,
245    /// since the bootstrap enumeration IS one.
246    pub(crate) last_resync: Option<Instant>,
247    /// The block of the latest tail change to each `NodeId`. A resync keeps the
248    /// current projection entries of every node changed above its snapshot block
249    /// (see [`Self::overlay_tail_changes`]), then forgets the rest.
250    pub(crate) tail_changes: HashMap<NodeId, u64>,
251}
252
253impl<R: RegistryChainReads> RegistrySink<R> {
254    /// `NodeRegistered`: active insert (unfiltered — see the module doc) AND a
255    /// binding insert.
256    fn on_registered(
257        &self,
258        node_id: NodeId,
259        eth_address: Address,
260        region: &str,
261        multiaddrs: &[u8],
262    ) {
263        apply_change(&self.active, &self.metrics, StakerChange::Active(node_id));
264        self.dial_addrs.set(node_id, decode_dial_addrs(multiaddrs));
265        if let Some(bindings) = &self.bindings {
266            set_binding(bindings, &self.metrics, node_id, eth_address);
267        }
268        with_write(&self.operator_to_node, "chain operator reverse map", |m| {
269            m.insert(eth_address, node_id);
270        });
271        if let Some(region) = canonical_region(region) {
272            with_write(&self.regions, "chain region directory", |m| {
273                m.insert(node_id, region);
274            });
275        }
276    }
277
278    /// `NodeDeregistered`: the binding, region, and dial addresses are cleared
279    /// only here — `registerNode` sets them, `deregisterNode` clears them,
280    /// `updateRegion` replaces the region ([`Self::on_region_updated`]), and
281    /// `updateMultiaddrs` replaces the dial addresses. Bond/unbonding/ejection
282    /// transitions flip `isActive` without touching any of them.
283    fn on_deregistered(&self, node_id: NodeId) {
284        apply_change(&self.active, &self.metrics, StakerChange::Inactive(node_id));
285        if let Some(bindings) = &self.bindings {
286            remove_binding(bindings, &self.metrics, &node_id);
287        }
288        // No address is carried on this event, so remove by value: the reverse
289        // map is always-on (unlike `bindings`), so it cannot be sourced from
290        // `bindings` to find the address first.
291        with_write(&self.operator_to_node, "chain operator reverse map", |m| {
292            m.retain(|_addr, nid| *nid != node_id);
293        });
294        with_write(&self.regions, "chain region directory", |m| {
295            m.remove(&node_id);
296        });
297        self.dial_addrs.remove(&node_id);
298    }
299
300    /// `RegionUpdated`: the region map follows the operator's new
301    /// `regionHint` on the tick it lands. Membership and bindings are
302    /// untouched. An empty or invalid region is absence, as in
303    /// [`Self::on_registered`].
304    fn on_region_updated(&self, node_id: NodeId, new_region: &str) {
305        let region = canonical_region(new_region);
306        with_write(&self.regions, "chain region directory", |m| match region {
307            Some(region) => {
308                m.insert(node_id, region);
309            }
310            None => {
311                m.remove(&node_id);
312            }
313        });
314    }
315
316    /// Resolve an operator-indexed event via `nodeIdOf` and apply the implied
317    /// change. The canonical `nodeIdOf.active` wins over what the event implies
318    /// (they can disagree if a later transition raced the event). Bindings are
319    /// untouched: these events never change one.
320    ///
321    /// Returns the node the change applied to, `None` when it applied nothing.
322    async fn on_operator_change(
323        &self,
324        operator: Address,
325        event_implies_active: bool,
326    ) -> Option<NodeId> {
327        let resolved = match self.reads.node_id_of(operator).await {
328            Ok(r) => r,
329            Err(err) => {
330                // Dropping the change leaves the cached set out of sync with chain
331                // state until a follow-up event for the same operator arrives, or
332                // until `on_tick_complete`'s re-enumeration succeeds. An RPC
333                // failure here is an `eth_call` while the poll is `eth_getLogs`,
334                // so it can happen with the route perfectly healthy — in which
335                // case the repair is the cadence, not the recovery edge. When it
336                // does travel with a poll failure, `on_recovered` forces the
337                // re-enumeration on the tick the route comes back. Unlike a
338                // stream-level error this does not trip the watcher backoff, so
339                // without this counter it would move no metric at all.
340                self.metrics.staker_set_watcher_resolve_failure();
341                warn!(
342                    error = %sanitize_err_chain(&err),
343                    %operator,
344                    "nodeIdOf RPC failed; cached active set may diverge from chain state for this operator"
345                );
346                return None;
347            }
348        };
349        let Some((node_id, now_active)) = resolved else {
350            debug!(%operator, "operator-indexed event for unbound operator; ignoring");
351            return None;
352        };
353        if now_active != event_implies_active {
354            debug!(
355                %operator,
356                event_implies_active,
357                now_active,
358                "operator-indexed event disagrees with canonical nodeIdOf.active; trusting nodeIdOf"
359            );
360        }
361        let change = if now_active {
362            StakerChange::Active(node_id)
363        } else {
364            StakerChange::Inactive(node_id)
365        };
366        apply_change(&self.active, &self.metrics, change);
367        Some(node_id)
368    }
369
370    /// Carry every node the tail changed above `at` from the current projections
371    /// into a snapshot pinned at `at`, then forget the changes at or below it.
372    ///
373    /// The snapshot reads the lagged block, so it misses any change the tail
374    /// already applied above it. Replacing the projections wholesale would roll
375    /// those back until the next resync. A node changed above `at` keeps its
376    /// current entry in every projection; every other node takes the snapshot's.
377    fn overlay_tail_changes(&mut self, at: u64, snap: &mut RegistrySnapshot) {
378        self.tail_changes.retain(|_, block| *block > at);
379        for node_id in self.tail_changes.keys() {
380            if with_read(&self.active, "chain staker set", |set| {
381                set.contains(node_id)
382            }) {
383                snap.active.insert(*node_id);
384            } else {
385                snap.active.remove(node_id);
386            }
387            if let Some(current) = &self.bindings {
388                match with_read(current, "chain node-address directory", |m| {
389                    m.get(node_id).copied()
390                }) {
391                    Some(addr) => snap.bindings.insert(*node_id, addr),
392                    None => snap.bindings.remove(node_id),
393                };
394            }
395            match with_read(&self.regions, "chain region directory", |m| {
396                m.get(node_id).cloned()
397            }) {
398                Some(region) => snap.regions.insert(*node_id, region),
399                None => snap.regions.remove(node_id),
400            };
401            match self.dial_addrs.get(node_id) {
402                Some(addrs) => snap.dial_addrs.insert(*node_id, addrs),
403                None => snap.dial_addrs.remove(node_id),
404            };
405            snap.operator_to_node.retain(|_, nid| nid != node_id);
406            with_read(&self.operator_to_node, "chain operator reverse map", |m| {
407                for (addr, nid) in m {
408                    if nid == node_id {
409                        snap.operator_to_node.insert(*addr, *nid);
410                    }
411                }
412            });
413        }
414    }
415}
416
417impl<R: RegistryChainReads> LogSink for RegistrySink<R> {
418    #[allow(clippy::cognitive_complexity, clippy::too_many_lines)] // one flat arm per event
419    async fn apply(&mut self, log: Log) -> Result<()> {
420        // A log without a block number never comes back from `eth_getLogs`; one
421        // that did would count as the newest change, which a resync keeps.
422        let block = log.block_number.unwrap_or(u64::MAX);
423        let changed = match log.topic0().copied() {
424            Some(sig) if sig == CapacityBond::NodeRegistered::SIGNATURE_HASH => {
425                match CapacityBond::NodeRegistered::decode_log_data(&log.inner.data) {
426                    Ok(event) => {
427                        let node_id = NodeId::from_bytes(event.nodeId.0);
428                        self.on_registered(
429                            node_id,
430                            event.ethAddress,
431                            &event.regionHint,
432                            &event.multiaddrs,
433                        );
434                        Some(node_id)
435                    }
436                    Err(err) => {
437                        warn!(error = %err, "skipping undecodable NodeRegistered log");
438                        None
439                    }
440                }
441            }
442            Some(sig) if sig == CapacityBond::NodeDeregistered::SIGNATURE_HASH => {
443                match CapacityBond::NodeDeregistered::decode_log_data(&log.inner.data) {
444                    Ok(event) => {
445                        let node_id = NodeId::from_bytes(event.nodeId.0);
446                        self.on_deregistered(node_id);
447                        Some(node_id)
448                    }
449                    Err(err) => {
450                        warn!(error = %err, "skipping undecodable NodeDeregistered log");
451                        None
452                    }
453                }
454            }
455            Some(sig) if sig == CapacityBond::NodeAutoEjected::SIGNATURE_HASH => {
456                match CapacityBond::NodeAutoEjected::decode_log_data(&log.inner.data) {
457                    // Deactivates WITHOUT clearing the binding: ejection flips
458                    // `isActive` only, and the operator may still be owed payment
459                    // on an open channel.
460                    Ok(event) => {
461                        let node_id = NodeId::from(event.nodeId.0);
462                        apply_change(&self.active, &self.metrics, StakerChange::Inactive(node_id));
463                        Some(node_id)
464                    }
465                    Err(err) => {
466                        warn!(error = %err, "skipping undecodable NodeAutoEjected log");
467                        None
468                    }
469                }
470            }
471            Some(sig) if sig == CapacityBond::Reinstated::SIGNATURE_HASH => {
472                match CapacityBond::Reinstated::decode_log_data(&log.inner.data) {
473                    Ok(event) => self.on_operator_change(event.operator, true).await,
474                    Err(err) => {
475                        warn!(error = %err, "skipping undecodable Reinstated log");
476                        None
477                    }
478                }
479            }
480            Some(sig) if sig == CapacityBond::UnbondingRequested::SIGNATURE_HASH => {
481                match CapacityBond::UnbondingRequested::decode_log_data(&log.inner.data) {
482                    Ok(event) => self.on_operator_change(event.operator, false).await,
483                    Err(err) => {
484                        warn!(error = %err, "skipping undecodable UnbondingRequested log");
485                        None
486                    }
487                }
488            }
489            // The blacklist-ejection twin of `Reinstated` (#1030). `ejected` is a
490            // conjunct of `isActive`, so an ejection deactivates the operator
491            // exactly as an unbonding request does — but the event was missing
492            // from this OR-set, so an ejection only landed at the next
493            // `REGISTRY_RESYNC_INTERVAL` re-enumeration. Every consumer of the
494            // staker set was wrong for up to 15 minutes, DHT admission included.
495            Some(sig) if sig == CapacityBond::EjectedByBlacklist::SIGNATURE_HASH => {
496                match CapacityBond::EjectedByBlacklist::decode_log_data(&log.inner.data) {
497                    Ok(event) => self.on_operator_change(event.operator, false).await,
498                    Err(err) => {
499                        warn!(error = %err, "skipping undecodable EjectedByBlacklist log");
500                        None
501                    }
502                }
503            }
504            Some(sig) if sig == CapacityBond::RegionUpdated::SIGNATURE_HASH => {
505                match CapacityBond::RegionUpdated::decode_log_data(&log.inner.data) {
506                    Ok(event) => {
507                        let node_id = NodeId::from_bytes(event.nodeId.0);
508                        self.on_region_updated(node_id, &event.newRegion);
509                        Some(node_id)
510                    }
511                    Err(err) => {
512                        warn!(error = %err, "skipping undecodable RegionUpdated log");
513                        None
514                    }
515                }
516            }
517            // `updateMultiaddrs`: the dial-address directory follows the new
518            // record on the tick it lands. An empty or undecodable record is
519            // absence.
520            Some(sig) if sig == CapacityBond::NodeMultiaddrUpdated::SIGNATURE_HASH => {
521                match CapacityBond::NodeMultiaddrUpdated::decode_log_data(&log.inner.data) {
522                    Ok(event) => {
523                        let node_id = NodeId::from_bytes(event.nodeId.0);
524                        self.dial_addrs
525                            .set(node_id, decode_dial_addrs(&event.multiaddrs));
526                        Some(node_id)
527                    }
528                    Err(err) => {
529                        warn!(error = %err, "skipping undecodable NodeMultiaddrUpdated log");
530                        None
531                    }
532                }
533            }
534            _ => {
535                debug!(topic0 = ?log.topic0(), "unmatched CapacityBond event in subscribed OR-set");
536                None
537            }
538        };
539        if let Some(node_id) = changed {
540            self.tail_changes.insert(node_id, block);
541        }
542        Ok(())
543    }
544
545    /// Force the next reconcile to re-enumerate, rather than waiting out the
546    /// cadence.
547    ///
548    /// The watcher just recovered from an errored tick, which is exactly when
549    /// the projections are most likely to have drifted: an event dropped by a
550    /// failed `nodeIdOf` during the outage leaves the cached set out of sync,
551    /// and the cadence is at its least helpful — the reconcile is skipped while
552    /// the route is errored, so the repair has not been running. Clearing the
553    /// clock makes [`Self::on_tick_complete`], on this same tick, re-read.
554    ///
555    /// Floored by [`clear_cadence_on_recovery`]: an endpoint that flaps rather
556    /// than staying down produces a recovery edge every few seconds, and
557    /// clearing the clock unconditionally would aim a full paginated enumeration
558    /// at it on each one.
559    fn on_recovered(&mut self) {
560        clear_cadence_on_recovery(&mut self.last_resync, self.resync_interval);
561    }
562
563    /// Cadence-gated re-enumeration: rebuild the projections from chain state at
564    /// the lagged snapshot block, carry over every node the tail changed above it
565    /// ([`Self::overlay_tail_changes`]), and swap them in.
566    ///
567    /// Build-then-swap, never clear-then-fill: the new sets are fully
568    /// materialized before either lock is taken for writing, so a failed read
569    /// leaves the previous projections intact rather than emptying them. An
570    /// empty staker set would make the node treat every peer as unstaked.
571    ///
572    /// Returns `Ok` on failure. The event tail is the primary path and is still
573    /// working; backing the whole watcher off would also stall event pickup,
574    /// trading a stale set for no updates at all. A failure moves
575    /// `decdn_capacity_bond_registry_resync_failures_total` instead, and a
576    /// success stamps
577    /// `decdn_capacity_bond_registry_last_resync_timestamp_seconds`.
578    ///
579    /// Two triggers reach here: the cadence, and
580    /// [`Self::on_recovered`] clearing the clock when the route comes back from
581    /// an errored tick.
582    async fn on_tick_complete(&mut self) -> Result<()> {
583        // Once per clean tick rather than per event: one pass covers every
584        // event the tick applied.
585        publish_region_counts(&self.active, &self.regions, &self.metrics);
586        let now = Instant::now();
587        if self
588            .last_resync
589            .is_some_and(|last| now.duration_since(last) < self.resync_interval)
590        {
591            return Ok(());
592        }
593        // Stamp before the call, not only on success, so a persistently failing
594        // read retries on the resync cadence rather than on every watcher tick.
595        self.last_resync = Some(now);
596
597        let read = async {
598            let at = self.reads.snapshot_block().await?;
599            let snapshot = self.reads.full_snapshot(at).await?;
600            anyhow::Ok((at, snapshot))
601        };
602        let (at, mut snap) = match read.await {
603            Ok(read) => read,
604            Err(err) => {
605                // `Ok` upward, so the counter is the only thing that moves: an
606                // `Err` marks the route errored, which stalls event pickup and
607                // also skips the next resync entirely.
608                self.metrics.capacity_bond_registry_resync_failure();
609                warn!(
610                    error = %sanitize_err_chain(&err),
611                    "capacity-bond registry resync failed; keeping current projections"
612                );
613                return Ok(());
614            }
615        };
616
617        self.overlay_tail_changes(at, &mut snap);
618        let RegistrySnapshot {
619            active,
620            bindings,
621            operator_to_node,
622            regions,
623            dial_addrs,
624        } = snap;
625        let active_len = active.len();
626        let binding_len = bindings.len();
627        with_write(&self.active, "chain staker set", |set| *set = active);
628        self.metrics.staker_set_active_count(active_len);
629        if let Some(slot) = &self.bindings {
630            with_write(slot, "chain node-address directory", |map| *map = bindings);
631            self.metrics.node_address_directory_size(binding_len);
632        }
633        with_write(&self.operator_to_node, "chain operator reverse map", |m| {
634            *m = operator_to_node;
635        });
636        with_write(&self.regions, "chain region directory", |map| {
637            *map = regions;
638        });
639        self.dial_addrs.replace_all(dial_addrs);
640        publish_region_counts(&self.active, &self.regions, &self.metrics);
641        self.metrics.capacity_bond_registry_resync();
642        debug!(
643            active_count = active_len,
644            binding_count = binding_len,
645            "capacity-bond registry resynced from chain"
646        );
647        Ok(())
648    }
649}
650
651/// How often the watcher re-derives both projections from `getRegisteredNodes`.
652///
653/// Fifteen minutes is well under any plausible drift-detection window and costs
654/// one paginated read plus an `isActive` per registered operator — on a network
655/// of tens of nodes, a handful of calls per quarter hour. Deliberately a
656/// constant, not a config knob: it is a correctness backstop rather than a
657/// tuning surface, and an operator who set it wrong (or to zero) would silently
658/// lose the only repair for a drifted set short of a process restart.
659const REGISTRY_RESYNC_INTERVAL: Duration = Duration::from_mins(15);
660
661/// One paginated `getRegisteredNodes` read feeding all five projections.
662///
663/// Always returns all five maps and lets the caller drop the unwanted one — a
664/// `want_bindings: bool` parameter would be a boolean trap, and building the map
665/// is free while already iterating the page. See the module doc for why `active`
666/// is `isActive`-filtered and `bindings` is not: `getRegisteredNodes` computes
667/// `isActive` per entry on-chain and returns it as a parallel `active[]`, so a
668/// single call per page derives `active` and `bindings` with no per-entry
669/// round-trip. `operator_to_node` is the inverse of `bindings`, built from the
670/// same page with no chain call of its own; like `regions` and `dial_addrs` it
671/// is unfiltered and always built.
672///
673/// Every page reads at `at`, the snapshot block, so the pages agree with each
674/// other: a load-balanced provider cannot answer one page from a lagging
675/// upstream and the next from a current one.
676async fn bootstrap_registry<P>(
677    registry: &CapacityBond::CapacityBondInstance<P>,
678    at: BlockId,
679) -> Result<RegistrySnapshot>
680where
681    P: Provider + Clone,
682{
683    let mut snap = RegistrySnapshot::default();
684    let mut offset = 0u64;
685    loop {
686        let resp = timed(
687            None,
688            "getRegisteredNodes",
689            registry
690                .getRegisteredNodes(U256::from(offset), U256::from(PAGE_SIZE))
691                .block(at)
692                .call(),
693        )
694        .await
695        .with_context(|| format!("getRegisteredNodes(offset={offset}, limit={PAGE_SIZE})"))?;
696        // `page` and `active` are equal-length by construction (the contract fills
697        // both in one loop). A divergence is an ABI/decoder fault; the `zip` below
698        // would silently truncate and drop stakers/bindings, so fail loudly. It is
699        // a `BootFault`, so a boot read does not retry it.
700        if resp.page.len() != resp.active.len() {
701            return Err(BootFault(format!(
702                "getRegisteredNodes(offset={offset}) returned mismatched page/active \
703                 lengths ({} vs {}) — ABI or decoder fault",
704                resp.page.len(),
705                resp.active.len()
706            ))
707            .into());
708        }
709        if resp.page.is_empty() {
710            break;
711        }
712        let page_len = resp.page.len() as u64;
713        // `active[i]` is `isActive(page[i])`, computed on-chain and index-aligned
714        // with `page` by the contract. `bindings` takes every entry (unfiltered):
715        // an operator mid-unbonding is inactive but still payable.
716        for (node, &is_active) in resp.page.iter().zip(resp.active.iter()) {
717            let node_id = NodeId::from_bytes(node.nodeId.0);
718            snap.bindings.insert(node_id, node.ethAddress);
719            snap.operator_to_node.insert(node.ethAddress, node_id);
720            if let Some(region) = canonical_region(&node.regionHint) {
721                snap.regions.insert(node_id, region);
722            }
723            let dial = decode_dial_addrs(&node.multiaddrs);
724            if !dial.is_empty() {
725                snap.dial_addrs.insert(node_id, dial);
726            }
727            if is_active {
728                snap.active.insert(node_id);
729            }
730        }
731        offset = offset.saturating_add(page_len);
732    }
733    Ok(snap)
734}
735
736/// Enumerate `CapacityBond` once, then spawn the single watcher that keeps both
737/// projections current.
738///
739/// The snapshot-block read and the enumeration retry transient failures on
740/// `boot`'s budget; a deterministic fault or an exhausted budget fails the
741/// bootstrap.
742///
743/// `track_node_addresses` mirrors `cache.node_to_node_pull_through_enabled`.
744pub async fn bootstrap<P>(
745    provider: P,
746    registry_addr: Address,
747    head: Arc<dyn HeadSource>,
748    track_node_addresses: bool,
749    metrics: Arc<Metrics>,
750    boot: &BootRetry,
751) -> Result<RegistryHandles>
752where
753    P: Provider + Clone + 'static,
754{
755    let registry = CapacityBond::new(registry_addr, provider.clone());
756    let reads = ContractReads {
757        registry: registry.clone(),
758        head,
759    };
760
761    // Pin every page to one snapshot block and seed the tail cursor at that same
762    // block. An event at or below it is in the enumeration; one above it is on
763    // the tail, which scans from the block inclusive. Re-applying an event the
764    // snapshot already reflects is a no-op in every arm, so overlap is safe but a
765    // gap is not. The block sits `SNAPSHOT_LAG_MARGIN_BLOCKS` below the reported
766    // head so every upstream behind a load-balanced RPC can serve the pages.
767    let (
768        snapshot_block,
769        RegistrySnapshot {
770            active: initial_active,
771            bindings: initial_bindings,
772            operator_to_node: initial_operator_to_node,
773            regions: initial_regions,
774            dial_addrs: initial_dial_addrs,
775        },
776    ) = boot
777        .run("CapacityBond registry snapshot", || async {
778            let snapshot_block = reads
779                .snapshot_block()
780                .await
781                .context("read the CapacityBond registry snapshot block")?;
782            let snapshot = reads.full_snapshot(snapshot_block).await.with_context(|| {
783                format!("paginated getRegisteredNodes from CapacityBond at {registry_addr}")
784            })?;
785            Ok((snapshot_block, snapshot))
786        })
787        .await?;
788    info!(
789        active_count = initial_active.len(),
790        binding_count = initial_bindings.len(),
791        dial_addr_count = initial_dial_addrs.len(),
792        track_node_addresses,
793        snapshot_block,
794        %registry_addr,
795        "CapacityBond registry bootstrap complete"
796    );
797    // Publish the bootstrap sizes so the gauges are non-`(no data)` before the
798    // first membership change.
799    metrics.staker_set_active_count(initial_active.len());
800    if track_node_addresses {
801        metrics.node_address_directory_size(initial_bindings.len());
802    }
803
804    let active = Arc::new(RwLock::new(initial_active));
805    let bindings = track_node_addresses.then(|| Arc::new(RwLock::new(initial_bindings)));
806    let operator_to_node = Arc::new(RwLock::new(initial_operator_to_node));
807    let regions = Arc::new(RwLock::new(initial_regions));
808    let dial_addrs = DialAddrDirectory::default();
809    dial_addrs.replace_all(initial_dial_addrs);
810    publish_region_counts(&active, &regions, &metrics);
811
812    // Bootstrap IS a successful re-enumeration — the same `getRegisteredNodes`
813    // read, against the same contract. Stamping the liveness gauge here is what
814    // makes the staleness alert usable: its `> 0` guard would otherwise suppress
815    // it forever on the node where every later resync fails or is skipped, which
816    // is the exact node it exists to find.
817    metrics.capacity_bond_registry_resync();
818
819    let sink = RegistrySink {
820        reads,
821        active: Arc::clone(&active),
822        bindings: bindings.clone(),
823        operator_to_node: Arc::clone(&operator_to_node),
824        regions: Arc::clone(&regions),
825        dial_addrs: dial_addrs.clone(),
826        metrics: Arc::clone(&metrics),
827        resync_interval: REGISTRY_RESYNC_INTERVAL,
828        // The bootstrap enumeration just ran, so the first backstop resync is
829        // due one interval from now rather than on the first tick.
830        last_resync: Some(Instant::now()),
831        tail_changes: HashMap::new(),
832    };
833    let route = Route {
834        addresses: vec![registry_addr],
835        topic0s: registry_route_topic0s(),
836        start: registry_cursor_start(snapshot_block),
837        // This sink observes no shutdown token, so it registers a `Ready` sink.
838        sink: SinkSource::Ready(Box::new(sink)),
839        label: "capacity-bond",
840        // `staker_set_watcher_*` is the shared `capacity-bond` route's health, and
841        // it covers the bindings projection too — one route feeds both, so there
842        // is one thing to report.
843        on_established: Some(metric_hook(
844            &metrics,
845            Metrics::staker_set_watcher_cycle_established,
846        )),
847        on_backoff: Some(metric_hook(
848            &metrics,
849            Metrics::staker_set_watcher_backoff_started,
850        )),
851        on_tick_success: Some(metric_hook(&metrics, Metrics::staker_set_watcher_tick)),
852        on_task_panic: Some(metric_hook(
853            &metrics,
854            Metrics::staker_set_watcher_task_panicked,
855        )),
856    };
857
858    let staker_set: Arc<dyn StakerSet> = Arc::new(ChainStakerSet::from_parts(active));
859    let node_addresses = bindings.map(|b| {
860        Arc::new(ChainNodeAddressDirectory::from_parts(b)) as Arc<dyn NodeAddressResolver>
861    });
862    Ok(RegistryHandles {
863        staker_set,
864        node_addresses,
865        regions,
866        operator_to_node,
867        dial_addrs,
868        route,
869    })
870}
871
872/// The canonical form of an on-chain `regionHint`, or `None` when it is empty
873/// or not an accepted code.
874///
875/// The contract only length-checks `regionHint`, so `" de "` can land on
876/// chain. Every region-map consumer compares codes as strings — the ADR-030
877/// penalty against the node's own normalized `identity.region` — so the map
878/// stores the [`Region::parse`] form and nothing else.
879fn canonical_region(raw: &str) -> Option<String> {
880    Region::parse(raw).map(|r| r.as_str().to_owned())
881}
882
883/// Publish the active-staker set size per declared region (ADR 030) to
884/// `decdn_staker_set_active_by_region`.
885///
886/// `regions` is unfiltered, so the count walks `active` and looks each node up.
887/// An active node with no region-map entry counts as unknown. The map holds
888/// canonical codes only ([`canonical_region`]); parsing again yields the typed
889/// key, and keeps an unparsed string from ever becoming a metric label.
890fn publish_region_counts(
891    active: &RwLock<HashSet<NodeId>>,
892    regions: &RwLock<HashMap<NodeId, String>>,
893    metrics: &Metrics,
894) {
895    let (counts, unknown) = with_read(active, "chain staker set", |active| {
896        with_read(regions, "chain region directory", |regions| {
897            let mut counts: BTreeMap<Region, usize> = BTreeMap::new();
898            let mut unknown = 0usize;
899            for id in active {
900                match regions.get(id).and_then(|raw| Region::parse(raw)) {
901                    Some(region) => *counts.entry(region).or_default() += 1,
902                    None => unknown += 1,
903                }
904            }
905            (counts, unknown)
906        })
907    });
908    metrics.staker_set_active_by_region(&counts, unknown);
909}
910
911/// Read a node's operator-attested region (ADR 030) straight from the registry's
912/// `NodeId → regionHint` projection, or `None` when the registry holds no region
913/// for it. Poison-tolerant via [`with_read`]: a writer that panicked left the map
914/// structurally intact, so recover it and log once rather than let a prior panic
915/// permanently suppress region reads (and with them the ADR-030 selection penalty).
916pub(crate) fn region_of(
917    regions: &Arc<RwLock<HashMap<NodeId, String>>>,
918    id: NodeId,
919) -> Option<String> {
920    with_read(regions, "registry regions", |m| m.get(&id).cloned())
921}
922
923/// The registry route's demux key: every `CapacityBond` staker-membership
924/// event, plus `RegionUpdated` for the region map and `NodeMultiaddrUpdated`
925/// for the dial-address directory. Split out from [`bootstrap`] so the exact topic0 set is unit-testable
926/// without a provider.
927fn registry_route_topic0s() -> Vec<B256> {
928    vec![
929        CapacityBond::NodeRegistered::SIGNATURE_HASH,
930        CapacityBond::NodeDeregistered::SIGNATURE_HASH,
931        CapacityBond::NodeAutoEjected::SIGNATURE_HASH,
932        CapacityBond::Reinstated::SIGNATURE_HASH,
933        CapacityBond::UnbondingRequested::SIGNATURE_HASH,
934        CapacityBond::EjectedByBlacklist::SIGNATURE_HASH,
935        CapacityBond::RegionUpdated::SIGNATURE_HASH,
936        CapacityBond::NodeMultiaddrUpdated::SIGNATURE_HASH,
937    ]
938}
939
940/// The registry route's cursor start: seed the live tail from the enumeration
941/// snapshot block. The staker set is rebuilt from that enumeration each boot, so
942/// there is no durable cursor to persist. Split out from [`bootstrap`] so the
943/// cursor shape is unit-testable without a provider.
944const fn registry_cursor_start(snapshot_block: u64) -> CursorStart {
945    CursorStart::Seeded { at: snapshot_block }
946}
947
948#[cfg(test)]
949#[allow(
950    clippy::unwrap_used,
951    clippy::expect_used,
952    clippy::panic,
953    clippy::indexing_slicing
954)]
955mod tests;