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, ®ions, &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(®ions),
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;