Skip to main content

decdn_node/runtime/
mod.rs

1//! Node runtime: owns the iroh endpoint, metrics server, and protocol router.
2
3mod addr_publish;
4pub mod eviction;
5mod reachability;
6pub mod reload;
7
8pub(crate) use reload::emit_config_notices;
9pub use reload::{LogLevelApply, LogLevelSetter, ReloadSnapshot, RuntimeReloadState};
10
11use crate::chain_events::boot_retry::{BOOT_CHAIN_RETRY_BUDGET, BootRetry};
12use crate::chain_events::shared_head::{HeadSource, SharedHead};
13
14use std::net::{Ipv4Addr, Ipv6Addr, SocketAddr, SocketAddrV4, SocketAddrV6};
15use std::num::NonZeroUsize;
16use std::path::{Path, PathBuf};
17use std::sync::Arc;
18use std::time::Duration;
19
20use anyhow::Context;
21use decdn_cache::{
22    CacheEngine, FilesystemOrigin, HttpOrigin, Origin, S3Credentials, S3Origin, S3OriginConfig,
23};
24use decdn_common::config::{ResolvedDiscovery, ResolvedOrigin, ResolvedS3Credentials};
25use iroh::address_lookup::{DnsAddressLookup, MemoryLookup, PkarrPublisher};
26use iroh::endpoint::{BindOpts, IdleTimeout, QuicTransportConfig, VarInt, presets};
27use iroh::protocol::Router;
28use iroh::{Endpoint, EndpointAddr, PublicKey, RelayMap, RelayMode, RelayUrl, SecretKey};
29
30use addr_publish::node_publish_filter;
31use noq_proto::congestion::Bbr3Config;
32use tokio::sync::oneshot;
33use tokio::task::JoinSet;
34use tokio_util::sync::CancellationToken;
35
36use crate::admin;
37use crate::channel_store::PersistentPoolStateStore;
38use crate::dht::{DhtRateLimiter, RecordStore, RecordStoreConfig, StakerSet};
39use crate::dispatch::ConnectionLimiter;
40use crate::handlers::client::{ClientHandler, ClientProtocol, MAX_CLIENT_STREAMS};
41use crate::handlers::dht::DhtHandler;
42use crate::handlers::probe::{ProbeHandler, StakeLanePolicy as ProbeStakeLanePolicy};
43use crate::handlers::probe_rate_limit::ProbeRateLimiter;
44use crate::metrics;
45use crate::payment_settlement::PoolSettlementService;
46use crate::stop_handle::StopHandle;
47use alloy::network::EthereumWallet;
48use alloy::primitives::U256;
49use alloy::providers::{Provider, ProviderBuilder};
50use alloy::signers::local::PrivateKeySigner;
51use decdn_common::address::parse_nonzero_address;
52use decdn_common::config::ResolvedConfig;
53use decdn_common::redact::{
54    redact_userinfo, sanitize_err_chain, sanitize_error_sources, sanitize_rpc_display,
55};
56use decdn_incentive::PoolStateStore;
57use decdn_incentive::eth_identity;
58
59/// Ceiling on each bounded shutdown stage: the router close ([`close_router`]),
60/// the final redeem sweep, and the drain of spawned tasks after the endpoint and
61/// metrics server have been signalled to stop. Sized comfortably larger than the
62/// sum of the probe handler's accept + close timeouts (see
63/// `handlers::probe::ACCEPT_BI_TIMEOUT` + `PROBE_READ_TIMEOUT` +
64/// `PROBE_CLOSE_TIMEOUT`) so in-flight handlers finish naturally, and than an
65/// endpoint close's `3 * PTO` wait; each stage's give-up branch only fires as a
66/// safety net.
67const SHUTDOWN_DEADLINE: Duration = Duration::from_secs(15);
68
69/// Period between dispatch per-source rate-limiter GC sweeps (#440).
70/// The acquire path already prunes opportunistically when the keyspace
71/// exceeds `cap + cap/10`, but a node whose connection rate falls below
72/// the over-cap threshold can carry millions of stale buckets
73/// indefinitely. 60s is comfortably larger than the longest realistic
74/// bucket refill window, so the sweep is essentially free when the
75/// keyspace is empty.
76const DISPATCH_GC_INTERVAL: Duration = Duration::from_mins(1);
77
78/// Sampling period for the load-shed controller's egress-bytes EWMA. One
79/// second keeps the `ResourcePressure` policy's egress-budget check
80/// responsive to short bursts, without sampling so often that the EWMA
81/// mostly reflects request-arrival jitter.
82const EGRESS_EWMA_INTERVAL: Duration = Duration::from_secs(1);
83
84/// Interval between periodic GC sweeps of the DHT rate-limiter's per-IP
85/// and per-peer keyed maps (#645). 60s matches `DISPATCH_GC_INTERVAL` —
86/// the two limiters share the same operator mental model for keyspace
87/// cleanup cadence. Separate constant (rather than reusing
88/// `DISPATCH_GC_INTERVAL`) so a future tune to one limiter doesn't drag
89/// the other along.
90const DHT_RATE_LIMIT_GC_INTERVAL: Duration = Duration::from_mins(1);
91
92/// Interval between periodic GC sweeps of the probe rate-limiter's per-IP and
93/// per-peer keyed maps (#645, #982). Matches `DHT_RATE_LIMIT_GC_INTERVAL` — the
94/// probe and DHT keyed limiters share the same keyspace-cleanup mental model.
95/// Separate constant so a future tune to one limiter doesn't drag the other.
96const PROBE_RATE_LIMIT_GC_INTERVAL: Duration = Duration::from_mins(1);
97
98/// Period between periodic sweeps of the local per-peer reputation store
99/// (ADR 008 §Local Score Calculation). `LocalReputation::evict` drops
100/// entries for peers that have been idle long enough that their EWMA is
101/// no longer a useful ranking signal, so the store does not grow
102/// unbounded with every peer ever observed. 1h matches the DHT
103/// bucket-refresh cadence — reputation and routing-table hygiene share
104/// the same operator mental model for peer-state cleanup.
105const REPUTATION_EVICT_INTERVAL: Duration = Duration::from_hours(1);
106
107/// QUIC-level idle timeout: the transport closes a connection if no
108/// packets arrive for this long. Set to match ADR 005's 30s
109/// connection-lifetime ceiling.
110///
111/// Because [`QUIC_KEEP_ALIVE_INTERVAL`] (10s) is shorter than this
112/// timeout, healthy peers keep refreshing it via PING ACKs and the QUIC
113/// idle reaper rarely fires on its own — that is the spec's intent (see
114/// ADR 005: "below the idle timeout to prevent NAT middleboxes from
115/// dropping the mapping"). The QUIC timer is a defense-in-depth floor
116/// for genuinely silent paths (e.g. peer crash / network partition); the
117/// "close 30s after last stream and no unacked vouchers" rule is
118/// application-layer and lives in the cdn/client/v1 handler's serve loop
119/// (`handlers::client::APP_IDLE_TIMEOUT`), which reaps keep-alive'd-but-
120/// streamless connections the transport timer cannot.
121pub(crate) const QUIC_MAX_IDLE_TIMEOUT: Duration = Duration::from_secs(30);
122
123/// Interval between QUIC PING keep-alive frames. Per ADR 005 §Connection
124/// lifetime, endpoints SHOULD send PINGs at 10s intervals — strictly
125/// less than [`QUIC_MAX_IDLE_TIMEOUT`] so a single dropped probe doesn't
126/// trip the idle reaper, and well below the typical NAT mapping timeout
127/// so middleboxes don't drop the path under quiet load. Quinn applies
128/// this on every connection regardless of role, so this endpoint emits
129/// keep-alives on both client- and server-initiated connections.
130const QUIC_KEEP_ALIVE_INTERVAL: Duration = Duration::from_secs(10);
131
132/// Maximum number of concurrent bidirectional streams a peer may open on
133/// a single connection. Sized for `cdn/client/v1` (the largest cap in
134/// ADR 005 §Concurrent stream limits: client=100, probe=1, dht=1).
135/// `QuicTransportConfig` is per-endpoint, not per-ALPN, so the QUIC
136/// layer can only enforce the union of these caps; tighter per-ALPN
137/// limits are enforced inside each handler. The probe handler already
138/// does this implicitly by calling `accept_bi()` exactly once per
139/// connection and then closing.
140const QUIC_MAX_CONCURRENT_BIDI_STREAMS: u32 = 100;
141
142/// The `FeeRouter` contract's own `OPERATOR_BPS_FLOOR` (`contracts/src/FeeRouter.sol`):
143/// the minimum operator share governance can configure. Used as the startup
144/// fallback for the live operator fee-share seed (ADR 041) — a conservative
145/// UNDER-estimate of the true operator share, which only makes the `margin`
146/// serve-economics policy's buy ceiling more conservative (it assumes MORE
147/// future serve revenue is skimmed by fees than governance has actually set),
148/// never a griefing surface.
149const FEE_ROUTER_OPERATOR_BPS_FLOOR: u16 = 4000;
150
151/// The most of the boot deadline the best-effort fee-share reads may spend on
152/// retries. It covers a short provider blip; a longer outage falls back to the
153/// floor share and leaves the rest of the deadline to the fail-closed reads.
154const FEE_SHARE_BOOT_BUDGET: Duration = Duration::from_mins(1);
155
156/// Apply an explicit client poll interval to a freshly built provider,
157/// overriding alloy's localhost-detected 250 ms default (#1011).
158///
159/// Despite the config knob's name, this does not touch event watching: the
160/// multiplexed poller ticks on its own `poll_interval` and never reads the
161/// client interval. The one consumer still reachable from this node is
162/// `PendingTransactionBuilder::get_receipt`'s heartbeat (alloy-provider
163/// `heart.rs`), which the node awaits in `payment_settlement` and
164/// `buyer_channel` — so this bounds how fast a node awaiting a mined settlement /
165/// channel tx re-polls for its receipt, and alloy's 250 ms localhost default
166/// would otherwise hammer a dev anvil for the life of every pending tx.
167///
168/// `set_poll_interval` uses interior mutability, so this applies to the
169/// already-constructed provider and returns it unchanged in type —
170/// wallet/nonce-filler providers route through it too, because `client()` is a
171/// default `Provider` trait method available on every provider. The interval
172/// comes from `blockchain.event_poll_interval_ms`.
173fn with_poll_interval<P: Provider>(provider: P, interval: Duration) -> P {
174    provider.client().set_poll_interval(interval);
175    provider
176}
177
178/// Keep every iroh ALPN listener closed until the blacklist watcher reports one
179/// clean boot enumeration + operator-scope enforcement pass. The closure is the explicit seam
180/// that makes it impossible to construct the `Router` on either pending or
181/// failed readiness.
182async fn gate_listener_on_blacklist_sync<T>(
183    readiness: oneshot::Receiver<crate::blacklist_watcher::InitialSyncResult>,
184    start_listener: impl FnOnce() -> T,
185) -> anyhow::Result<T> {
186    readiness
187        .await
188        .context("blacklist watcher exited before initial sync completed")?
189        .map_err(anyhow::Error::msg)
190        .context("initial ContentBlacklist sync unavailable")?;
191    Ok(start_listener())
192}
193
194type HttpUrl = alloy::transports::http::reqwest::Url;
195
196/// Construct the four HTTP-provider roles owned by the node runtime (#1252).
197///
198/// Keeping these constructors together makes the pending-transaction poll
199/// interval and wallet nonce policy single-sourced while preserving separate
200/// provider instances for every consumer. The shared-head provider is the one
201/// deliberate exception to the poll override: it never creates pending
202/// transactions and therefore keeps Alloy's transport default.
203///
204/// Every provider sends through [`crate::rpc_metrics::metered_http_client`],
205/// so each JSON-RPC request the node makes counts on
206/// `decdn_rpc_requests_total`.
207#[derive(Clone)]
208struct ProviderFactory {
209    url: HttpUrl,
210    metrics: Arc<metrics::Metrics>,
211}
212
213impl ProviderFactory {
214    const fn new(url: HttpUrl, metrics: Arc<metrics::Metrics>) -> Self {
215        Self { url, metrics }
216    }
217
218    fn client(&self) -> alloy::rpc::client::RpcClient {
219        crate::rpc_metrics::metered_http_client(self.url.clone(), &self.metrics)
220    }
221
222    fn read_only(&self, interval: Duration) -> impl Provider + Clone + use<> {
223        with_poll_interval(
224            ProviderBuilder::new().connect_client(self.client()),
225            interval,
226        )
227    }
228
229    fn shared_head(&self) -> impl Provider + Clone + use<> {
230        ProviderBuilder::new().connect_client(self.client())
231    }
232
233    fn seller_wallet(
234        &self,
235        signer: PrivateKeySigner,
236        interval: Duration,
237    ) -> impl Provider + Clone + use<> {
238        self.wallet(signer, interval)
239    }
240
241    fn buyer_wallet(
242        &self,
243        signer: PrivateKeySigner,
244        interval: Duration,
245    ) -> impl Provider + Clone + use<> {
246        self.wallet(signer, interval)
247    }
248
249    fn wallet(
250        &self,
251        signer: PrivateKeySigner,
252        interval: Duration,
253    ) -> impl Provider + Clone + use<> {
254        with_poll_interval(
255            ProviderBuilder::new()
256                .with_simple_nonce_management()
257                .wallet(EthereumWallet::from(signer))
258                .connect_client(self.client()),
259            interval,
260        )
261    }
262}
263
264/// Runtime [`QuicTransportConfig`]. Tests build their own config via
265/// the same builder when they need to shorten the idle timeout to keep
266/// the test runtime under a second.
267fn quic_transport_config() -> anyhow::Result<QuicTransportConfig> {
268    let idle_timeout: IdleTimeout = QUIC_MAX_IDLE_TIMEOUT.try_into().map_err(|e| {
269        anyhow::anyhow!(
270            "BUG: QUIC_MAX_IDLE_TIMEOUT={QUIC_MAX_IDLE_TIMEOUT:?} not representable as IdleTimeout: {e}"
271        )
272    })?;
273    Ok(QuicTransportConfig::builder()
274        .max_idle_timeout(Some(idle_timeout))
275        .keep_alive_interval(QUIC_KEEP_ALIVE_INTERVAL)
276        .max_concurrent_bidi_streams(VarInt::from_u32(QUIC_MAX_CONCURRENT_BIDI_STREAMS))
277        // BBR3 over the default Cubic. Congestion control lives at the sender, so
278        // this governs the bytes the node serves to a downstream client or node.
279        // BBR paces to the path's measured bandwidth and round trip instead of
280        // treating loss as the sole congestion signal, so a high-latency,
281        // lossy long-haul link — where a single Cubic flow backs off hard — keeps
282        // the pipe full without needing many parallel flows to compensate.
283        .congestion_controller_factory(Arc::new(Bbr3Config::default()))
284        .build())
285}
286
287/// Spawn a periodic task onto `tasks`: burn the first tick, then run `tick`
288/// every `interval` until the returned stop signal is sent (or its sender is
289/// dropped). Every tick body here is synchronous, so `tick` is a plain
290/// `FnMut()` run to completion inside the task.
291///
292/// The `biased; stop-before-tick` select keeps shutdown prompt: the stop arm is
293/// polled first, so a signalled stop returns without waiting out the current
294/// `interval`. A stop arm that failed to return would instead stall shutdown by
295/// up to one full `interval`, which the runtime's normal path would mask as a
296/// "task slow to drain" rather than a bug. `spawn_periodic_exits_promptly_on_shutdown`
297/// pins that a signalled stop returns promptly and that the first tick is
298/// burned; `spawn_periodic_fires_the_tick_body` pins that ticks otherwise run.
299///
300/// The first tick is burned so the first pass lands one interval after startup
301/// rather than on the same tick as bring-up. `name` labels the shutdown log.
302fn spawn_periodic<F>(
303    tasks: &mut JoinSet<()>,
304    name: &'static str,
305    interval: Duration,
306    mut tick: F,
307) -> oneshot::Sender<()>
308where
309    F: FnMut() + Send + 'static,
310{
311    let (stop_tx, mut stop_rx) = oneshot::channel::<()>();
312    tasks.spawn(async move {
313        let mut ticker = tokio::time::interval(interval);
314        ticker.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
315        ticker.tick().await;
316        loop {
317            tokio::select! {
318                biased;
319                _ = &mut stop_rx => {
320                    tracing::debug!(task = name, "periodic task shutdown signal received");
321                    return;
322                }
323                _ = ticker.tick() => tick(),
324            }
325        }
326    });
327    stop_tx
328}
329
330/// Warn when the pin set cannot fit under the cache ceiling (#1130).
331///
332/// Pinned hashes are LRU-exempt (`eviction_candidates` filters them out), so a
333/// pin set larger than `cache.cache_size_mb` makes the eviction driver's
334/// high-water mark permanently unreachable: it will starve every tick while the
335/// disk keeps growing. The hazard applies to every node however the pinned
336/// content arrived, so this runs unconditionally at startup and on reload.
337///
338/// Warn rather than fail or truncate. The operator's pin set is a deliberate
339/// statement about what this node must serve, and silently warming only part of
340/// it would be worse than warming all of it noisily. Sizes come from the
341/// origin-held index, so a hash the index cannot size contributes nothing and
342/// the total is a **lower bound**. That matters: an origin that blocks `HEAD`
343/// leaves the index empty and this check silently never fires. It is a cheap
344/// early hint, not the authoritative signal — that is
345/// `decdn_cache_evictions_starved_total` with `decdn_cache_bytes` at
346/// `decdn_cache_size_limit_bytes`.
347fn warn_if_pins_exceed_cache(cache: &decdn_cache::CacheEngine, cache_size_mb: u64) {
348    let pinned = cache.pinned_snapshot();
349    let pinned_bytes: u64 = pinned
350        .iter()
351        .filter_map(|h| cache.origin_held_size(decdn_cache::Hash::from_bytes(*h.as_bytes())))
352        .fold(0u64, u64::saturating_add);
353    let limit_bytes = cache_size_mb.saturating_mul(1024 * 1024);
354    if pinned_bytes > limit_bytes {
355        tracing::warn!(
356            pinned_count = pinned.len(),
357            pinned_bytes,
358            cache_size_mb,
359            limit_bytes,
360            "cache.pinned_hashes is larger than cache.cache_size_mb; pinned blobs are \
361             LRU-exempt, so the eviction driver can never reach its high-water target and \
362             disk use will exceed the configured ceiling. Raise cache_size_mb or trim the \
363             pin set (#1130)"
364        );
365    }
366}
367
368/// Emit one `debug` line for a keyed rate-limiter GC sweep that pruned buckets.
369/// Shared by the DHT and probe rate-limiter GC ticks, whose per-layer
370/// (`per_ip` / `per_peer`) log arms are otherwise byte-identical.
371fn log_keyspace_gc(kind: &'static str, layer: &'static str, sweep: Option<(usize, usize)>) {
372    if let Some((before, after)) = sweep {
373        tracing::debug!(
374            layer,
375            before,
376            after,
377            dropped = before.saturating_sub(after),
378            "{kind} rate-limit GC sweep complete"
379        );
380    }
381}
382
383/// Runtime infrastructure built during the front bring-up phase of [`run`].
384///
385/// A plain by-value bundle of the long-lived handles the rest of `run` (and the
386/// shutdown sequence) consumes. `run` destructures it immediately so every
387/// downstream call site keeps referring to the same-named locals.
388struct Infra {
389    node_metrics: Arc<metrics::Metrics>,
390    secret_key: SecretKey,
391    eth_signer: Arc<PrivateKeySigner>,
392    /// The `PaymentPool` deployment the lane store is stamped with. The voucher
393    /// EIP-712 domain is derived from this same value, so the stamp and the
394    /// signature domain can never disagree.
395    payment_pool_deployment: crate::channel_store::Deployment,
396    concrete_channel_store: Arc<PersistentPoolStateStore>,
397    channel_state_store: Arc<dyn PoolStateStore>,
398    watcher_checkpoint_store: Arc<dyn decdn_incentive::KeyedCheckpointStore>,
399    receipt_sink: Arc<dyn crate::receipt_log::ReceiptSink>,
400    /// Stop handle for the background receipt writer: cancelled after the
401    /// router drains on shutdown, then awaited in the drain phase so the audit
402    /// tail survives shutdown (#803).
403    receipt_writer: StopHandle,
404    /// Periodic lane-store flush timer (ADR 003 §Off-chain voucher state
405    /// persistence). Aborted after one final durable flush on shutdown.
406    lane_flush_task: tokio::task::JoinHandle<()>,
407    node_origin: Option<crate::node_origin::NodeOrigin>,
408    pull_through_origin: Option<Arc<crate::node_origin::NodeOrigin>>,
409    cache: CacheEngine,
410    /// Cache eviction policy selected by `cache.eviction_policy` (ADR 040).
411    /// Injected into the eviction driver at spawn time
412    /// ([`spawn_background_tasks`]); the engine itself never chooses a policy.
413    eviction_policy: Arc<dyn decdn_cache::EvictionPolicy>,
414    /// Shared ADR 040 frequency estimator, when built (`cache.eviction_policy`
415    /// / `cache.admission_policy` == `"tinylfu"`, or `cache.serve_economics.policy`
416    /// == `"margin"`). `None` when no consumer needs it. Threaded into
417    /// [`crate::node_origin::NodeOriginConfig`] at spawn time
418    /// ([`spawn_background_tasks`]) as the ADR 041 `margin` policy's heat input.
419    frequency_estimator: Option<Arc<dyn decdn_cache::FrequencyEstimator>>,
420    ep: Endpoint,
421    limiter: Arc<ConnectionLimiter>,
422}
423
424/// Front bring-up phase: RPC preflight, eth keystore load, voucher-state
425/// and receipt stores, cache + origin chain, iroh endpoint, and the
426/// connection limiter. The two `reload_state` attach side effects run inline,
427/// at their points in the bring-up order, so a SIGHUP delivered mid-bring-up
428/// still finds a target.
429#[allow(clippy::cognitive_complexity, clippy::too_many_lines)]
430async fn build_infra(
431    cfg: &ResolvedConfig,
432    secret_key: SecretKey,
433    reload_state: &RuntimeReloadState,
434    node_metrics: Arc<metrics::Metrics>,
435) -> anyhow::Result<Infra> {
436    // Preflight: verify RPC endpoint is reachable before committing to
437    // port binding. A 5-second timeout keeps startup responsive on flaky
438    // networks while still catching typos and dead endpoints early.
439    check_rpc_reachability(&cfg.blockchain.rpc_url).await?;
440
441    // Registry-mandatory `decdn_probe_hold_slots_max` (ADR
442    // appendix-observability.md) — static, set once from config.
443    node_metrics.probe_hold_slots_max(cfg.cache.max_probe_holds);
444
445    // Issue #406: load the Ethereum keystore into a live `PrivateKeySigner`
446    // before the rest of startup so any password-source error (env unset,
447    // missing file, prompt aborted) fails fast with a clear message rather
448    // than after the cache + endpoint have been built. `decrypt_keystore`
449    // runs scrypt/argon2 (hundreds of milliseconds to multi-second under
450    // hardened KDF params), so it must run on a blocking thread to avoid
451    // stalling the tokio runtime.
452    let eth_signer = Arc::new(load_eth_signer(cfg).await?);
453    tracing::info!(address = %eth_signer.address(), "loaded eth keystore");
454
455    // Open the off-chain voucher-state store (issue #527, ADR 003
456    // §Off-chain voucher state persistence) before any handler that could
457    // accept a voucher comes online. `PersistentPoolStateStore::open`
458    // performs disk I/O (file create + mode tighten + redb header read), so
459    // run it on a blocking thread to avoid stalling the tokio runtime.
460    // Failure here MUST abort startup: continuing with a fresh in-memory
461    // map silently reopens the replay window the store exists to close.
462    let channel_store_data_dir = cfg.identity.data_dir.clone();
463    // The store binds to the configured `PaymentPool` deployment and drops the
464    // seller-side rows another deployment wrote: pool ids repeat across
465    // deployments, so a stale row would shadow a returning buyer's new pool.
466    let channel_store_deployment = crate::channel_store::Deployment {
467        chain_id: cfg.blockchain.chain_id,
468        payment_pool: parse_nonzero_address(
469            &cfg.blockchain.payment_pool_address,
470            "blockchain.payment_pool_address",
471        )?,
472    };
473    // Verify the configured deployment against the live chain BEFORE the store
474    // opens: `bind_deployment` drops the seller lane state when the stamp
475    // differs, so a typo'd chain id or contract address must abort bring-up
476    // here, while the store is untouched, rather than trigger an irreversible
477    // drop on a WARN.
478    // The URL is not echoed on a parse failure: it can embed a provider API
479    // key, and the reachability probe above has already reported unusable
480    // endpoints with a sanitized display.
481    let preflight_rpc_url: HttpUrl = cfg
482        .blockchain
483        .rpc_url
484        .parse()
485        .context("blockchain.rpc_url is not a valid URL (value redacted; check the config)")?;
486    check_deployment_preflight(
487        ProviderFactory::new(preflight_rpc_url, Arc::clone(&node_metrics)).shared_head(),
488        channel_store_deployment,
489        &BootRetry::new(DEPLOYMENT_PREFLIGHT_BUDGET, Arc::clone(&node_metrics)),
490    )
491    .await?;
492    // Keep the concrete store `Arc` so it can back the seller
493    // `PoolStateStore` (lane_state_v1 table), the pending-settle store
494    // (pending_settle_v1 table, PR #743 review), and the buyer
495    // `BuyerPoolStore` (buyer_pool_state_v1 table, #744) — redb forbids a
496    // second `Database` handle to the same file, so one shared store owns all.
497    let concrete_channel_store: Arc<PersistentPoolStateStore> = Arc::new(
498        tokio::task::spawn_blocking(move || {
499            PersistentPoolStateStore::open(&channel_store_data_dir, channel_store_deployment)
500        })
501        .await
502        .context("channel state store open task panicked")?
503        .context("failed to open channel state store (issue #527 voucher replay guard)")?,
504    );
505    // The one redb-backed store implements the voucher-state trait (for the
506    // handler + #527 replay guard), the pending-settle trait (for the on-chain
507    // settlement sweep, PR #743 review), and the buyer-channel trait (#744).
508    // Derive trait-object handles from the single concrete store so every write
509    // family shares one open store and one fsync discipline (each family commits
510    // on its own per-family redb file); `concrete_channel_store` stays bound for
511    // the buyer handle built further below.
512    let channel_state_store: Arc<dyn PoolStateStore> = concrete_channel_store.clone();
513    // Debounce the scan-checkpoint writes (#784, keyed in #1092): each persisted
514    // watcher (settlement `ChannelOpened`, origin `Origin`) advances its cursor
515    // once per completed `eth_getLogs` window — on the live tail, once per poll
516    // tick with new confirmed blocks — and the directly-durable store fsyncs on
517    // each. The persisted value is only a *floor* for the resume backfill
518    // (`resolve_persisted_start` rewinds it by the reorg margin; the sinks are
519    // idempotent), so coarsening the write cadence is safe — and the one key
520    // still persisted (settlement) is force-flushed on graceful shutdown by its
521    // own service, so steady-state progress is not lost. The origin directory
522    // stopped persisting a cursor in #1504; it enumerates on every boot.
523    // Wrapping here (the wiring layer) keeps the domain trait and the disk store
524    // free of the debounce policy.
525    let watcher_checkpoint_store: Arc<dyn decdn_incentive::KeyedCheckpointStore> = Arc::new(
526        crate::payment_settlement::DebouncedCheckpointStore::new(concrete_channel_store.clone()),
527    );
528    // Boot-time smoke test: read every persisted record so startup fails
529    // fast on corruption / forward-incompatible schema, well before the
530    // `cdn/client/v1` handler is constructed further down the bring-up.
531    // That handler calls `load_all` again to bootstrap its in-memory channel
532    // map — the duplicate read is by design. Infra bring-up hands the handler
533    // the *store*, not a snapshot: a pre-built map threaded through
534    // `ClientHandlerDeps` would couple this stage to the handler's internal
535    // channel representation. Cost: one extra `load_all` on startup.
536    let persisted_count = tokio::task::spawn_blocking({
537        let store = Arc::clone(&channel_state_store);
538        move || store.load_all()
539    })
540    .await
541    .context("channel state store load task panicked")?
542    .context("failed to hydrate persisted channel state")?
543    .len();
544    tracing::info!(
545        channels = persisted_count,
546        "channel state store ready (issue #527 replay guard active)",
547    );
548
549    // Open the append-only download-receipt audit log (issue #248). Unlike the
550    // channel store, a failure here is NON-fatal: the receipt log is an audit
551    // artifact (revenue reconciliation, dispute evidence), not the replay
552    // guard, so the node still serves paid delivery — falling back to a
553    // discard-only log — rather than refusing to start. The open does a small
554    // amount of disk I/O (create + chmod), so run it on the blocking pool.
555    let receipt_log_data_dir = cfg.identity.data_dir.clone();
556    let receipt_log_policy = crate::receipt_log::RotationPolicy::from(&cfg.receipts);
557    let receipt_log: Arc<dyn crate::receipt_log::ReceiptLog> =
558        match tokio::task::spawn_blocking(move || {
559            crate::receipt_log::JsonlReceiptLog::open(&receipt_log_data_dir, receipt_log_policy)
560        })
561        .await
562        .context("download-receipt log open task panicked")?
563        {
564            Ok(log) => {
565                tracing::info!(path = %log.path().display(), "download-receipt log ready");
566                Arc::new(log)
567            }
568            Err(e) => {
569                tracing::error!(
570                    error = %e,
571                    event = "download_receipt_log_open_failed",
572                    "failed to open download-receipt log; continuing without the audit log \
573                     (paid delivery is unaffected; revenue reconciliation will be incomplete)",
574                );
575                Arc::new(crate::receipt_log::NoopReceiptLog)
576            }
577        };
578
579    // Decouple the audit write from the paid-delivery hot path (#803): a single
580    // background task owns the receipt log and drains a bounded queue, so the
581    // voucher-accept path only does a non-blocking enqueue before it continues
582    // delivery and a slow/full disk can never back-pressure delivery. The
583    // writer's stop handle is cancelled after the router drains on shutdown
584    // (below) so the writer flushes its tail before exiting.
585    let (receipt_sink, receipt_writer) =
586        crate::receipt_log::spawn_receipt_writer(receipt_log, Arc::clone(&node_metrics));
587
588    // Background lane-store flush (ADR 003 §Off-chain voucher state persistence):
589    // mirror the in-memory voucher watermark to disk every
590    // `payment.voucher_commit_interval_ms`. A failed flush retains the dirty set
591    // for the next tick; it never blocks delivery.
592    let flush_store = Arc::clone(&channel_state_store);
593    let flush_metrics = Arc::clone(&node_metrics);
594    let flush_interval =
595        std::time::Duration::from_millis(cfg.payment.voucher_commit_interval_ms.max(1));
596    let lane_flush_task = tokio::spawn(async move {
597        let mut ticker = tokio::time::interval(flush_interval);
598        ticker.tick().await; // skip the immediate first tick
599        loop {
600            ticker.tick().await;
601            let store = Arc::clone(&flush_store);
602            let res = tokio::task::spawn_blocking(move || store.flush()).await;
603            match res {
604                Ok(Ok(())) => {}
605                Ok(Err(err)) => {
606                    flush_metrics.lane_flush_failure();
607                    tracing::warn!(error = %err, "lane store background flush failed; retrying next tick");
608                }
609                Err(join_err) => {
610                    flush_metrics.lane_flush_failure();
611                    tracing::warn!(%join_err, "lane store flush task join failed");
612                }
613            }
614        }
615    });
616
617    // Node-to-node pull-through origin (#831, ADR 001/022). Constructed empty up
618    // front so it can be appended to the cache's origin chain here; its
619    // dependencies (DHT, buyer channel, reputation handles) don't exist yet and
620    // are injected via `provision` once bring-up completes (below). When the
621    // feature is off it is never created and never enters the chain. The handle
622    // is retained to provision later.
623    let node_origin = cfg
624        .cache
625        .node_to_node_pull_through_enabled
626        .then(crate::node_origin::NodeOrigin::new);
627    // A shared `Arc<NodeOrigin>` handle for the window-paced serve path (#856).
628    // `NodeOrigin` is `Clone` over an `Arc<OnceLock<deps>>`, so this clone sees
629    // the dependencies `provision`ed (below) on the chain's copy. Held so the
630    // client handler can drive progressive pulls directly, bypassing the buffered
631    // `populate` for the serve-miss pull-through path.
632    let pull_through_origin = node_origin.clone().map(Arc::new);
633    let cache = build_cache(
634        cfg,
635        Arc::clone(&node_metrics),
636        node_origin.clone().map(|o| Arc::new(o) as Arc<dyn Origin>),
637    )
638    .await?;
639    // Attach the cache to the reload state so SIGHUP handlers can swap
640    // the pinned-hashes set atomically (#276). Done immediately after
641    // `build_cache` succeeds so a SIGHUP delivered during the rest of
642    // startup will still find a target.
643    reload_state.attach_cache(Some(cache.clone()));
644    // `decdn_probe_hold_slots_used` samples this cache on every scrape.
645    node_metrics.attach_probe_holds(cache.clone());
646
647    // Admission/eviction policy selection (ADR 040) plus the ADR 041 estimator
648    // decoupling (see `wire_cache_policies`).
649    let (eviction_policy, estimator) = wire_cache_policies(&cfg.cache, &cache);
650
651    let retry = cfg.cache.origin_retry;
652    tracing::info!(
653        cache_dir = %cfg.cache.cache_dir.display(),
654        has_origin = !cfg.cache.origins.is_empty(),
655        origin_count = cfg.cache.origins.len(),
656        origin_kinds = %origin_kinds_label(&cfg.cache.origins),
657        pinned_hashes = cfg.cache.pinned_hashes.len(),
658        // Origin retry policy (#285). Logged once at startup so operators
659        // can audit the active resilience budget without hitting an RPC.
660        retry_max_retries = retry.max_retries,
661        retry_initial_backoff_ms = retry.initial_backoff_ms,
662        retry_max_backoff_ms = retry.max_backoff_ms,
663        retry_jitter_ratio = retry.jitter_ratio,
664        "cache engine ready",
665    );
666
667    let transport_config =
668        quic_transport_config().context("failed to build QUIC transport config")?;
669    let ep = build_endpoint(
670        &secret_key,
671        cfg.network.bind_port,
672        &cfg.network.relay_urls,
673        &cfg.network.discovery,
674        transport_config,
675    )
676    .await
677    .context("failed to build iroh endpoint")?;
678
679    node_metrics
680        .register_iroh_endpoint(&ep)
681        .context("failed to register iroh metrics")?;
682
683    let limiter = Arc::new(ConnectionLimiter::new(
684        &cfg.security,
685        Arc::clone(&node_metrics),
686    ));
687    // Attach the limiter to the reload state so SIGHUP / admin reloads
688    // can forward `[security]` changes via `ConnectionLimiter::reload`
689    // (#235). Done immediately after construction so a SIGHUP delivered
690    // during the rest of startup still finds a target.
691    reload_state.attach_limiter(Some(Arc::clone(&limiter)));
692    reload_state.attach_metrics(Arc::clone(&node_metrics));
693
694    Ok(Infra {
695        node_metrics,
696        secret_key,
697        eth_signer,
698        payment_pool_deployment: channel_store_deployment,
699        concrete_channel_store,
700        channel_state_store,
701        watcher_checkpoint_store,
702        receipt_sink,
703        receipt_writer,
704        lane_flush_task,
705        node_origin,
706        pull_through_origin,
707        cache,
708        eviction_policy,
709        frequency_estimator: estimator,
710        ep,
711        limiter,
712    })
713}
714
715/// By-value bundle of the endpoint, handlers, limiter, and blacklist
716/// readiness receiver consumed when [`serve_until_shutdown`] builds the router.
717/// Every field is moved into the router builder (or its gate), so none is
718/// referenced by [`run`] after the serve call returns.
719struct ServeInputs {
720    ep: Endpoint,
721    probe_handler: Arc<ProbeHandler>,
722    client_handler: Arc<ClientHandler>,
723    dht_handler: Arc<DhtHandler>,
724    blacklist_ready_rx: oneshot::Receiver<crate::blacklist_watcher::InitialSyncResult>,
725}
726
727/// Serve phase of [`run`] (#1253): build the paid-delivery router behind the
728/// fail-closed blacklist gate, install the signal + SIGHUP streams, and run the
729/// select loop until a shutdown signal arrives. Returns the live [`Router`] and
730/// the observed [`ShutdownSignal`] so [`run`] can drive the teardown sequence.
731/// `drain_trigger` is borrowed, not consumed, because [`run`] still passes it to
732/// [`shutdown`] afterwards.
733#[allow(clippy::cognitive_complexity)]
734async fn serve_until_shutdown(
735    reload_state: &RuntimeReloadState,
736    config_path: Option<&Path>,
737    drain_trigger: &Arc<admin::DrainTrigger>,
738    serve_in: ServeInputs,
739) -> anyhow::Result<(Router, ShutdownSignal)> {
740    let ServeInputs {
741        ep,
742        probe_handler,
743        client_handler,
744        dht_handler,
745        blacklist_ready_rx,
746    } = serve_in;
747
748    // No Probe, Client, or DHT ALPN is registered before the mandatory first
749    // global + operator-region blacklist enumeration and enforcement pass
750    // succeeds. That pass runs inline in `build_chain_and_handlers`, before the
751    // metrics and admin listeners bind, and has always resolved by the time
752    // control reaches this gate; a boot that retries it has nothing listening
753    // yet. The gate is the structural seam: the `Router` cannot be built on a
754    // pending or failed sync. A *failed* initial sync is fail-closed the hard
755    // way: the gate returns `Err`, `run` propagates it, and the process exits
756    // rather than ever serving un-vetted content.
757    let router = gate_listener_on_blacklist_sync(blacklist_ready_rx, || {
758        Router::builder(ep.clone())
759            .accept(ProbeHandler::ALPN, probe_handler)
760            .accept(ClientProtocol::ALPN, ClientProtocol::new(client_handler))
761            .accept(DhtHandler::ALPN, dht_handler)
762            .spawn()
763    })
764    .await?;
765
766    // Install signal streams once, before entering the select loop.
767    // tokio docs are explicit that `Signal::recv` is the supported way
768    // to await repeated signals, and re-creating the stream per signal
769    // is not — see `tokio::signal::unix::signal` for the reasoning.
770    let mut shutdown_streams = ShutdownStreams::install();
771    let mut hup_stream = HupStream::install();
772
773    let signal = loop {
774        tokio::select! {
775            sig = shutdown_streams.recv() => break sig,
776            () = hup_stream.recv() => {
777                match config_path {
778                    Some(path) => {
779                        if let Err(err) = reload_state.reload(path).await {
780                            tracing::warn!(error = %err, "config reload error");
781                        }
782                    }
783                    None => {
784                        tracing::warn!(
785                            "SIGHUP received but no config file path is in use; ignoring"
786                        );
787                    }
788                }
789            }
790            () = drain_trigger.wait() => {
791                tracing::info!("admin_v1_drain received; initiating graceful shutdown");
792                break ShutdownSignal::AdminDrain;
793            }
794        }
795    };
796    tracing::info!(signal = %signal, "shutdown signal received; closing router");
797
798    Ok((router, signal))
799}
800
801/// Chain providers, event watchers, and paid-delivery handlers built during the
802/// middle phase of [`run`] (#1253). A by-value bundle of the long-lived handles
803/// the background-task, serve, and shutdown phases consume, mirroring [`Infra`].
804/// Generic over the seller-wallet provider `P` (an opaque `impl Provider`
805/// threaded through [`PoolSettlementService`]); [`run`] infers `P` at the call
806/// site and hands it to [`ShutdownHandles`].
807struct ChainHandlers<P: Provider + Clone + 'static> {
808    providers: ProviderFactory,
809    event_poll_interval: Duration,
810    slash_domain: alloy::dyn_abi::Eip712Domain,
811    bind_domain: alloy::dyn_abi::Eip712Domain,
812    staker_set: Arc<dyn StakerSet>,
813    /// The single multiplexed chain-event poller driving every registered
814    /// watcher route — six with the fee-shares route, which registers only
815    /// when the startup `feeRouter()` read succeeds.
816    /// Stopped once, late in shutdown (after `router.shutdown`), because the
817    /// capacity-bond route's staker set gates DHT admission through drain.
818    poller: crate::chain_events::resumable_watcher::WatcherHandle,
819    slash_store: crate::slash_watcher::SlashStore,
820    node_address_resolver: Option<Arc<dyn crate::dht::NodeAddressResolver>>,
821    probe_rate_limiter: Arc<ProbeRateLimiter>,
822    probe_handler: Arc<ProbeHandler>,
823    dht_rate_limiter: Arc<DhtRateLimiter>,
824    record_store: Arc<std::sync::Mutex<RecordStore>>,
825    origin_directory: Arc<dyn crate::dht::origin::OriginDirectory>,
826    dht_handler: Arc<DhtHandler>,
827    dht_routing: Arc<std::sync::Mutex<crate::dht::RoutingTable>>,
828    /// The `CapacityBond` registry's `NodeId → regionHint` projection (ADR 030),
829    /// threaded to the node-origin pull path for the region-latency penalty.
830    registry_regions: Arc<std::sync::RwLock<std::collections::HashMap<crate::dht::NodeId, String>>>,
831    client_handler: Arc<ClientHandler>,
832    /// Shared with the reload state (`attach_load_shed`) and the egress-EWMA
833    /// sampling tick spawned in [`spawn_background_tasks`], so all three see
834    /// the same live policy.
835    shed_controller: Arc<crate::load_shed::LoadShedController>,
836    payment_service: PoolSettlementService<P>,
837    blacklist_ready_rx: oneshot::Receiver<crate::blacklist_watcher::InitialSyncResult>,
838    /// Bring-up node-id binding self-check (#1034), carried through to
839    /// `AdminState` so `admin_v1_health` can report it. A plain value, not a
840    /// handle: the binding only moves by an explicit operator transaction, so
841    /// there is nothing live to keep.
842    binding_report: crate::binding_check::BindingReport,
843    /// ADR 041 buy-side profitability gate policy, selected from
844    /// `cache.serve_economics.policy`.
845    serve_economics: Arc<dyn crate::serve_economics::ServeEconomicsPolicy>,
846    /// Live operator fee-share (basis points) cell, seeded from chain at
847    /// startup and kept current by the fee-shares watcher.
848    operator_shares: crate::fee_shares::OperatorShares,
849    /// ADR 041 per-source warming allowance. The SAME `Arc` the eviction path
850    /// holds (to forget a dropped hash's tag) and the warming-credit aggregator
851    /// applies serve credits to, threaded to
852    /// [`crate::node_origin::NodeOriginConfig`] for the buy-side debit. The
853    /// client handler reaches it only through a
854    /// [`crate::warming_allowance::WarmingCreditSink`], never directly.
855    warming: Arc<crate::warming_allowance::WarmingAllowance>,
856    /// Stop handle for the ADR 041 warming-credit aggregator. Cancelled after
857    /// the router drains on shutdown so the aggregator flushes the credits
858    /// already queued by in-flight serves, then awaited — held rather than
859    /// detached so a task that died shows up as a join error instead of as
860    /// credits that quietly stop landing.
861    warming_creditor: StopHandle,
862}
863
864/// The chain-event poller builder with its config-driven settings: the
865/// `eth_getLogs` block-span ceiling (`blockchain.get_logs_max_block_span`) and
866/// the span, range-rejection and window-retry metrics. Routes are added by the
867/// caller.
868fn chain_poller_builder(
869    head: Arc<dyn HeadSource>,
870    poll_interval: Duration,
871    blockchain: &decdn_common::config::ResolvedBlockchain,
872    node_metrics: &Arc<metrics::Metrics>,
873) -> crate::chain_events::multiplexed_poller::MultiplexedPollerBuilder {
874    let span_metrics = Arc::clone(node_metrics);
875    crate::chain_events::multiplexed_poller::MultiplexedPollerBuilder::new(head, poll_interval)
876        .max_backfill_span(blockchain.get_logs_max_block_span)
877        .on_span_change(Box::new(move |span| span_metrics.chain_get_logs_span(span)))
878        .on_range_rejection(metrics::metric_hook(
879            node_metrics,
880            metrics::Metrics::chain_get_logs_range_rejected,
881        ))
882        .on_window_retry(metrics::metric_hook(
883            node_metrics,
884            metrics::Metrics::chain_get_logs_retried,
885        ))
886        .on_window_deferred(metrics::metric_hook(
887            node_metrics,
888            metrics::Metrics::chain_get_logs_deferred,
889        ))
890}
891
892/// Middle phase of [`run`] (#1253): parse the chain contract addresses and
893/// EIP-712 domains, bootstrap the `CapacityBond` registry / slash / origin
894/// watchers, and construct the probe, client, and DHT handlers plus the
895/// seller-side [`PoolSettlementService`]. Borrows [`Infra`]; the buyer-side
896/// provider/store construction and the background tasks belong to
897/// `spawn_background_tasks`. Returns [`ChainHandlers`], whose seller-wallet provider `P` [`run`]
898/// infers and threads into [`ShutdownHandles`].
899#[allow(clippy::cognitive_complexity, clippy::too_many_lines)]
900async fn build_chain_and_handlers(
901    cfg: &ResolvedConfig,
902    reload_state: &RuntimeReloadState,
903    infra: &Infra,
904) -> anyhow::Result<ChainHandlers<impl Provider + Clone + 'static>> {
905    // SlashJudge EIP-712 domain for probe `slash_sig` (ADR 014 §1–2). Parsed
906    // through the shared zero-address guard (#1219) for a uniform "every daemon
907    // contract address is non-zero" invariant. `resolve_blockchain` already
908    // rejects a zero or malformed `slash_judge_address` at config load, so here
909    // the guard is defense-in-depth rather than the sole check.
910    let slash_judge_addr = parse_nonzero_address(
911        &cfg.blockchain.slash_judge_address,
912        "blockchain.slash_judge_address",
913    )?;
914    let slash_domain =
915        decdn_incentive::slash_judge_domain(cfg.blockchain.chain_id, slash_judge_addr);
916
917    // Chain-backed active-staker set. Bootstrap failure is fatal on a
918    // deterministic fault or once its retries are exhausted (see the boot
919    // budget below): an
920    // empty set silently rejects every inbound `Store`, and once the
921    // iterative `FindValue` lookup filter exists it would drop every
922    // responder. Built here (ahead of the probe handler) so the probe
923    // handler can consult it for stake-lane probe-acceptance (#757); the
924    // DHT handler below shares the same `Arc`.
925    let rpc_url: HttpUrl = cfg.blockchain.rpc_url.parse().with_context(|| {
926        format!(
927            "blockchain.rpc_url {:?} is not a valid URL",
928            cfg.blockchain.rpc_url
929        )
930    })?;
931    let capacity_bond_addr = parse_nonzero_address(
932        &cfg.blockchain.capacity_bond_address,
933        "blockchain.capacity_bond_address",
934    )?;
935    // Mandatory for paid delivery. Config resolution rejects absence; this
936    // runtime guard preserves fail-closed behavior for directly constructed
937    // `ResolvedConfig` values as well.
938    let content_blacklist_addr = parse_nonzero_address(
939        cfg.blockchain
940            .content_blacklist_address
941            .as_deref()
942            .context("missing mandatory blockchain.content_blacklist_address")?,
943        "blockchain.content_blacklist_address",
944    )?;
945    let providers = ProviderFactory::new(rpc_url, Arc::clone(&infra.node_metrics));
946    // One value, two consumers (#1011/#1106): the multiplexed poller's merged
947    // `eth_getLogs` tick cadence, and — through `with_poll_interval` below — the
948    // pending-tx receipt heartbeat, overriding alloy's 250 ms localhost default
949    // that would hammer a dev anvil.
950    let event_poll_interval = Duration::from_millis(cfg.blockchain.event_poll_interval_ms);
951    let chain_provider = providers.read_only(event_poll_interval);
952    // One `eth_blockNumber` per TTL window for ALL watchers, instead of one per
953    // watcher per tick. Plain read-only provider: a head read needs no wallet or
954    // nonce filler, and coupling it to the signer stack would give every watcher's
955    // head read a dependency on it. Deliberately NOT wrapped in
956    // `with_poll_interval` — that only sets the pending-tx receipt heartbeat, and
957    // this provider never builds a `PendingTransactionBuilder`.
958    let head: Arc<dyn HeadSource> = Arc::new(SharedHead::new(
959        providers.shared_head(),
960        event_poll_interval,
961    ));
962    // Every on-chain watcher registers a `Route` on ONE shared multiplexed
963    // poller instead of running its own `eth_getLogs` loop: six watchers scan
964    // disjoint `(address, topic0)` slices of the same four contracts, so one
965    // merged `get_logs` per tick — demuxed after the fact — replaces six. The
966    // routes are collected as each watcher bootstraps below and spawned once (see
967    // `build`/`spawn` near the end of this function). The poller mints one
968    // shutdown token; the runtime drives graceful stop through the single handle.
969    let mut poller_routes: Vec<crate::chain_events::multiplexed_poller::Route> = Vec::new();
970    // One read-only provider for the poller's merged `get_logs` (each sink still
971    // holds its own contract instance for its follow-up reads/writes).
972    let poller_provider = providers.read_only(event_poll_interval);
973
974    // One boot deadline for the four fail-closed chain bootstraps below — the
975    // registry, slash, `usdc()` self-check and blacklist reads (#2159). Each
976    // retries its reads on transient provider errors and fails only on a
977    // deterministic fault or once the shared deadline passes. The best-effort
978    // fee-share reads retry on a capped sub-budget of it and fall back to the
979    // floor share instead of failing boot; the binding check does not use it.
980    // Startup stays fail-closed: a retry delays readiness, and the ALPN router
981    // waits on the blacklist enumeration and enforcement.
982    let boot = BootRetry::new(BOOT_CHAIN_RETRY_BUDGET, Arc::clone(&infra.node_metrics));
983
984    // One CapacityBond enumeration + one route feeding both registry projections
985    // (#1110). The bindings half is built only when pull-through is on; it derives
986    // from page data already read here, so it has no RPC that can fail on its own.
987    let registry = crate::dht::capacity_bond_registry::bootstrap(
988        chain_provider,
989        capacity_bond_addr,
990        Arc::clone(&head),
991        cfg.cache.node_to_node_pull_through_enabled,
992        Arc::clone(&infra.node_metrics),
993        &boot,
994    )
995    .await
996    .with_context(|| format!("CapacityBond registry bootstrap at {capacity_bond_addr}"))?;
997    let staker_set: Arc<dyn StakerSet> = registry.staker_set;
998    let registry_regions = Arc::clone(&registry.regions);
999    // The shared `operator address → NodeId` reverse projection (#1110), kept
1000    // current by the same registry route. The lazy `ChainOriginDirectory`
1001    // resolves against it directly rather than maintaining its own binding cache.
1002    let operator_to_node = Arc::clone(&registry.operator_to_node);
1003    // Registry multiaddrs as an endpoint address lookup (ADR 001 § Node
1004    // Discovery): every bare-`NodeId` dial resolves the node's registry
1005    // addresses alongside its pkarr record, so a peer stays directly dialable
1006    // when the pkarr server is unreachable or its record is not yet published.
1007    // The same registry route keeps the lookup current.
1008    infra
1009        .ep
1010        .address_lookup()
1011        .map_err(|e| anyhow::anyhow!("endpoint address lookup unavailable: {e}"))?
1012        .add(registry.dial_addrs.lookup());
1013    let own_id = crate::dht::routing::NodeId::from_bytes(*infra.ep.id().as_bytes());
1014    reachability::report(
1015        &infra.node_metrics,
1016        cfg.network.bind_port,
1017        &registry.dial_addrs.get(&own_id).unwrap_or_default(),
1018    );
1019    // The registry route MUST stay live through drain (its staker set gates DHT
1020    // admission), which forces the single poller stop to the LATE point below.
1021    poller_routes.push(registry.route);
1022
1023    // Slash-detection watcher (#1032, G-NODE-05): enumerate this operator's
1024    // still-appealable slashes from `CapacityBond` and follow `SlashRecorded`, so
1025    // a slash surfaces over `admin_v1_slashes` (+ the `decdn_slashes_detected_total`
1026    // metric) and the operator can file `decdn appeal slash` in time. Read-only;
1027    // held to `run()`'s end so its background task lives as long as the daemon.
1028    // The boot enumeration retries on the shared boot budget, like the
1029    // CapacityBond registry bootstrap above; thereafter a tail blip retries and
1030    // the periodic resync heals drift, so detection is never disabled for the
1031    // daemon's lifetime.
1032    let (slash_store, slash_route) = crate::slash_watcher::bootstrap(
1033        providers.read_only(event_poll_interval),
1034        capacity_bond_addr,
1035        infra.eth_signer.address(),
1036        Arc::clone(&head),
1037        Arc::clone(&infra.node_metrics),
1038        &boot,
1039    )
1040    .await
1041    .context("bootstrap the slash-detection watcher")?;
1042    poller_routes.push(slash_route);
1043
1044    // NodeId → bonded operator address resolver for node-to-node pulls (#831),
1045    // produced by the same CapacityBond bootstrap as the staker set above and
1046    // `Some` exactly when pull-through is enabled. Held to provision the
1047    // `NodeOrigin` below.
1048    let node_address_resolver: Option<Arc<dyn crate::dht::NodeAddressResolver>> =
1049        if cfg.cache.node_to_node_pull_through_enabled {
1050            registry.node_addresses
1051        } else {
1052            None
1053        };
1054
1055    // Stake-lane probe-acceptance reservation (#757, ADR 003 §Admission and
1056    // Priority). Strictly operator opt-in: a policy is built only for a
1057    // non-zero `cache.stake_lane_reserved_holds` (the `NonZeroUsize` gate
1058    // makes the "off" case unrepresentable in `StakeLanePolicy`), so the
1059    // default single-lane node passes `None` and the probe handler's hot
1060    // path is unchanged. A reservation paired with disabled holds
1061    // (`max_probe_holds == 0`) can never fire — surface that misconfig as a
1062    // warning and leave the lane off rather than wire a dead per-probe lookup.
1063    let stake_lane_policy =
1064        NonZeroUsize::new(cfg.cache.stake_lane_reserved_holds).and_then(|reserved| {
1065            if cfg.cache.max_probe_holds == 0 {
1066                tracing::warn!(
1067                    reserved_holds = reserved.get(),
1068                    "cache.stake_lane_reserved_holds is set but cache.max_probe_holds=0; \
1069                     the stake-lane reservation has no effect (probe holds are disabled)"
1070                );
1071                None
1072            } else {
1073                Some(ProbeStakeLanePolicy::new(
1074                    Arc::clone(&staker_set),
1075                    reserved,
1076                    cfg.cache.max_probe_holds,
1077                ))
1078            }
1079        });
1080
1081    // ADR 005 §Probe rate limiting three-layer token-bucket limiter for
1082    // `cdn/probe/v1`. Built from `[probe.rate_limit]` and run *in addition* to
1083    // the shared `ConnectionLimiter` (see `probe_rate_limit` module docs); the
1084    // per-peer (NodeId) layer it adds is the gap #982 closed.
1085    // `from_resolved` rather than `new`: both limiter configs are aliases of
1086    // the same struct, so passing `&cfg.dht` here would compile (#1457).
1087    let probe_rate_limiter = Arc::new(ProbeRateLimiter::from_resolved(
1088        &cfg.probe,
1089        Arc::clone(&infra.node_metrics),
1090    ));
1091
1092    // Chain-read liveness (ADR 011 §Serving while chain-stale). One handle,
1093    // stamped by the blacklist watcher (boot enumeration + every successful poll
1094    // tick) and read by the probe and serve paths, which refuse once the node
1095    // has been unable to reach the chain for longer than the grace window. This
1096    // runtime path always wires the blacklist watcher — `content_blacklist_address`
1097    // is mandatory at node config resolution — so the gate is always live here;
1098    // the `Option` the handlers hold exists for the test paths that build a
1099    // handler with no chain.
1100    let chain_freshness = crate::chain_freshness::ChainFreshness::new(Duration::from_secs(
1101        cfg.blockchain.chain_staleness_grace_sec,
1102    ));
1103
1104    let probe_handler = Arc::new(ProbeHandler::new(
1105        infra.secret_key.public(),
1106        cfg.payment.rate_per_mb,
1107        Arc::clone(&infra.node_metrics),
1108        Arc::clone(&infra.limiter),
1109        Arc::clone(&probe_rate_limiter),
1110        infra.cache.clone(),
1111        Arc::clone(&infra.eth_signer),
1112        slash_domain.clone(),
1113        stake_lane_policy,
1114        cfg.cache.relay_foreign_namespaces,
1115        Some(chain_freshness.clone()),
1116    ));
1117
1118    // `cdn/dht/v1` handler (ADR 022 / #320): serves FindNode, FindValue, and
1119    // Store. Bootstrap, bucket refresh, and the republish scheduler run as
1120    // background tasks below. Three-layer rate limiter operates at the full
1121    // ADR 022 spec.
1122    let dht_rate_limiter = Arc::new(DhtRateLimiter::from_resolved(
1123        &cfg.dht,
1124        Arc::clone(&infra.node_metrics),
1125    ));
1126    // Record store sized from the ADR 022 defaults; per-publisher /
1127    // global / per-hash caps are pinned by the protocol and only the
1128    // TTL field is plausibly operator-tunable, but no knob is exposed
1129    // yet — operators with non-default needs should file a follow-up
1130    // rather than tune in TOML.
1131    let record_store = Arc::new(std::sync::Mutex::new(RecordStore::new(
1132        RecordStoreConfig::default(),
1133    )));
1134    // Origin directory for the node-origin FIND_VALUE reads — the last-resort
1135    // fallback when the DHT returns no providers, and the supplement when a ranged
1136    // pull's probed holders do not span the blob (ADR 022 §FIND_VALUE Flow; #912,
1137    // #2195) — and for the reactive pull-through gate. When the operator configures the
1138    // OriginAssignment + PublisherRegistry addresses, use the chain-backed
1139    // `ChainOriginDirectory` — a lazy TTL cache resolving hash → namespace →
1140    // authorized origin → active NodeId on demand, reusing the
1141    // already-bootstrapped `staker_set` and `operator_to_node` reverse
1142    // projection for operator liveness and binding. No bootstrap RPC: the
1143    // cache populates on the first lookup miss per namespace.
1144    // Without those addresses this is an `EmptyOriginDirectory`: the FIND_VALUE
1145    // fallback resolves nothing.
1146    let origin_directory: Arc<dyn crate::dht::origin::OriginDirectory> =
1147        if let Some(origin_addr) = cfg.blockchain.origin_assignment_address.as_deref() {
1148            let origin_assignment_addr =
1149                parse_nonzero_address(origin_addr, "blockchain.origin_assignment_address")?;
1150            Arc::new(crate::dht::ChainOriginDirectory::new(
1151                providers.read_only(event_poll_interval),
1152                origin_assignment_addr,
1153                Arc::clone(&operator_to_node),
1154                Arc::clone(&staker_set),
1155                reload_state.content_denylist(),
1156                cfg.blockchain.origin_directory_cache_capacity,
1157                Duration::from_secs(cfg.blockchain.origin_directory_positive_ttl_sec),
1158                Duration::from_secs(cfg.blockchain.origin_directory_negative_ttl_sec),
1159                Arc::clone(&infra.node_metrics),
1160            ))
1161        } else {
1162            Arc::new(crate::dht::origin::EmptyOriginDirectory)
1163        };
1164    let dht_handler = Arc::new(DhtHandler::new(
1165        infra.secret_key.public(),
1166        Arc::clone(&dht_rate_limiter),
1167        Arc::clone(&infra.limiter),
1168        Arc::clone(&infra.node_metrics),
1169        Arc::clone(&staker_set),
1170        Arc::clone(&record_store),
1171    ));
1172
1173    // The DHT handler builds its own routing table internally; grab a
1174    // shared handle so the bootstrap path + republish + bucket-refresh
1175    // tasks can all operate on the same instance.
1176    let dht_routing = dht_handler.routing_table();
1177
1178    // `cdn/client/v1` paid-delivery handler (#317). The voucher EIP-712 domain
1179    // binds to the `PaymentPool` deployment the lane store is stamped with
1180    // (`infra.payment_pool_deployment` — one parse, one source of truth); the
1181    // ephemeral-binding domain to the `CapacityBond` deployment
1182    // (== `capacity_bond_addr`, which holds the NodeId↔address mappings). The
1183    // handler hydrates per-channel voucher state from `channel_state_store` so
1184    // a restart cannot replay an already-accepted voucher (#527).
1185    let payment_pool_addr = infra.payment_pool_deployment.payment_pool;
1186
1187    // Live operator fee-share seed (ADR 041 / ADR 016 § Tunable Economics):
1188    // `PaymentPool.feeRouter()` resolves the router address, then
1189    // `FeeRouter.getShares()` reads the current 3-way split, narrowed to the
1190    // operator's bps. Unlike the fail-closed reads, NO leg of this chain is fatal
1191    // to boot: a failure falls back to `FEE_ROUTER_OPERATOR_BPS_FLOOR` and the
1192    // node still comes up. Both reads retry transient errors; the `feeRouter()`
1193    // retry matters most, because `feeRouter` is immutable and a missed read
1194    // leaves the `SharesUpdated` route below unregistered until restart. They
1195    // retry on a `FEE_SHARE_BOOT_BUDGET` sub-budget of the boot deadline, so a
1196    // flapping provider cannot spend the time the later `usdc()` and blacklist
1197    // reads need.
1198    let crate::fee_shares::FeeShareSeed {
1199        router: fee_router_addr,
1200        operator_bps: seed_operator_bps,
1201    } = crate::fee_shares::seed_from_chain(
1202        providers.read_only(event_poll_interval),
1203        payment_pool_addr,
1204        FEE_ROUTER_OPERATOR_BPS_FLOOR,
1205        &boot.capped(FEE_SHARE_BOOT_BUDGET),
1206    )
1207    .await;
1208    infra
1209        .node_metrics
1210        .fee_shares_watcher_unregistered(fee_router_addr.is_none());
1211    let operator_shares = crate::fee_shares::OperatorShares::new(seed_operator_bps);
1212    tracing::info!(
1213        bps = seed_operator_bps,
1214        "seeded live operator fee share for serve-economics"
1215    );
1216
1217    // ADR 041 buy-side profitability gate for cache-miss relay legs. Node-local,
1218    // config-selectable; `off` disables the economic ceiling entirely.
1219    let serve_economics = crate::serve_economics::select_policy(&cfg.cache.serve_economics);
1220
1221    // ADR 041 per-source warming allowance, bounding the loss from speculative
1222    // above-floor buys per upstream source. Built once and shared by the buy loop
1223    // (debit), the serve path (credit realized margin), and the eviction path
1224    // (forget a dropped hash's tag).
1225    let warming = Arc::new(crate::warming_allowance::WarmingAllowance::new(
1226        cfg.cache.serve_economics.warming_budget,
1227        cfg.cache.serve_economics.warming_refill,
1228    ));
1229
1230    // Bring-up self-check: is the key we are about to serve under the one bound
1231    // to this operator on-chain? A node that answers "no" is UNSLASHABLE
1232    // (`SlashJudge` resolves the accused through `nodeIdOf`), and nothing else
1233    // in the runtime would notice — so the check is here rather than left to an
1234    // operator to think of. Advisory: it never blocks startup, because a daemon
1235    // that refused to boot on a transient RPC failure could not be used to run
1236    // the rotation that repairs the binding. See `crate::binding_check`.
1237    let binding_report = crate::binding_check::check(
1238        providers.read_only(event_poll_interval),
1239        capacity_bond_addr,
1240        infra.eth_signer.address(),
1241        *infra.secret_key.public().as_bytes(),
1242    )
1243    .await;
1244
1245    let voucher_domain = infra.payment_pool_deployment.voucher_domain();
1246    let bind_domain =
1247        decdn_incentive::bind_node_id_domain(cfg.blockchain.chain_id, capacity_bond_addr);
1248
1249    // Reactive LOCAL-origin pull-through (#1116). Arm a local-only populate on the
1250    // serve-miss path whenever the operator configured any origin (`[cache.origin]`),
1251    // INDEPENDENT of `node_to_node_pull_through_enabled`: a cache-only operator must
1252    // be able to reactively serve content it holds in its OWN fs/http/s3 origin, and
1253    // — with node→node on — that local origin is preferred over the paid peer window
1254    // path. The handler still gates it on proven channel ownership
1255    // (`pull_authorized`), so it fronts no free egress, and `populate_local` never
1256    // consults the paid `Peer` node→node origin. The node→node
1257    // window/buffered/governor/gate paths below stay flag-gated.
1258    //
1259    // A single local origin-chain walk (fs/http/s3), NOT a provider fan-out — so
1260    // budget it at the per-attempt `node_pull_timeout_sec`, not `outer_pull_deadline`
1261    // (which budgets the sequential node→node pull). Using the outer deadline would
1262    // let a wedged local origin block many times longer (>7× at defaults) before
1263    // falling through to the node→node paths.
1264    let local_populate = (!cfg.cache.origins.is_empty())
1265        .then(|| Duration::from_secs(cfg.cache.node_pull_timeout_sec));
1266
1267    // Node→node pull-through deps (#831/#856/#821), all gated on
1268    // `node_to_node_pull_through_enabled`. Computed as `Option`s here so the handler's
1269    // wiring is one construction-time literal; the gates (and their nesting) are
1270    // preserved exactly.
1271    let mut pull_through = None;
1272    let mut pull_through_origin = None;
1273    if cfg.cache.node_to_node_pull_through_enabled {
1274        // The outer deadline bounds how long a miss blocks the delivery path before
1275        // falling back to `NotFound`. It is *derived* from the per-candidate budget
1276        // (`node_pull_timeout_sec`) rather than equal to it: the handler wraps the
1277        // whole `discover → probe → rank → pull` fetch in one `tokio::time::timeout`,
1278        // so an outer deadline equal to the per-candidate timeout would cancel the
1279        // fetch the instant candidate #1 stalls, before the `MAX_PROVIDER_ATTEMPTS`
1280        // fallback loop ever reaches candidates #2..N (#859). `outer_pull_deadline`
1281        // budgets all three of each candidate's sequential stages — channel open,
1282        // stream open, and one silent-streaming window — plus one-time discovery
1283        // slack. Whether the pull can actually succeed additionally depends on the
1284        // `NodeOrigin` being provisioned; an unprovisioned origin just makes the
1285        // `get` a fast miss.
1286        let per_candidate = Duration::from_secs(cfg.cache.node_pull_timeout_sec);
1287        let stall = Duration::from_secs(cfg.cache.node_pull_stall_window_sec);
1288        let outer_deadline = crate::selection::outer_pull_deadline(per_candidate, stall);
1289        pull_through = Some(outer_deadline);
1290        // Window-paced pull-through (#856, ADR 037): when the `NodeOrigin` is
1291        // available, serve cache misses by fusing the upstream pull with downstream
1292        // delivery, paced by the ramped credit window (ADR 003 §Credit window,
1293        // #1669) instead of the buffered `populate`.
1294        if let Some(origin) = &infra.pull_through_origin {
1295            pull_through_origin = Some(Arc::clone(origin));
1296        }
1297    }
1298
1299    // ADR 011 deny-set. Taken from the reload state rather than built here, so
1300    // the handler and the SIGHUP / `decdn node reload` path hold the SAME `Arc`
1301    // — a denylist entry added to the config file takes effect on reload with no
1302    // handler rebuild and no restart, which is what makes it usable against a
1303    // one-hour statutory clock. It is a compliance gate, so the deps literal
1304    // below names it like every other field: a wiring site that drops it fails
1305    // to compile rather than silently degrading to "deny nothing".
1306    let content_denylist = reload_state.content_denylist();
1307    // Redeem-hint channel (#327), created outside `PoolSettlementService::bootstrap`
1308    // so the sender can be cloned into the handler deps below while the service
1309    // takes the receiver. `redeem_tx` is cloned into the handler deps and also
1310    // handed to the service (so `redeem_hint_sender()` keeps working); `redeem_rx`
1311    // drives the service's redeemer loop.
1312    let (redeem_tx, redeem_rx) =
1313        tokio::sync::mpsc::channel(crate::payment_settlement::REDEEM_HINT_CAPACITY);
1314    // Overload-protection gate (load-shed): sheds new serves under resource
1315    // pressure so in-flight streams stay fast. Bound to a named local, not
1316    // inlined into the deps literal, so the reload section (`[load_shed]`,
1317    // hot-reloadable via `RuntimeReloadState::attach_load_shed`) and the
1318    // egress-EWMA sampling tick spawned in `spawn_background_tasks` both
1319    // reach the SAME controller.
1320    let shed_controller = crate::load_shed::LoadShedController::from_config(&cfg.load_shed);
1321    reload_state.attach_load_shed(Some(Arc::clone(&shed_controller)));
1322    // Coarse wall clock (issue #1792 item 4): a background task refreshes an
1323    // atomic every 500 ms so the voucher-accept path's two under-lock reads — the
1324    // capability-expiry gate and the `last_voucher_at` liveness stamp — cost a
1325    // relaxed atomic load instead of a `SystemTime::now()` syscall inside the
1326    // per-lane critical section. The refresher holds only a `Weak`, so it
1327    // self-terminates when the handler (the clock's last owner) drops at
1328    // shutdown and needs no stop handle.
1329    let coarse_clock = Arc::new(crate::coarse_clock::CoarseClock::new());
1330    crate::coarse_clock::CoarseClock::spawn_refresher(
1331        &coarse_clock,
1332        std::time::Duration::from_millis(500),
1333    );
1334    // Event-fed pool view (owner + remaining) for the floor-`M` solvency gate and
1335    // the ADR 011 funder gate. The settlement watcher below folds every
1336    // `PaymentPool` event into this projection, so a serve request reads
1337    // `{owner, remaining}` in-memory — no per-serve `getPool` `eth_call`. The same
1338    // instance is handed to the settlement service (its watcher is the writer).
1339    let pool_view = crate::pool_view::PoolProjection::new();
1340    // Wallet-filled provider + `PaymentPool` binding, created here so the handler's
1341    // pool-view (below) can confirm a projection-miss pool with a `getPool` at
1342    // admission. The same provider is moved into the settlement service, which
1343    // builds its own binding from the same address; the buyer path builds its own
1344    // provider separately.
1345    let wallet_provider = providers.seller_wallet((*infra.eth_signer).clone(), event_poll_interval);
1346    // Admit-path pool-view: reads the event-fed projection first and, on a miss
1347    // (a pool opened before the watcher's `ColdStart::Head` anchor), does ONE
1348    // `getPool` to confirm the pool is live and solvent before a serve is admitted
1349    // — folding a servable pool into the projection and refusing an absent/closed/
1350    // errored one. The mid-stream re-check reads the projection only.
1351    let resolving_pool_view = crate::payment_settlement::ResolvingPoolView::new(
1352        decdn_incentive::payment_pool::PaymentPool::new(payment_pool_addr, wallet_provider.clone()),
1353        pool_view.clone(),
1354        Arc::clone(&infra.node_metrics),
1355    );
1356    // ADR 041 serve-credit inputs: a non-blocking sink in front of the SAME warming
1357    // allowance the buy loop debits and the eviction path forgets, plus the live
1358    // operator fee-share cell. The background aggregator behind the sink is what
1359    // keeps a serve's final step off the bucket lock the buy loop takes. Eviction
1360    // shares the allowance but not that lock — `forget` touches the tag map only.
1361    let (warming_credit, warming_creditor) = crate::warming_allowance::spawn_warming_creditor(
1362        Arc::clone(&warming),
1363        Arc::clone(&infra.node_metrics),
1364    );
1365    // One exhaustive literal, never a setter chain (#1254). Every field is named
1366    // here, so adding one to `ClientHandlerDeps` fails this call site rather than
1367    // defaulting silently, and deleting a wiring line is a missing-field error
1368    // instead of a live node that quietly runs the inert default. That matters
1369    // most for the economic hooks: a dropped `warming_credit` degrades to
1370    // `NoopWarmingCreditSink` and a dropped `operator_shares` to a zero share,
1371    // and both lose money on a real node while every test stays green.
1372    let client_deps = crate::handlers::client::ClientHandlerDeps {
1373        node_id: infra.secret_key.public(),
1374        metrics: Arc::clone(&infra.node_metrics),
1375        limiter: Arc::clone(&infra.limiter),
1376        shed: Arc::clone(&shed_controller),
1377        cache: infra.cache.clone(),
1378        eth_signer: Arc::clone(&infra.eth_signer),
1379        slash_domain: slash_domain.clone(),
1380        voucher_domain: voucher_domain.clone(),
1381        bind_domain: bind_domain.clone(),
1382        channel_state_store: Arc::clone(&infra.channel_state_store),
1383        receipt_sink: Arc::clone(&infra.receipt_sink),
1384        pool_view: Some(Arc::new(resolving_pool_view) as Arc<dyn crate::pool_view::PoolView>),
1385        pool_min_remaining_deposit: U256::from(
1386            cfg.blockchain.pool_min_remaining_deposit_micro_usdc,
1387        ),
1388        rate_per_mb: cfg.payment.rate_per_mb,
1389        max_concurrent_streams: MAX_CLIENT_STREAMS,
1390        content_deny: Arc::clone(&content_denylist),
1391        chain_freshness: Some(chain_freshness.clone()),
1392        // Hint the settlement service on each accepted voucher so a lane's
1393        // accrued claim is planned into a chunk promptly rather than waiting the
1394        // self-tick.
1395        redeem_hint: Some(redeem_tx.clone()),
1396        pull_through,
1397        local_populate,
1398        pull_through_origin,
1399        // Downstream credit-window ramp (ADR 003 §Credit window, #1477, #1669).
1400        credit_max: cfg.payment.credit_max,
1401        credit_ramp_divisor: cfg.payment.credit_ramp_divisor,
1402        frame_target_bytes: cfg.payment.frame_target_bytes,
1403        idle_timeout: None,
1404        pool_recheck_interval: None,
1405        // Per-signer floor gate (ADR 003 §Pool solvency): the live concurrency cap
1406        // `k` underneath the per-pool `remaining − M` ceiling, which bounds how much
1407        // live un-vouchered floor one capability signer may hold at once.
1408        pool_floor_signer_live_windows: cfg.blockchain.pool_floor_signer_live_windows,
1409        warming_credit,
1410        operator_shares: operator_shares.clone(),
1411        // Origin-only policy (#1759): backend-authoritative own/foreign decision.
1412        relay_foreign_namespaces: cfg.cache.relay_foreign_namespaces,
1413        coarse_clock: Some(coarse_clock),
1414        // The capability-expiry margin covers one sweep of the redeemer below.
1415        redeem_interval_secs: cfg.blockchain.redeem_interval_secs,
1416    };
1417    let client_handler = Arc::new(ClientHandler::new(client_deps)?);
1418
1419    // On-chain seller-settlement service (#327). A wallet-filled provider
1420    // (the staker-set provider above is read-only) signs the `redeemMany`
1421    // transactions with the same eth keystore signer. The bootstrap
1422    // self-checks the contract via `usdc()`; the watcher folds `PaymentPool`
1423    // events so the serve path reads pool solvency in-memory, and forgets a
1424    // reclaimed pool's lanes. Redemption is purely periodic: a self-tick
1425    // flushes the lane store then sweeps every above-threshold lane.
1426    // Simple (re-fetch-each-send) nonce management, not alloy's default cached
1427    // manager (#904). The cached manager advances its in-memory nonce when it
1428    // *prepares* a tx; if that send then fails (e.g. its `eth_estimateGas`
1429    // reverts on an always-reverting settlement under contention), the tx never
1430    // lands but the cached nonce stays advanced, so every subsequent tx from
1431    // this wallet carries a gapped nonce, sits unmined, and wedges the lane
1432    // until restart. `SimpleNonceManager` stores nothing — each send re-reads
1433    // the pending nonce — so a failed send can't gap the lane. The buyer
1434    // provider below relies on this same property for the retried `reclaimExpired`.
1435    // `wallet_provider` was built above (the handler's admit-path pool-view shares
1436    // it); it moves into the service here.
1437    let (payment_service, settlement_route) = PoolSettlementService::bootstrap(
1438        wallet_provider,
1439        payment_pool_addr,
1440        infra.eth_signer.address(),
1441        Arc::clone(&infra.channel_state_store),
1442        Arc::clone(&infra.watcher_checkpoint_store),
1443        Arc::clone(&client_handler),
1444        U256::from(cfg.blockchain.redeem_threshold_micro_usdc),
1445        usize::try_from(cfg.blockchain.redeem_max_vouchers_per_tx).unwrap_or(usize::MAX),
1446        Duration::from_secs(cfg.blockchain.redeem_interval_secs),
1447        Arc::clone(&infra.node_metrics),
1448        &boot,
1449        pool_view,
1450        redeem_tx,
1451        redeem_rx,
1452    )
1453    .await
1454    .context("PaymentPool settlement service bootstrap")?;
1455    poller_routes.push(settlement_route);
1456
1457    // Blacklist compliance watcher (ADR 011/031, issue #1031). Its boot pass
1458    // ENUMERATES the current on-chain deny-set at one pinned block
1459    // (`blacklistedAddresses` ∪ per-region `blacklistedHashes`) rather than
1460    // replaying logs, then follows the live tail seeded at that block. It evicts
1461    // held blobs whose hash is blacklisted in scope for this operator, which
1462    // cascades to DHT-announce suppression (the republisher's `is_evicted` gate),
1463    // probe `has_blob:false`, and delivery refusal — the node's only local
1464    // protection against the slash for serving blacklisted content.
1465    // `blacklist_ready_rx` gates the ALPN router below on that first enumeration.
1466    // The route carries a shutdown-token-observing sink factory (its re-scope
1467    // polls the poller's token, #1236); the tail carries no durable cursor, so
1468    // teardown has no scan checkpoint to flush.
1469    let (blacklist_ready_tx, blacklist_ready_rx) = oneshot::channel();
1470    let blacklist_route = crate::blacklist_watcher::bootstrap(
1471        providers.read_only(event_poll_interval),
1472        content_blacklist_addr,
1473        infra.eth_signer.address(),
1474        infra.cache.clone(),
1475        Arc::clone(&warming),
1476        Arc::clone(&head),
1477        Duration::from_secs(cfg.blockchain.content_blacklist_poll_interval_sec),
1478        blacklist_ready_tx,
1479        &infra.node_metrics,
1480        Arc::clone(&content_denylist),
1481        chain_freshness,
1482        &boot,
1483    )
1484    .await
1485    .context("blacklist compliance watcher boot enumeration")?;
1486    poller_routes.push(blacklist_route);
1487
1488    // Fee-shares route (ADR 041 / ADR 016 § Tunable Economics): follows
1489    // `SharesUpdated` on the shared poller and re-reads `getShares()`
1490    // authoritatively every `fee_shares_poll_interval_sec` as a safety net,
1491    // storing into the same shared `operator_shares` cell seeded by the startup
1492    // read above. Only registered when the startup `feeRouter()` read succeeded
1493    // — with no router address there is nothing to watch, and the seed already
1494    // fell back to `FEE_ROUTER_OPERATOR_BPS_FLOOR`. Read-only, no durable cursor.
1495    if let Some(addr) = fee_router_addr {
1496        let fee_shares_route = crate::fee_shares_watcher::route(
1497            providers.read_only(event_poll_interval),
1498            addr,
1499            operator_shares.clone(),
1500            Duration::from_secs(cfg.blockchain.fee_shares_poll_interval_sec),
1501            &infra.node_metrics,
1502        );
1503        poller_routes.push(fee_shares_route);
1504    }
1505
1506    // Assemble and spawn the ONE poller for all routes. `build` fails fast if
1507    // two routes claim the same `(address, topic0)` — a wiring bug, not a
1508    // runtime condition — surfacing it at boot. Up to four contract addresses
1509    // (payment_pool, capacity_bond, content_blacklist, fee_router) with
1510    // disjoint topic0s per (address, topic0).
1511    let poller = chain_poller_builder(
1512        Arc::clone(&head),
1513        event_poll_interval,
1514        &cfg.blockchain,
1515        &infra.node_metrics,
1516    );
1517    let poller = poller_routes
1518        .into_iter()
1519        .fold(
1520            poller,
1521            crate::chain_events::multiplexed_poller::MultiplexedPollerBuilder::route,
1522        )
1523        .build()
1524        .context("assemble the multiplexed chain-event poller")?;
1525    let poller = crate::chain_events::multiplexed_poller::spawn(poller_provider, poller);
1526
1527    // Bootstrap (ADR 022 §Bootstrap): seed the routing table from the
1528    // active-staker set + parallel `FindNode(self.node_id)` against a
1529    // fan-out of seeds. Best-effort — failures here log but don't
1530    // abort startup.
1531    let bootstrap_outcome = crate::dht::bootstrap::bootstrap(
1532        &infra.ep,
1533        infra.secret_key.public(),
1534        &dht_routing,
1535        &staker_set,
1536    )
1537    .await;
1538    tracing::info!(
1539        seeds = bootstrap_outcome.seeds_seen,
1540        inserted = bootstrap_outcome.seeds_inserted,
1541        find_node_ok = bootstrap_outcome.find_node_ok,
1542        find_node_err = bootstrap_outcome.find_node_err,
1543        closer_added = bootstrap_outcome.closer_peers_inserted,
1544        "dht bootstrap complete"
1545    );
1546    infra.node_metrics.dht_bootstrap_find_node_failures(
1547        u64::try_from(bootstrap_outcome.find_node_err).unwrap_or(u64::MAX),
1548    );
1549    infra
1550        .node_metrics
1551        .dht_routing_table_size(crate::dht::chain_projection::with_lock(
1552            &dht_routing,
1553            "dht routing table",
1554            |table| table.len(),
1555        ));
1556
1557    Ok(ChainHandlers {
1558        providers,
1559        event_poll_interval,
1560        slash_domain,
1561        bind_domain,
1562        staker_set,
1563        poller,
1564        slash_store,
1565        node_address_resolver,
1566        probe_rate_limiter,
1567        probe_handler,
1568        dht_rate_limiter,
1569        record_store,
1570        origin_directory,
1571        dht_handler,
1572        dht_routing,
1573        registry_regions,
1574        client_handler,
1575        shed_controller,
1576        payment_service,
1577        blacklist_ready_rx,
1578        binding_report,
1579        serve_economics,
1580        operator_shares,
1581        warming,
1582        warming_creditor,
1583    })
1584}
1585
1586/// Owned handle bundle produced by [`spawn_background_tasks`]: every local born
1587/// in the background region that [`run`] still needs after it returns — the
1588/// stop signals, task handles, and drain trigger that flow into
1589/// [`serve_until_shutdown`] and [`ShutdownHandles`]. Non-generic: the buyer
1590/// provider is consumed inside the region and the settlement indexer stores a
1591/// concrete watcher handle, so no opaque provider type escapes.
1592struct Background {
1593    metrics_stop_tx: oneshot::Sender<()>,
1594    dispatch_gc_stop_tx: oneshot::Sender<()>,
1595    egress_ewma_stop_tx: oneshot::Sender<()>,
1596    /// Origin-held-index periodic rescan (#1130). `None` when
1597    /// `cache.fs_rescan_interval_sec == 0` disables it.
1598    origin_rescan_stop_tx: Option<oneshot::Sender<()>>,
1599    record_store_gc_stop_tx: oneshot::Sender<()>,
1600    eviction_stop_tx: oneshot::Sender<()>,
1601    dht_rate_limit_gc_stop_tx: oneshot::Sender<()>,
1602    probe_rate_limit_gc_stop_tx: oneshot::Sender<()>,
1603    reputation_evict_stop_tx: oneshot::Sender<()>,
1604    republish_stop: CancellationToken,
1605    bucket_refresh_stop_tx: oneshot::Sender<()>,
1606    buyer_bootstrap_stop_tx: oneshot::Sender<()>,
1607    rpc_watchdog: Option<(oneshot::Sender<()>, tokio::task::JoinHandle<()>)>,
1608    admin_stop_tx: Option<oneshot::Sender<()>>,
1609    drain_trigger: Arc<admin::DrainTrigger>,
1610    tasks: JoinSet<()>,
1611}
1612
1613/// Background-tasks phase of [`run`] (#1253): construct the buyer-side
1614/// provider/stores, spawn every periodic GC / DHT / metrics / admin task, and
1615/// emit the startup banner. Borrows [`Infra`] and [`ChainHandlers`]; the buyer
1616/// provider is built from `ch.providers`, and the three EIP-712 domains are
1617/// cloned out of the shared `ch` reference,
1618/// and each clone is consumed exactly once. Returns the [`Background`] handles
1619/// [`run`] threads into the serve call and [`ShutdownHandles`].
1620#[allow(clippy::cognitive_complexity, clippy::too_many_lines)]
1621async fn spawn_background_tasks<P: Provider + Clone + 'static>(
1622    cfg: &ResolvedConfig,
1623    config_path: Option<&PathBuf>,
1624    reload_state: &Arc<RuntimeReloadState>,
1625    infra: &Infra,
1626    ch: &ChainHandlers<P>,
1627    started_at: std::time::Instant,
1628) -> anyhow::Result<Background> {
1629    // On-chain buyer-side service (#744). When this node pulls content from an
1630    // upstream provider on a cache miss it pays via the same channel mechanism,
1631    // acting as the client: a separate wallet-filled provider signs `approve` /
1632    // `openPool` / `topUp` / `closePool` / `reclaim`. It shares the persistent
1633    // store (a distinct `buyer_pool_state_v1` table in `buyer.redb`) and re-derives
1634    // the voucher domain the handler consumed above. The cache-miss hook that
1635    // *calls* `open_or_reuse_pool` is the `NodeOrigin` provisioned below (#831), gated
1636    // on `cache.node_to_node_pull_through_enabled`; the service is also held for
1637    // the process lifetime so its reclaim sweep keeps running even when
1638    // pull-through is off. The backgrounded bootstrap task (#1109, below) owns
1639    // that binding via its `let _service` hold — and thus keeps the service's
1640    // `AbortOnDropHandle` reclaim task alive to shutdown.
1641    //
1642    // Unlike the seller service, a buyer-bootstrap failure is NON-fatal: buying
1643    // is opportunistic cost-recovery, so a failed startup `approve` tx (e.g.
1644    // insufficient gas) must not block the node's core seller function. Log and
1645    // continue with the buyer path disabled (and thus pull-through disabled).
1646    // Simple nonce management, for the reason given on the seller
1647    // `wallet_provider` above (#904). It matters most here: `reclaimExpired` is
1648    // *expected* to revert under host-clock-vs-chain skew and be retried, so a
1649    // reverting send must not leak a cached nonce and wedge the buyer lane. This
1650    // provider is shared across `approve`/`openChannel`/`topUp`/`reclaimExpired`
1651    // and the reclaim sweep runs concurrently with opens, so correctness relies
1652    // on `SimpleNonceManager` re-reading the pending nonce each send (a transient
1653    // racing collision just gets a fresh nonce on the next attempt), not on the
1654    // sends being strictly serialized.
1655    let buyer_wallet_provider = ch
1656        .providers
1657        .buyer_wallet((*infra.eth_signer).clone(), ch.event_poll_interval);
1658    let buyer_channel_store: Arc<dyn decdn_incentive::BuyerPoolStore> = Arc::new(
1659        crate::channel_store::BuyerPoolStoreHandle::new(Arc::clone(&infra.concrete_channel_store)),
1660    );
1661    // `admin_v1_pools` reads the SAME store the bootstrap below writes adoptions
1662    // and top-ups into. It has to be the same handle, not a second open of
1663    // `buyer.redb`: redb holds a process-exclusive lock on that file for the
1664    // daemon's lifetime, which is why no CLI can read it from disk (#2078).
1665    let buyer_channel_store_for_admin = Arc::clone(&buyer_channel_store);
1666    // The buyer-side PaymentPool bootstrap (whose on-chain round-trips —
1667    // notably the one-time USDC `approve` receipt — can block for many
1668    // minutes on a stuck tx, bounded by `APPROVE_RECEIPT_TIMEOUT`) and
1669    // the node-origin pull-through provisioning it feeds are BOTH deferred to a
1670    // background task spawned below (#1109), so the metrics/admin listeners and
1671    // the "node runtime ready" banner come up independent of any chain RPC. The
1672    // provider/store/pending handles built just above are moved into that task.
1673
1674    let metrics_addr = std::net::SocketAddr::new(
1675        cfg.observability.metrics_bind,
1676        cfg.observability.metrics_port,
1677    );
1678    let metrics_listener =
1679        metrics::bind(metrics_addr).context("failed to bind metrics listener")?;
1680
1681    let (metrics_stop_tx, metrics_stop_rx) = oneshot::channel::<()>();
1682
1683    let metrics_handle = Arc::clone(&infra.node_metrics);
1684    let mut tasks = JoinSet::new();
1685    tasks.spawn(async move {
1686        if let Err(err) = metrics::serve(metrics_listener, metrics_handle, metrics_stop_rx).await {
1687            tracing::error!(error = %err, "metrics server exited with error");
1688        }
1689    });
1690
1691    // Periodic dispatch-limiter GC (#440). The acquire path only prunes
1692    // under flood (when the keyspace exceeds `cap + cap/10`); a node
1693    // with bursty short-lived clients can otherwise accumulate stale
1694    // per-source buckets between bursts and never reclaim them until
1695    // restart. The sweep is `O(n)` over the live keyspace; on an idle
1696    // limiter `n = 0` so the steady-state cost is one mutex acquire
1697    // per minute. Stops on its own oneshot — same pattern as the
1698    // metrics and admin servers.
1699    let dispatch_gc_stop_tx = {
1700        let limiter = Arc::clone(&infra.limiter);
1701        spawn_periodic(&mut tasks, "dispatch_gc", DISPATCH_GC_INTERVAL, move || {
1702            if let Some((before, after)) = limiter.gc_per_source() {
1703                tracing::debug!(
1704                    before,
1705                    after,
1706                    dropped = before.saturating_sub(after),
1707                    "dispatch GC sweep complete"
1708                );
1709            }
1710        })
1711    };
1712
1713    // Periodic load-shed egress-EWMA sample: folds the last interval's served
1714    // bytes into the controller's smoothed rate and republishes it as the
1715    // gauges the `ResourcePressure` policy's egress-budget check reads live
1716    // from `try_admit`, and that operators watch via metrics.
1717    let egress_ewma_stop_tx = {
1718        let shed = Arc::clone(&ch.shed_controller);
1719        let warming = Arc::clone(&ch.warming);
1720        let metrics = Arc::clone(&infra.node_metrics);
1721        spawn_periodic(&mut tasks, "egress_ewma", EGRESS_EWMA_INTERVAL, move || {
1722            let bps = shed.sample_egress(EGRESS_EWMA_INTERVAL.as_secs());
1723            metrics.load_shed_egress_bps(i64::try_from(bps).unwrap_or(i64::MAX));
1724            metrics.load_shed_pressure_active(shed.pressure_active());
1725            // Live concurrency against the shed high-water mark (#load-shed
1726            // telemetry), and how many upstream sources warming has cut off
1727            // (ADR 041), sampled on the same interval as the egress EWMA.
1728            metrics.load_shed_streams_in_flight(shed.node_in_flight());
1729            metrics.warming_sources_blocked(warming.blocked_source_count());
1730        })
1731    };
1732
1733    // Periodic DHT record-store GC (ADR 022 §Content Records and TTL).
1734    // `RecordStore::providers_at` lazily scrubs expired records for the hash it
1735    // is queried about, but a hash nobody queries again keeps its expired
1736    // entries — they keep counting against the publisher's per-publisher quota
1737    // and against the global cap until this sweep fires. Without it a node that
1738    // publishes a one-shot blob can exhaust its 200-record quota and reject
1739    // every future `Store`. The sweep is `O(expired × log N)` over the global
1740    // LRU (front-walk in `RecordStore::gc`); default 60s is well below the
1741    // 1-hour record TTL.
1742    let record_store_gc_stop_tx = {
1743        let records = Arc::clone(&ch.record_store);
1744        spawn_periodic(
1745            &mut tasks,
1746            "record_store_gc",
1747            DISPATCH_GC_INTERVAL,
1748            move || {
1749                let now_us = std::time::SystemTime::now()
1750                    .duration_since(std::time::UNIX_EPOCH)
1751                    .map_or(0, |d| u64::try_from(d.as_micros()).unwrap_or(u64::MAX));
1752                let removed = if let Ok(mut store) = records.lock() {
1753                    store.gc(now_us)
1754                } else {
1755                    tracing::error!("dht record-store mutex poisoned; skipping GC sweep this tick");
1756                    0
1757                };
1758                if removed > 0 {
1759                    tracing::debug!(removed, "dht record-store GC sweep complete");
1760                }
1761            },
1762        )
1763    };
1764
1765    // Cache-eviction driver (#1173, ADR 040). Async
1766    // (the sweep does `total_bytes()`/`release_for_eviction().await`), so it
1767    // cannot ride `spawn_periodic`'s sync `FnMut`; it spawns directly into the
1768    // JoinSet with its own oneshot stop, mirroring the metrics server. Enforces
1769    // the `cache.cache_size_mb` ceiling the write path leaves unbounded.
1770    let eviction_stop_tx = {
1771        // The driver's actuator is `release_for_eviction`, which only drops GC
1772        // protection — the iroh-blobs GC sweep is what actually reclaims disk.
1773        // With the sweep disabled the ceiling is unenforceable, and the symptom
1774        // (a cache pinned at 100% while the driver reports work) points at
1775        // pinning/probe-holds rather than at the real cause. Say so at boot.
1776        if cfg.cache.gc_interval_sec == 0 {
1777            tracing::warn!(
1778                cache_size_mb = cfg.cache.cache_size_mb,
1779                "eviction driver is running but cache.gc_interval_sec = 0: released blobs are \
1780                 never reclaimed, so cache.cache_size_mb cannot be enforced. Set a nonzero \
1781                 gc_interval_sec (default 300) to make eviction reclaim disk."
1782            );
1783        }
1784        let (eviction_stop_tx, eviction_stop_rx) = oneshot::channel::<()>();
1785        let params = eviction::EvictionParams {
1786            cache_size_mb: cfg.cache.cache_size_mb,
1787            disk_headroom_mb: cfg.cache.disk_headroom_mb,
1788            high_water_pct: cfg.cache.eviction_high_water_pct,
1789            target_pct: cfg.cache.eviction_target_pct,
1790            per_sweep_budget: cfg.cache.eviction_per_sweep_budget,
1791            tick: Duration::from_secs(cfg.cache.eviction_tick_secs),
1792        };
1793        tasks.spawn(eviction::run(
1794            infra.cache.clone(),
1795            infra.node_metrics.cache_metrics(),
1796            params,
1797            cfg.cache.cache_dir.clone(),
1798            infra.eviction_policy.clone(),
1799            // ADR 041: the SAME warming allowance the buy loop debits and the serve
1800            // path credits — the driver forgets an evicted hash's tag.
1801            Arc::clone(&ch.warming),
1802            eviction_stop_rx,
1803        ));
1804        eviction_stop_tx
1805    };
1806
1807    // Periodic DHT rate-limiter GC (#645). The acquire path opportunistically
1808    // prunes when the per-IP / per-peer keyed maps exceed `cap + cap/10`, but a
1809    // node whose DHT traffic falls below that threshold can carry millions of
1810    // stale buckets indefinitely — the same DoS shape the dispatch-limiter GC
1811    // prevents at the connection layer. `gc_per_ip` / `gc_per_peer` use
1812    // independent single-flight flags, so the two sequential sweeps here never
1813    // block a concurrent lazy prune from `check`.
1814    //
1815    // Panic contract: a panic inside `retain_recent` (governor arithmetic bug,
1816    // allocator OOM during the walk) unwinds out of the sweep and kills this
1817    // task. `PruneGuard`'s Drop still releases the single-flight flag, so the
1818    // lazy-prune path in `check` keeps working — but the periodic sweep is dead
1819    // until restart, surfacing only via `JoinSet::join_next` at shutdown.
1820    // Operators wanting earlier notice should alert on the
1821    // `decdn_dht_rate_limit_tracked_{per_ip,per_peer}` gauges failing to drop on
1822    // a quiet node (there the periodic sweep is their only writer). A map already
1823    // empty when the sweep died sits at `0` legitimately — indistinguishable
1824    // from a healthy sweep over an empty map.
1825    let dht_rate_limit_gc_stop_tx = {
1826        let limiter = Arc::clone(&ch.dht_rate_limiter);
1827        spawn_periodic(
1828            &mut tasks,
1829            "dht_rate_limit_gc",
1830            DHT_RATE_LIMIT_GC_INTERVAL,
1831            move || {
1832                log_keyspace_gc("dht", "per_ip", limiter.gc_per_ip());
1833                log_keyspace_gc("dht", "per_peer", limiter.gc_per_peer());
1834            },
1835        )
1836    };
1837
1838    // Probe rate-limiter keyspace GC (#982) — same DoS-shape rationale and panic
1839    // contract as the DHT GC above, for the `cdn/probe/v1` per-IP / per-peer
1840    // keyed maps.
1841    let probe_rate_limit_gc_stop_tx = {
1842        let limiter = Arc::clone(&ch.probe_rate_limiter);
1843        spawn_periodic(
1844            &mut tasks,
1845            "probe_rate_limit_gc",
1846            PROBE_RATE_LIMIT_GC_INTERVAL,
1847            move || {
1848                log_keyspace_gc("probe", "per_ip", limiter.gc_per_ip());
1849                log_keyspace_gc("probe", "per_peer", limiter.gc_per_peer());
1850            },
1851        )
1852    };
1853
1854    // DHT republish scheduler (ADR 022 §STORE Flow). The subscribe
1855    // handle is taken before the cold-start seed so an announcement racing
1856    // with seed-time lands in the channel backlog rather than the
1857    // gap between the snapshot and the spawn.
1858    let republish_scheduler = Arc::new(crate::dht::RepublishScheduler::new());
1859    // A token rather than a `oneshot`: the republisher's detached lag sweep
1860    // selects against the same signal, and shutdown must cancel it *before*
1861    // `cache.shutdown()` flushes the store. A signal the republisher forwards
1862    // only once it is next polled cannot give that ordering.
1863    let republish_stop = CancellationToken::new();
1864    let cache_inserts_rx = infra.cache.subscribe_inserts();
1865    // Walk the on-disk store (NOT `access_times_snapshot`, which is
1866    // eviction-recency state rather than the on-disk set) so every held (complete or
1867    // partial), non-evicted blob gets a `uniform(0, 40 min)` republish entry
1868    // per ADR 022 §Bootstrap AC 15. On a transient list-error we degrade: the
1869    // steady-state `subscribe_inserts` path catches only blobs newly fetched
1870    // or blocks newly completed post-boot — blobs already on disk that get
1871    // cache-HIT requests are NOT re-scheduled until the next lag sweep or
1872    // successful restart.
1873    // Populate the origin-held index before seeding announces (#1130) so cold
1874    // origin content (fs directory entries + present pins) is advertised from
1875    // the first republish, not only after a warm pulls it into the store.
1876    infra.cache.rescan_origins().await;
1877
1878    // Union stored blobs (complete or partial) with origin-held content — the
1879    // same snapshot the republisher's lag sweep re-seeds from, so the two
1880    // cannot drift apart.
1881    // The union coalesces a hash that is both stored and origin-held into one
1882    // jitter draw, and on a store list-error the origin-held half still seeds:
1883    // announce degrades only for the store half. See `holder_snapshot` for why
1884    // the store half is skipped under the origin-only policy.
1885    let snapshot =
1886        crate::dht::publish::holder_snapshot(&infra.cache, cfg.cache.relay_foreign_namespaces)
1887            .await;
1888    // Same counters and lines as the lag sweep's degradation: both are this one
1889    // derivation coming up short, and the boot case is the worse of the two —
1890    // it lasts the whole process lifetime rather than until the next lag
1891    // (ADR 022 §Bootstrap AC 15 degraded).
1892    snapshot.report_degradation(&infra.node_metrics, "cold start");
1893    let cold_start_count = republish_scheduler.seed_cold_start(
1894        snapshot
1895            .hashes
1896            .into_iter()
1897            .map(|h| decdn_protocol::ContentHash::from_bytes(*h.as_bytes())),
1898    );
1899    tracing::info!(
1900        cold_start_count,
1901        "republish scheduler seeded from existing cache + origin-held index (ADR 022 §Bootstrap cold-start, #1130)"
1902    );
1903    tasks.spawn(crate::dht::publish::run_republish(
1904        infra.ep.clone(),
1905        infra.secret_key.public(),
1906        Arc::clone(&ch.dht_routing),
1907        Arc::clone(&republish_scheduler),
1908        infra.cache.clone(),
1909        cfg.cache.relay_foreign_namespaces,
1910        Arc::clone(&infra.node_metrics),
1911        cache_inserts_rx,
1912        republish_stop.clone(),
1913    ));
1914
1915    // Periodic origin rescan (#1130): re-walk the fs origin + re-check pins so a
1916    // file added after boot becomes discoverable within one interval, then push
1917    // newly-found hashes into the republish scheduler — origin content never
1918    // generates a store-insert event, so this is its only steady-state announce
1919    // trigger. `fs_rescan_interval_sec == 0` disables it (startup + reload still
1920    // rescan). The tick body offloads the async rescan to a detached task
1921    // (`spawn_periodic` bodies are synchronous); scheduling is idempotent, so an
1922    // already-announced hash coalesces rather than double-drawing jitter.
1923    let origin_rescan_stop_tx = if cfg.cache.fs_rescan_interval_sec > 0 {
1924        let cache = infra.cache.clone();
1925        let scheduler = Arc::clone(&republish_scheduler);
1926        Some(spawn_periodic(
1927            &mut tasks,
1928            "origin_rescan",
1929            Duration::from_secs(cfg.cache.fs_rescan_interval_sec),
1930            move || {
1931                let cache = cache.clone();
1932                let scheduler = Arc::clone(&scheduler);
1933                tokio::spawn(async move {
1934                    cache.rescan_origins().await;
1935                    let report = cache.origin_held_snapshot();
1936                    // The steady-state announce trigger reports a short set the
1937                    // same way the boot seed and the lag sweep do. It runs far
1938                    // more often than either, so leaving it silent would make the
1939                    // most common truncation the least visible one.
1940                    if report.probe_faults > 0 || report.enumerate_failures > 0 {
1941                        tracing::warn!(
1942                            faults = report.probe_faults,
1943                            enumerate_failures = report.enumerate_failures,
1944                            "origin rescan could not resolve every candidate; the announce set omits anything it could not confirm and no earlier pass had indexed"
1945                        );
1946                    }
1947                    scheduler.seed_cold_start(
1948                        report
1949                            .hashes
1950                            .into_iter()
1951                            .map(|h| decdn_protocol::ContentHash::from_bytes(*h.as_bytes())),
1952                    );
1953                });
1954            },
1955        ))
1956    } else {
1957        None
1958    };
1959
1960    // Oversized pin set starves the eviction driver on EVERY node, because
1961    // pinned blobs are LRU-exempt however they arrived. Runs unconditionally at
1962    // startup (and again on reload).
1963    warn_if_pins_exceed_cache(&infra.cache, cfg.cache.cache_size_mb);
1964
1965    // DHT bucket-refresh (ADR 022 §Routing Table). Once per hour
1966    // picks the bucket with the oldest last-refresh timestamp and
1967    // runs `FindNode(random_id_in_bucket)` against the bucket's
1968    // freshest peer to repopulate it. Best-effort hygiene — a failed
1969    // refresh is silently retried on the next tick.
1970    let (bucket_refresh_stop_tx, bucket_refresh_stop_rx) = oneshot::channel::<()>();
1971    // Shared "last bucket-refresh completed at" clock (wall-clock µs, 0 =
1972    // never). The refresh task stamps it each pass; `admin_v1_status`
1973    // reads it for the routing-table health view (issue #741).
1974    let bucket_refresh_clock = Arc::new(std::sync::atomic::AtomicU64::new(0));
1975    tasks.spawn(crate::dht::bucket_refresh::run_bucket_refresh(
1976        infra.ep.clone(),
1977        infra.secret_key.public(),
1978        Arc::clone(&ch.dht_routing),
1979        bucket_refresh_stop_rx,
1980        crate::dht::bucket_refresh::BUCKET_REFRESH_TICK,
1981        Arc::clone(&bucket_refresh_clock),
1982        Arc::clone(&infra.node_metrics),
1983    ));
1984
1985    // RPC connectivity watchdog (issue #283). Updates `decdn_rpc_healthy`
1986    // each tick; a sustained transition fires an alert. `interval == 0`
1987    // disables the watchdog entirely (operators can opt out for offline
1988    // dev). Spawned outside the `JoinSet` because we drive it via its own
1989    // `oneshot` and an explicit `await` during drain, since `JoinSet::abort_all`
1990    // cancels eagerly and we'd rather let the watchdog observe its `shutdown`
1991    // arm.
1992    //
1993    // Seed the gauge to `1` here unconditionally: the startup
1994    // `check_rpc_reachability` above already established the endpoint is
1995    // reachable, and registered Prometheus gauges otherwise default to 0
1996    // — which alerts would (correctly, by their own logic) read as an
1997    // outage. Seeding before the watchdog-spawn branch covers the
1998    // `interval == 0` case too.
1999    infra.node_metrics.rpc_healthy(true);
2000    let rpc_watchdog = if cfg.blockchain.rpc_watchdog_interval_sec > 0 {
2001        let client = reqwest::Client::builder()
2002            .timeout(Duration::from_secs(5))
2003            .build()
2004            .context("failed to build HTTP client for RPC watchdog")?;
2005        let interval = Duration::from_secs(cfg.blockchain.rpc_watchdog_interval_sec);
2006        let (tx, rx) = oneshot::channel::<()>();
2007        let handle = spawn_rpc_watchdog(
2008            client,
2009            cfg.blockchain.rpc_url.clone(),
2010            interval,
2011            Arc::clone(&infra.node_metrics),
2012            rx,
2013        );
2014        Some((tx, handle))
2015    } else {
2016        tracing::info!("RPC watchdog disabled (blockchain.rpc_watchdog_interval_sec = 0)");
2017        None
2018    };
2019
2020    // Admin HTTP surface (`appendix-local-admin-http.md`). Bind here so startup fails fast on a port
2021    // collision rather than after side-effectful subscriptions have
2022    // registered. The `serve` task itself is spawned later, once the rest of
2023    // the runtime state it reads is wired.
2024    let admin_listener = if let Some(admin_port) = cfg.observability.admin_port {
2025        let admin_addr = std::net::SocketAddr::from(([127, 0, 0, 1], admin_port));
2026        Some(admin::bind(admin_addr).context("failed to bind admin listener")?)
2027    } else {
2028        tracing::info!("admin server disabled (observability.admin_port = 0)");
2029        None
2030    };
2031
2032    // Local per-peer EWMA reputation score store (ADR 008 §Local Score
2033    // Calculation). Reputation is local-only: a node ranks its peers solely from
2034    // its own delivery observations — there is no cross-node aggregation. The
2035    // store feeds node selection via the `NodeOrigin` pull path (provisioned
2036    // below).
2037    // Opt into ADR 008 §Score Clamping's ±0.05 per-report clamp (the library
2038    // default is a no-op cap) so one bad interaction cannot over-penalize an
2039    // otherwise good peer (#1176).
2040    let local_reputation = Arc::new(
2041        decdn_reputation::LocalReputation::new(
2042            decdn_reputation::LocalReputationConfig::default()
2043                .with_max_delta_per_update(decdn_reputation::LOCAL_SCORE_MAX_DELTA_PER_REPORT),
2044        )
2045        .context("local reputation config invalid")?,
2046    );
2047
2048    // Periodic local-reputation eviction (ADR 008 §Local Score Calculation).
2049    // The store is written by the node-origin pull path; without this sweep
2050    // it would retain an entry for every peer ever ranked. `evict` is
2051    // synchronous and cheap (a bounded walk of idle entries), so it rides
2052    // `spawn_periodic`'s sync `FnMut` like the other GC sweeps.
2053    let reputation_evict_stop_tx = {
2054        let rep = Arc::clone(&local_reputation);
2055        spawn_periodic(
2056            &mut tasks,
2057            "reputation_evict",
2058            REPUTATION_EVICT_INTERVAL,
2059            move || {
2060                rep.evict();
2061            },
2062        )
2063    };
2064
2065    // Buyer-side PaymentPool bootstrap + node-to-node pull-through
2066    // provisioning (#831), fully backgrounded off the startup critical path
2067    // (#1109). The USDC `approve` receipt that `bootstrap` awaits could hang for
2068    // many minutes on a stuck tx (capped by `APPROVE_RECEIPT_TIMEOUT`);
2069    // running it inline would gate the metrics/admin binds and the
2070    // "node runtime ready" banner below. The origin shares its `OnceLock` with
2071    // the clone already in the cache's origin chain, so a later `provision` from
2072    // this task arms pull-through; reads before it land as clean misses (no
2073    // spend, no panic). Bootstrap failure stays NON-fatal — buying is
2074    // opportunistic cost-recovery.
2075    //
2076    // Precompute every cfg/secret-derived value the task needs: the closure is
2077    // `'static` so it can't borrow `cfg`/`secret_key`, and those are used later.
2078    let (buyer_bootstrap_stop_tx, buyer_bootstrap_stop_rx) = oneshot::channel::<()>();
2079    let buyer_working_deposit = U256::from(cfg.blockchain.buyer_working_deposit_micro_usdc);
2080    let buyer_ensure_max_approval = cfg.blockchain.buyer_max_approve;
2081    let buyer_signer_address = infra.eth_signer.address();
2082    let pull_through_enabled = cfg.cache.node_to_node_pull_through_enabled;
2083    let node_origin_self_id = crate::dht::NodeId::from_bytes(*infra.secret_key.public().as_bytes());
2084    let node_origin_slash_domain = ch.slash_domain.clone();
2085    // #1117: the `CapacityBond` bind domain this node signs its node→node client
2086    // identity binding under — reuses the serving-side `bind_domain` constructed
2087    // once above so an upstream verifies against an identical domain.
2088    let node_origin_bind_domain = ch.bind_domain.clone();
2089    let node_origin_config = crate::node_origin::NodeOriginConfig {
2090        probe_fanout: cfg.cache.node_pull_probe_fanout,
2091        pull_timeout: std::time::Duration::from_secs(cfg.cache.node_pull_timeout_sec),
2092        stall_window: std::time::Duration::from_secs(cfg.cache.node_pull_stall_window_sec),
2093        min_throughput_bps: cfg.cache.node_pull_min_throughput_bps,
2094        max_blob_size_bytes: cfg
2095            .cache
2096            .max_blob_size_mb
2097            .saturating_mul(decdn_protocol::MB_BYTES),
2098        max_rate_per_mb: cfg.cache.max_rate_per_mb,
2099        event_poll_interval: std::time::Duration::from_millis(
2100            cfg.blockchain.event_poll_interval_ms,
2101        ),
2102        lookup: crate::dht::LookupConfig::default(),
2103        // Own self-attested region for the ADR 030 latency-vs-claim penalty
2104        // (#1177); `None` disables it (nothing to compare a peer's claim against).
2105        own_region: cfg.identity.region.clone(),
2106        // ADR 041 buy-side profitability gate + its inputs: the policy object
2107        // itself, the live operator fee-share cell, the shared ADR 040
2108        // frequency estimator (when built), and this node's configured sell rate
2109        // `P_sell` (`payment.rate_per_mb`).
2110        serve_economics: ch.serve_economics.clone(),
2111        operator_shares: ch.operator_shares.clone(),
2112        frequency_estimator: infra.frequency_estimator.clone(),
2113        sell_rate_base: cfg.payment.rate_per_mb,
2114        warming: Arc::clone(&ch.warming),
2115    };
2116    // Arc/handle clones for the task — the originals are used later in `run()`.
2117    let ep_for_buyer = infra.ep.clone();
2118    let eth_signer_for_buyer = Arc::clone(&infra.eth_signer);
2119    let resolver_opt = ch.node_address_resolver.clone();
2120    let node_origin_opt = infra.node_origin.clone();
2121    let dht_routing_c = Arc::clone(&ch.dht_routing);
2122    let staker_set_c = Arc::clone(&ch.staker_set);
2123    let origin_directory_c = Arc::clone(&ch.origin_directory);
2124    let local_reputation_c = Arc::clone(&local_reputation);
2125    let registry_regions_c = Arc::clone(&ch.registry_regions);
2126    let node_metrics_for_buyer = Arc::clone(&infra.node_metrics);
2127    let node_metrics_for_origin = Arc::clone(&infra.node_metrics);
2128    let node_origin_engine = infra.cache.clone();
2129    let mut buyer_bootstrap_stop_rx = buyer_bootstrap_stop_rx;
2130    let buyer_deployment = infra.payment_pool_deployment;
2131    let payment_pool_addr_for_buyer = buyer_deployment.payment_pool;
2132    tasks.spawn(async move {
2133        let service = tokio::select! {
2134            biased;
2135            // A shutdown that races a slow bootstrap unwinds cleanly here.
2136            _ = &mut buyer_bootstrap_stop_rx => return,
2137            res = crate::buyer_channel::BuyerPoolService::bootstrap(
2138                buyer_wallet_provider,
2139                buyer_deployment,
2140                buyer_signer_address,
2141                buyer_channel_store,
2142                eth_signer_for_buyer,
2143                buyer_working_deposit,
2144                buyer_ensure_max_approval,
2145                node_metrics_for_buyer,
2146            ) => match res {
2147                Ok(service) => Arc::new(service),
2148                Err(err) => {
2149                    tracing::warn!(
2150                        error = %sanitize_err_chain(&err),
2151                        payment_pool_addr = %payment_pool_addr_for_buyer,
2152                        "buyer-side PaymentPool bootstrap failed; node→node paid cache-miss \
2153                         pulls are DISABLED for this process (seller settlement is unaffected). \
2154                         This condition is sticky — restart the node to retry. Check: (1) \
2155                         blockchain.payment_pool_address is correct, (2) the RPC endpoint is \
2156                         reachable, (3) the wallet holds gas for the one-time USDC approve."
2157                    );
2158                    if pull_through_enabled {
2159                        tracing::warn!(
2160                            "cache.node_to_node_pull_through_enabled is set, but the buyer service \
2161                             failed to bootstrap; pull-through stays DISABLED this process \
2162                             (restart to retry)"
2163                        );
2164                    }
2165                    return;
2166                }
2167            }
2168        };
2169
2170        // Provision the node-to-node pull origin now that the buyer service is
2171        // up (#831). `node_origin_opt` is `Some` iff the feature is on; the
2172        // buyer is always present at this point, so the remaining gate is the
2173        // address resolver. The origin's `OnceLock` is shared with the cache
2174        // chain's clone, so this set is what actually arms pull-through.
2175        if let Some(origin) = node_origin_opt.as_ref() {
2176            if let Some(resolver) = resolver_opt.as_ref() {
2177                origin.provision(crate::node_origin::NodeOriginDeps {
2178                    endpoint: ep_for_buyer.clone(),
2179                    dial_runtime: tokio::runtime::Handle::current(),
2180                    routing_table: dht_routing_c,
2181                    staker_set: staker_set_c,
2182                    // FIND_VALUE directory reads: the last-resort fallback when
2183                    // the DHT returns no providers, and the supplement when a
2184                    // ranged pull's probed holders do not span the blob (ADR 022
2185                    // §FIND_VALUE Flow; #912, #2195). Shares the single origin
2186                    // directory built above with the reactive pull-through gate,
2187                    // so a configured `ChainOriginDirectory` backs both reads for
2188                    // every node. Absent chain addresses it is empty, and both
2189                    // reads find no origin.
2190                    origin_directory: origin_directory_c,
2191                    addr_resolver: Arc::clone(resolver),
2192                    buyer: Arc::clone(&service) as Arc<dyn crate::buyer_channel::PoolOpener>,
2193                    self_id: node_origin_self_id,
2194                    slash_domain: node_origin_slash_domain,
2195                    bind_domain: node_origin_bind_domain,
2196                    local_rep: local_reputation_c,
2197                    negative_cache: crate::dht::NegativeProbeCache::new(),
2198                    probe_cache: crate::dht::PositiveProbeCache::new(),
2199                    metrics: node_metrics_for_origin,
2200                    registry_regions: registry_regions_c,
2201                    config: node_origin_config,
2202                    // One voucher ledger per provider channel, shared by every concurrent
2203                    // pull on it (#1145 review). Built here, at the single place the pull
2204                    // paths' deps are assembled, so both paths necessarily share it —
2205                    // which is the point: a per-pull ledger makes concurrent pulls collide
2206                    // on the same cumulative amount. See `buyer_ledgers::BuyerLedgers`.
2207                    ledgers: Arc::new(crate::buyer_ledgers::BuyerLedgers::default()),
2208                    // Providers suppressed after a lane-terminal voucher rejection, skipped in
2209                    // ranking for a fixed window (#1145 review). In-memory, like the negative
2210                    // cache beside it.
2211                    wedged_providers: Arc::new(std::sync::Mutex::new(
2212                        std::collections::HashMap::new(),
2213                    )),
2214                    engine: node_origin_engine.clone(),
2215                });
2216                tracing::info!(
2217                    "node-to-node cache-miss pull-through provisioned and enabled (#831)"
2218                );
2219            } else {
2220                tracing::warn!(
2221                    "cache.node_to_node_pull_through_enabled is set, but the node-address \
2222                     resolver failed to bootstrap; pull-through stays DISABLED this process \
2223                     (restart to retry)"
2224                );
2225            }
2226        }
2227
2228        // Hold the service (and thus its `AbortOnDropHandle` reclaim sweep)
2229        // alive until shutdown — reclaim must keep running even when
2230        // pull-through is off (`node_origin_opt` is `None`).
2231        let _service = service;
2232        let _ = buyer_bootstrap_stop_rx.await;
2233    });
2234
2235    // Drain trigger for `admin_v1_drain` (issue #244). Constructed
2236    // unconditionally so the admin handler always has a live target —
2237    // there is no "drain disabled" state analogous to "no region" or
2238    // "no config path". The `Arc` is shared with the admin state (via
2239    // `Arc::clone`) and the select loop arm below.
2240    let drain_trigger = Arc::new(admin::DrainTrigger::new());
2241
2242    // Spawn the admin serve task with the full state. Bind happened
2243    // earlier (see `admin_listener` above) so a port collision would
2244    // have failed startup before any side-effectful subscribes ran.
2245    let admin_stop_tx = if let Some(listener) = admin_listener {
2246        let (tx, rx) = oneshot::channel::<()>();
2247        // Build the reload hook only when a config file path was passed
2248        // (CLI-only invocation has nothing on disk to re-read). The
2249        // hook hands `admin_v1_reload` the same `RuntimeReloadState` and
2250        // file path the SIGHUP arm uses, so both paths converge on a
2251        // single mutex-serialised reload — see `admin::AdminRpcImpl::reload`
2252        // and the SIGHUP arm of the select loop below.
2253        let reload_hook = config_path.map(|path| admin::ReloadHook {
2254            reload_state: Arc::clone(reload_state),
2255            config_path: path.clone(),
2256        });
2257        let state = admin::AdminState::new(
2258            *infra.secret_key.public().as_bytes(),
2259            started_at,
2260            infra.cache.clone(),
2261            reload_hook,
2262            Arc::clone(&drain_trigger),
2263            Arc::clone(&infra.node_metrics),
2264        )
2265        // DHT introspection for `admin_v1_status` (issue #741). All handles
2266        // are clones of state the DHT tasks already share — read-only here.
2267        .with_dht(admin::DhtStatusHandles {
2268            routing: Arc::clone(&ch.dht_routing),
2269            staker_set: Arc::clone(&ch.staker_set),
2270            record_store: Arc::clone(&ch.record_store),
2271            republish: Arc::clone(&republish_scheduler),
2272            refresh_clock: Arc::clone(&bucket_refresh_clock),
2273            refresh_interval: crate::dht::bucket_refresh::BUCKET_REFRESH_TICK,
2274        })
2275        // Lane introspection for `admin_v1_lanes` (issue #749). Shares the
2276        // same persistent lane-state store the client handler and settlement
2277        // service use, plus a read handle over the handler's live lane
2278        // registry for the last-voucher clock (issue #1733) — read-only here.
2279        .with_lanes(admin::LaneStatusHandles {
2280            pool_store: Arc::clone(&infra.channel_state_store),
2281            lane_activity: ch.client_handler.lane_activity_clock(),
2282            redeem_threshold_micro_usdc: cfg.blockchain.redeem_threshold_micro_usdc,
2283        })
2284        // Buyer-pool introspection for `admin_v1_pools` (#2078). The SAME store
2285        // handle `BuyerPoolService` records into, so the admin surface reports
2286        // what the daemon believes — the only read path to `buyer.redb` while
2287        // this process holds redb's exclusive lock on it.
2288        .with_buyer_pools(Arc::clone(&buyer_channel_store_for_admin))
2289        // Slash-detection introspection for `admin_v1_slashes` (#1032). Shares
2290        // the in-memory store the watcher appends to — read-only here.
2291        .with_slash_detection(admin::SlashStatusHandles {
2292            store: Arc::clone(&ch.slash_store),
2293        })
2294        // Bring-up node-id binding check (#1034), already logged during
2295        // `build_chain_and_handlers`. Surfacing it here too is what lets an
2296        // operator (or the G-NODE-07 journey) detect the unslashable state
2297        // without scraping the daemon's log for a WARN line.
2298        .with_binding(ch.binding_report)
2299        // Operator eth address for `admin_v1_status` (#1906). The loaded eth
2300        // signer's address — the same value the binding check above derives —
2301        // so an operator can read the wallet the node runs under without
2302        // grepping the bring-up log.
2303        .with_operator_address(infra.eth_signer.address())
2304        // ADR 041: the SAME warming allowance the buy loop debits, the serve
2305        // path credits, and the eviction driver forgets from, so
2306        // `admin_v1_evict` can also drop a hash's provenance tag on removal.
2307        .with_warming(Arc::clone(&ch.warming))
2308        // Live ADR 019 §Phase 4 registry state (#1030). The same `Arc` the serve
2309        // gate reads, so `decdn node health` cannot report a node as servable
2310        // while `serve_stream` is refusing it — and an operator who has just run
2311        // `decdn setup` sees `registry_active` flip without a restart.
2312        .with_staker_set(Arc::clone(&ch.staker_set))
2313        // Live content denylist for `admin_v1_status`'s
2314        // `chain_denied_origins` (ADR 011). The SAME `Arc` the client
2315        // handler and blacklist watcher hold, so the reported on-chain
2316        // origin deny-set size is always current.
2317        .with_denylist(reload_state.content_denylist());
2318        tasks.spawn(async move {
2319            if let Err(err) = admin::serve(listener, state, rx).await {
2320                tracing::error!(error = %err, "admin server exited with error");
2321            }
2322        });
2323        Some(tx)
2324    } else {
2325        None
2326    };
2327
2328    // Startup banner (#274). One structured INFO event per restart lets
2329    // operators correlate log streams across a fleet and across restarts
2330    // without stitching multiple lines together. Field names are stable —
2331    // log aggregators key on them.
2332    tracing::info!(
2333        event = "startup_banner",
2334        node_id = %infra.secret_key.public(),
2335        version = env!("CARGO_PKG_VERSION"),
2336        region = cfg.identity.region.as_deref().unwrap_or(""),
2337        bind_port = cfg.network.bind_port,
2338        metrics_addr = %metrics_addr,
2339        admin_port = ?cfg.observability.admin_port,
2340        rate_per_mb = cfg.payment.rate_per_mb,
2341        cache_dir = %cfg.cache.cache_dir.display(),
2342        has_origin = !cfg.cache.origins.is_empty(),
2343        origin_count = cfg.cache.origins.len(),
2344        origin_kinds = %origin_kinds_label(&cfg.cache.origins),
2345        "node runtime ready"
2346    );
2347
2348    Ok(Background {
2349        metrics_stop_tx,
2350        dispatch_gc_stop_tx,
2351        egress_ewma_stop_tx,
2352        origin_rescan_stop_tx,
2353        record_store_gc_stop_tx,
2354        eviction_stop_tx,
2355        dht_rate_limit_gc_stop_tx,
2356        probe_rate_limit_gc_stop_tx,
2357        reputation_evict_stop_tx,
2358        republish_stop,
2359        bucket_refresh_stop_tx,
2360        buyer_bootstrap_stop_tx,
2361        rpc_watchdog,
2362        admin_stop_tx,
2363        drain_trigger,
2364        tasks,
2365    })
2366}
2367
2368/// Build the endpoint, register handlers on a `Router`, spawn the metrics
2369/// server and other background tasks, and run until a shutdown signal is
2370/// received.
2371///
2372/// SIGHUP triggers a hot-reload of mutable config fields via
2373/// [`RuntimeReloadState`] (see issue #236). Other signals
2374/// (SIGINT/SIGTERM) trigger graceful shutdown.
2375///
2376/// The signal streams (SIGHUP, SIGTERM) are registered **once** before
2377/// the select loop and reused on every iteration. Re-creating
2378/// `tokio::signal::unix::Signal` each iteration would race with signal
2379/// delivery: a SIGHUP that arrives while `reload_runtime_config(..)` is
2380/// running would have nowhere to land if the future holding the
2381/// `Signal` had already been dropped, and would be silently lost. The
2382/// persistent stream queues the signal until the next `recv()` call
2383/// (kernel-managed, with coalescing) so concurrent or rapidly-repeated
2384/// signals are observed deterministically.
2385///
2386/// `node_metrics` is the registry `/metrics` serves. The caller builds it
2387/// so that pre-runtime components (the OTLP exporter) count into the same
2388/// registry.
2389///
2390/// `secret_key` is the node's iroh identity. The caller loads it, so that the
2391/// daemon entry point can build its JSON log formatter, which writes
2392/// `node_id` on every event, before bring-up starts.
2393pub async fn run(
2394    cfg: ResolvedConfig,
2395    secret_key: SecretKey,
2396    config_path: Option<PathBuf>,
2397    reload_state: Arc<RuntimeReloadState>,
2398    node_metrics: Arc<metrics::Metrics>,
2399) -> anyhow::Result<()> {
2400    // Captured at the very top of `run()`, before any `await` or I/O,
2401    // so `admin_v1_health.uptime_s` covers the whole runtime — including
2402    // the RPC reachability preflight below (which can spend up to its 5s
2403    // timeout on flaky networks). Only `commands::run`'s config resolution,
2404    // node key load and tracing bring-up come before it. Operators reasoning
2405    // about "how long has this node been up?" want every second since
2406    // `decdn-node run` started, not just everything after the admin server
2407    // bound.
2408    let started_at = std::time::Instant::now();
2409
2410    let infra = build_infra(&cfg, secret_key, &reload_state, node_metrics).await?;
2411
2412    let ch = build_chain_and_handlers(&cfg, &reload_state, &infra).await?;
2413
2414    let bg = spawn_background_tasks(
2415        &cfg,
2416        config_path.as_ref(),
2417        &reload_state,
2418        &infra,
2419        &ch,
2420        started_at,
2421    )
2422    .await?;
2423
2424    let (router, signal) = serve_until_shutdown(
2425        &reload_state,
2426        config_path.as_deref(),
2427        &bg.drain_trigger,
2428        ServeInputs {
2429            ep: infra.ep,
2430            probe_handler: ch.probe_handler,
2431            client_handler: ch.client_handler,
2432            dht_handler: ch.dht_handler,
2433            blacklist_ready_rx: ch.blacklist_ready_rx,
2434        },
2435    )
2436    .await?;
2437
2438    let handles = ShutdownHandles {
2439        metrics_stop_tx: bg.metrics_stop_tx,
2440        dispatch_gc_stop_tx: bg.dispatch_gc_stop_tx,
2441        egress_ewma_stop_tx: bg.egress_ewma_stop_tx,
2442        buyer_bootstrap_stop_tx: bg.buyer_bootstrap_stop_tx,
2443        origin_rescan_stop_tx: bg.origin_rescan_stop_tx,
2444        record_store_gc_stop_tx: bg.record_store_gc_stop_tx,
2445        eviction_stop_tx: bg.eviction_stop_tx,
2446        dht_rate_limit_gc_stop_tx: bg.dht_rate_limit_gc_stop_tx,
2447        probe_rate_limit_gc_stop_tx: bg.probe_rate_limit_gc_stop_tx,
2448        reputation_evict_stop_tx: bg.reputation_evict_stop_tx,
2449        republish_stop: bg.republish_stop,
2450        bucket_refresh_stop_tx: bg.bucket_refresh_stop_tx,
2451        admin_stop_tx: bg.admin_stop_tx,
2452        rpc_watchdog: bg.rpc_watchdog,
2453        poller: ch.poller,
2454        payment_service: ch.payment_service,
2455        receipt_writer: infra.receipt_writer,
2456        warming_creditor: ch.warming_creditor,
2457        lane_flush_task: infra.lane_flush_task,
2458        channel_state_store: infra.channel_state_store,
2459        node_metrics: infra.node_metrics,
2460        tasks: bg.tasks,
2461    };
2462    shutdown(handles, router, signal, &bg.drain_trigger, infra.cache).await
2463}
2464
2465/// Owned teardown state handed from [`run`] to [`shutdown`]. Every field is a
2466/// stop signal, watcher handle, or task handle that the graceful-shutdown
2467/// sequence consumes exactly once; the exhaustive destructure at the top of
2468/// [`shutdown`] (no `..` rest) is a deliberate safety net — a field left
2469/// unconsumed raises an `unused_variables` warning, which CI and the pre-commit
2470/// hook make fatal, proving no stop signal was dropped on the floor.
2471struct ShutdownHandles<P: Provider + Clone + 'static> {
2472    metrics_stop_tx: oneshot::Sender<()>,
2473    dispatch_gc_stop_tx: oneshot::Sender<()>,
2474    egress_ewma_stop_tx: oneshot::Sender<()>,
2475    buyer_bootstrap_stop_tx: oneshot::Sender<()>,
2476    /// Origin-held-index periodic rescan (#1130). `None` when
2477    /// `cache.fs_rescan_interval_sec == 0` disables it.
2478    origin_rescan_stop_tx: Option<oneshot::Sender<()>>,
2479    record_store_gc_stop_tx: oneshot::Sender<()>,
2480    eviction_stop_tx: oneshot::Sender<()>,
2481    dht_rate_limit_gc_stop_tx: oneshot::Sender<()>,
2482    probe_rate_limit_gc_stop_tx: oneshot::Sender<()>,
2483    reputation_evict_stop_tx: oneshot::Sender<()>,
2484    republish_stop: CancellationToken,
2485    bucket_refresh_stop_tx: oneshot::Sender<()>,
2486    admin_stop_tx: Option<oneshot::Sender<()>>,
2487    rpc_watchdog: Option<(oneshot::Sender<()>, tokio::task::JoinHandle<()>)>,
2488    /// The single multiplexed chain-event poller. Stopped once, late (after
2489    /// `router.shutdown`), because its capacity-bond route gates DHT admission
2490    /// through drain — see the cancel site in [`shutdown`].
2491    poller: crate::chain_events::resumable_watcher::WatcherHandle,
2492    payment_service: PoolSettlementService<P>,
2493    /// The background receipt writer, cancelled once the router has drained
2494    /// and awaited in the drain phase so the audit tail survives shutdown
2495    /// (#803).
2496    receipt_writer: StopHandle,
2497    /// The ADR 041 warming-credit aggregator, cancelled beside the receipt
2498    /// writer and awaited in the drain phase so the serve credits already
2499    /// queued land in the ledger before the runtime returns.
2500    warming_creditor: StopHandle,
2501    /// Periodic lane-store flush timer, aborted below after one final durable
2502    /// flush of `channel_state_store`.
2503    lane_flush_task: tokio::task::JoinHandle<()>,
2504    channel_state_store: Arc<dyn PoolStateStore>,
2505    node_metrics: Arc<metrics::Metrics>,
2506    tasks: JoinSet<()>,
2507}
2508
2509/// Shut `router` down, giving up after `deadline`. Returns whether the router
2510/// task ran to completion.
2511///
2512/// The router's shutdown cancels its handlers, then calls
2513/// [`iroh::Endpoint::close`], which waits for every open connection to reach
2514/// QUIC's draining state. A connection whose QUIC driver died with the runtime
2515/// that dialled it never does, and the close then waits forever. The teardown
2516/// after this call redeems lanes, flushes the lane store and the receipt tail,
2517/// and closes the cache, so a stuck close must not cost that settlement state.
2518///
2519/// Past `deadline` this drops the shutdown future, which aborts the router's
2520/// task, and logs an error: every upstream dial runs on the main runtime, so a
2521/// close that does not finish is a defect, not load. The handlers are already
2522/// cancelled by then, so no serve outlives the give-up. The endpoint is left
2523/// closing rather than closed, so nothing after this call may wait on
2524/// [`iroh::Endpoint::closed`]. A router task that panicked also counts as not
2525/// finished: its endpoint close may never have run.
2526async fn close_router(router: &Router, deadline: Duration) -> bool {
2527    match tokio::time::timeout(deadline, router.shutdown()).await {
2528        Ok(Ok(())) => true,
2529        Ok(Err(err)) => {
2530            tracing::error!(
2531                error = %err,
2532                "router shutdown task failed; the endpoint may not have closed"
2533            );
2534            false
2535        }
2536        Err(_) => {
2537            tracing::error!(
2538                ?deadline,
2539                "router shutdown did not finish within the deadline, most likely because the \
2540                 endpoint close waits on a connection whose QUIC driver is gone; continuing \
2541                 with redeem and flush"
2542            );
2543            false
2544        }
2545    }
2546}
2547
2548/// Graceful teardown of [`run`] (#1253). Consumes every field of
2549/// [`ShutdownHandles`] via an exhaustive destructure — see that type's docs for
2550/// why the `..`-free binding is load-bearing. The teardown ordering here is
2551/// itself load-bearing (metrics accept-loop stop first; the multiplexed `poller`
2552/// stops after `router.shutdown`) and must not be reordered.
2553#[allow(clippy::cognitive_complexity, clippy::too_many_lines)]
2554async fn shutdown<P: Provider + Clone + 'static>(
2555    handles: ShutdownHandles<P>,
2556    router: Router,
2557    signal: ShutdownSignal,
2558    drain_trigger: &Arc<admin::DrainTrigger>,
2559    cache: CacheEngine,
2560) -> anyhow::Result<()> {
2561    let ShutdownHandles {
2562        metrics_stop_tx,
2563        dispatch_gc_stop_tx,
2564        egress_ewma_stop_tx,
2565        buyer_bootstrap_stop_tx,
2566        origin_rescan_stop_tx,
2567        record_store_gc_stop_tx,
2568        eviction_stop_tx,
2569        dht_rate_limit_gc_stop_tx,
2570        probe_rate_limit_gc_stop_tx,
2571        reputation_evict_stop_tx,
2572        republish_stop,
2573        bucket_refresh_stop_tx,
2574        mut admin_stop_tx,
2575        rpc_watchdog,
2576        poller,
2577        payment_service,
2578        receipt_writer,
2579        warming_creditor,
2580        lane_flush_task,
2581        channel_state_store,
2582        node_metrics,
2583        mut tasks,
2584    } = handles;
2585
2586    // Signal the HTTP accept loops to stop *before* closing the router.
2587    // The router close can take up to `SHUTDOWN_DEADLINE`, and while it runs
2588    // the metrics/admin servers would otherwise keep accepting fresh loopback
2589    // connections — wasting that budget and emitting misleading
2590    // "still serving" signals. The accept loops are cheap to unwind, so
2591    // stopping them first is strictly cleaner.
2592    if metrics_stop_tx.send(()).is_err() {
2593        // Receiver dropped → the metrics server task already exited on
2594        // its own. The spawn closure logs its own error on abnormal exit
2595        // (see `metrics server exited with error` above), so this branch
2596        // is purely informational: under a healthy shutdown we'd have
2597        // been the ones signaling it.
2598        tracing::warn!("metrics server exited before shutdown signal was sent");
2599    }
2600    // Best-effort: silently ignore the dispatch GC stop send failure.
2601    // The task only exits early on a panic, and its panic surfaces
2602    // through `JoinSet::join_next` during the drain phase below — no
2603    // operator-actionable signal to log at this seam.
2604    let _ = dispatch_gc_stop_tx.send(());
2605    // Best-effort, same rationale as the dispatch-GC stop above: the egress
2606    // EWMA tick only exits early on a panic, which surfaces through
2607    // `JoinSet::join_next` during the drain phase below.
2608    let _ = egress_ewma_stop_tx.send(());
2609    // Cancel a still-running buyer bootstrap and release the task's
2610    // process-lifetime hold on the service (#1109). Best-effort: on the
2611    // bootstrap-failed path the task already returned and dropped the receiver,
2612    // so this send errors harmlessly.
2613    let _ = buyer_bootstrap_stop_tx.send(());
2614    // origin-held-index periodic rescan (#1130)
2615    if let Some(tx) = origin_rescan_stop_tx {
2616        let _ = tx.send(());
2617    }
2618    let _ = record_store_gc_stop_tx.send(());
2619    // Eviction driver (#1173): best-effort stop, same shape as the GC sweeps.
2620    // The task also drains via `JoinSet::join_next` below.
2621    let _ = eviction_stop_tx.send(());
2622    let _ = dht_rate_limit_gc_stop_tx.send(());
2623    let _ = probe_rate_limit_gc_stop_tx.send(());
2624    // Best-effort, same rationale as the GC sweeps above: the reputation
2625    // evict tick exits on this stop signal (or a panic, which surfaces
2626    // through `JoinSet::join_next` during the drain phase below).
2627    let _ = reputation_evict_stop_tx.send(());
2628    // Cancels the republisher AND any lag sweep it left detached. The cancel is
2629    // ordered strictly before the `cache.shutdown()` flush below, which is what
2630    // the sweep needs: its `select!` is `biased` on this token, so a worker
2631    // polled any time after the flush takes the cancel arm instead of observing
2632    // the store it was walking disappear and reporting that as a store-walk
2633    // failure. The sweeps are detached and not joined, so one may outlive the
2634    // flush by a poll — it just cannot report anything once cancelled.
2635    republish_stop.cancel();
2636    let _ = bucket_refresh_stop_tx.send(());
2637    // The chain watchers are one multiplexed poller with one shutdown token, so
2638    // they stop together in a SINGLE `poller.shutdown()` at the LATE point below
2639    // (after `router.shutdown`). For the blacklist and rate-bounds routes the
2640    // late stop is harmless — neither carries a durable cursor to flush, and
2641    // neither is consulted for a drain-time decision (unlike the capacity-bond
2642    // route's staker set), so they only keep applying compliance/rate updates a
2643    // little longer.
2644    // Admin server shutdown is ordered per `admin_stop_order`:
2645    //
2646    //   - `Early` (the default, including SIGTERM/SIGINT and plain
2647    //     `admin_v1_drain`): stop admin *before* `router.shutdown` so
2648    //     admin doesn't keep accepting fresh loopback connections
2649    //     during drain. This is the original ordering (see
2650    //     `appendix-local-admin-http`).
2651    //   - `AfterRouter` (only when an `admin_v1_drain` call carried
2652    //     `wait_admin: true`, i.e. `decdn node drain --wait`): keep
2653    //     admin alive *through* `router.shutdown` so a polling client
2654    //     can observe `admin_v1_health.in_flight_streams` reach 0. The
2655    //     admin stop signal fires below, after `router.shutdown`
2656    //     returns.
2657    //
2658    // `wait_admin` is sampled once into a local; the two `if` blocks
2659    // below are mutually exclusive, so the single `Option<Sender>` is
2660    // consumed by exactly one branch. The signal-gating on
2661    // `AdminDrain` means a sticky `wait_admin=true` from an in-flight
2662    // RPC that was racing a SIGTERM does *not* flip the runtime onto
2663    // the AfterRouter path when SIGTERM actually won the select (the
2664    // operator's stated intent wins; the RPC's request becomes a
2665    // no-op since drain is already in progress).
2666    let stop_order = admin_stop_order(signal, drain_trigger);
2667    if matches!(stop_order, AdminStopOrder::Early)
2668        && let Some(tx) = admin_stop_tx.take()
2669        && tx.send(()).is_err()
2670    {
2671        tracing::warn!("admin server exited before shutdown signal was sent");
2672    }
2673    let rpc_watchdog_handle = if let Some((tx, handle)) = rpc_watchdog {
2674        if tx.send(()).is_err() {
2675            tracing::warn!("RPC watchdog exited before shutdown signal was sent");
2676        }
2677        Some(handle)
2678    } else {
2679        None
2680    };
2681
2682    // Router::shutdown waits for ProtocolHandler::shutdown on each handler,
2683    // then closes the endpoint. Bounded, so every step below still runs.
2684    close_router(&router, SHUTDOWN_DEADLINE).await;
2685    // The ONE multiplexed poller driving all registered watcher routes stops here,
2686    // once. It exits cooperatively — cancelling its loop at the next await
2687    // boundary and flushing every persisting route's checkpoint (settlement's
2688    // `PoolOpened`) before returning — with its `WatcherHandle`'s `AbortOnDropHandle`
2689    // as the backstop.
2690    //
2691    // *After* `router.shutdown` deliberately, and the capacity-bond route is why:
2692    // its projection is the cached active-staker set, which gates DHT `Store`
2693    // admission and decides which probes the stake-lane reservation sheds.
2694    // Cancelling it before the drain would freeze that set while the router is
2695    // still serving, so a membership change landing mid-drain would be missed by
2696    // exactly the requests still in flight. This binding constraint forces the
2697    // single stop late; the other four routes (slash, settlement, blacklist,
2698    // rate-bounds) are not drain-consulted and could stop earlier, but one shared
2699    // token means they stop here too. `shutdown` only cancels the token — it is
2700    // not awaited — so settlement's checkpoint flush races the redeem sweep just
2701    // below rather than completing before it; that is fine because the flush is
2702    // best-effort and the checkpoint is independent of, and idempotent with
2703    // respect to, the redeem sweep.
2704    poller.shutdown();
2705    // The router has drained, so no further vouchers — and therefore no further
2706    // receipts — will be produced. Signal the receipt writer to flush whatever
2707    // is already enqueued and exit; it is awaited in the drain phase below so
2708    // the audit tail survives shutdown (#803). The early cancel overlaps the
2709    // tail flush with the settlement and store teardown between here and the
2710    // drain; the `shutdown().await` there cancels again as a no-op.
2711    receipt_writer.cancel();
2712    // Same reasoning for the ADR 041 warming-credit aggregator: no further
2713    // serves complete, so no further credits are produced. Let it apply what is
2714    // already queued and exit; it is awaited in the drain phase below so a
2715    // completed serve's credit is not lost to shutdown timing.
2716    warming_creditor.cancel();
2717
2718    // Redeem on shutdown (#327): now that the router has drained, no further
2719    // vouchers arrive and the persisted lane state is final. A pool is
2720    // owner-closed only, so there is nothing to close here. The poller cancel
2721    // above triggers settlement's tail stop and checkpoint flush asynchronously
2722    // (best-effort, not awaited before this point), so this quiesces the redeemer
2723    // and runs one final best-effort redeem sweep so an above-threshold lane is
2724    // not left un-redeemed. The redeem sweep does not depend on the checkpoint
2725    // flush having completed — the scan checkpoint is independent lane state and
2726    // idempotent to re-scan. Bounded by the deadline
2727    // so a slow RPC cannot hang shutdown.
2728    payment_service.shutdown(SHUTDOWN_DEADLINE).await;
2729
2730    // Final durable flush before stop, so the last interval of frontier lands.
2731    // The router has drained and the redeem sweep above already ran, so the
2732    // in-memory lane state is final; this is the last write before the
2733    // background flush task is aborted below.
2734    let flush_result = match tokio::task::spawn_blocking(move || channel_state_store.flush()).await
2735    {
2736        Ok(result) => result,
2737        Err(join_err) => Err(decdn_incentive::StoreError::Backend(format!(
2738            "flush join: {join_err}"
2739        ))),
2740    };
2741    if let Err(err) = flush_result {
2742        node_metrics.lane_flush_failure();
2743        tracing::warn!(error = %err, "final lane store flush on shutdown failed");
2744    }
2745    lane_flush_task.abort();
2746
2747    // Late admin stop (issue #604 `AfterRouter` path). The polling
2748    // client (`decdn node drain --wait`) needed admin to stay open
2749    // while `router.shutdown` awaited the last in-flight client
2750    // streams; now that it's returned, tear admin down so the polling
2751    // client either sees `in_flight_streams == 0` on its next tick or
2752    // observes ECONNREFUSED — both of which it treats as "drain
2753    // complete".
2754    //
2755    // If the receiver has already dropped (i.e. the admin task
2756    // crashed/panicked between the Early skip and this point), that's
2757    // a violated invariant — `decdn node drain --wait` clients may
2758    // have misread the ECONNREFUSED as drain completion while
2759    // in-flight streams were still draining. Log at `error!`
2760    // accordingly so post-mortems surface it.
2761    //
2762    // A router close that gave up at its deadline still reaches here: its
2763    // handlers were cancelled first, so no stream is in flight, and
2764    // `close_router` has already logged the stuck endpoint close.
2765    if matches!(stop_order, AdminStopOrder::AfterRouter)
2766        && let Some(tx) = admin_stop_tx.take()
2767        && tx.send(()).is_err()
2768    {
2769        tracing::error!(
2770            "admin server exited while runtime was awaiting router.shutdown; \
2771             `decdn node drain --wait` clients may have observed ECONNREFUSED \
2772             before in-flight streams completed (false drain-complete)"
2773        );
2774    }
2775
2776    // Flush the cache store before the drain deadline so in-flight writes
2777    // hit disk. Intentionally *not* gated by `SHUTDOWN_DEADLINE`: a slow
2778    // flush is preferable to a lost write, and the watchdog at the outer
2779    // process level catches a truly stuck shutdown. On failure we still
2780    // finish the task drain cleanly (so metrics/dispatch don't leak) and
2781    // then bubble the error out of `run()` — supervisors need a non-zero
2782    // exit to know the store may be inconsistent.
2783    let cache_shutdown_err = cache.shutdown().await.err();
2784    if let Some(err) = cache_shutdown_err.as_ref() {
2785        tracing::error!(error = %err.display_chain(), "cache shutdown failed; store state may be inconsistent");
2786    }
2787
2788    // Last-resort abort handle for the RPC watchdog, which lives *outside* the
2789    // `JoinSet`. On the normal path the watchdog oneshot drains it cleanly;
2790    // this is fired only if `drain` overruns `SHUTDOWN_DEADLINE`, because
2791    // dropping the timed-out `drain` future would otherwise merely *detach*
2792    // a wedged task (a dropped `JoinHandle` keeps running), not stop it.
2793    // `tasks.abort_all()` covers the `JoinSet` (reaped + logged per task
2794    // below); this covers the watchdog as a fire-and-forget abort — we do NOT
2795    // await it past the deadline, since `abort()` only lands at a poll point
2796    // and a truly non-yielding loop would re-hang the shutdown the timeout
2797    // escaped.
2798    let watchdog_abort = rpc_watchdog_handle
2799        .as_ref()
2800        .map(tokio::task::JoinHandle::abort_handle);
2801    // The receipt writer also lives outside `tasks`; same fire-and-forget abort
2802    // backstop if the drain overruns the deadline (#803).
2803    let receipt_writer_abort = receipt_writer.abort_handle();
2804    // The warming-credit aggregator is in the same position: outside `tasks`,
2805    // cancelled above, awaited in the drain, aborted if the drain overruns.
2806    let warming_creditor_abort = warming_creditor.abort_handle();
2807
2808    let drain = async {
2809        while let Some(result) = tasks.join_next().await {
2810            log_join_result(result, "shutdown");
2811        }
2812        // Stop-and-await the receipt writer so the runtime doesn't return while
2813        // it's still flushing its tail. `StopHandle::shutdown` cancels before
2814        // it joins (a no-op after the early cancel above), so the join cannot
2815        // hang on the sinks the client handler holds; a panic surfaces at
2816        // `warn` and a cancellation at `debug`, matching how every other
2817        // drained task is logged.
2818        log_join_result(receipt_writer.shutdown().await, "receipt-writer-shutdown");
2819        // Stop-and-await the aggregator for the same reason, and so that a task
2820        // that died earlier in the run surfaces here as a join error rather
2821        // than as credits that silently stopped landing.
2822        log_join_result(
2823            warming_creditor.shutdown().await,
2824            "warming-creditor-shutdown",
2825        );
2826        // Await the RPC watchdog. We signalled it via oneshot above, so
2827        // a healthy run resolves cleanly here. A panic surfaces as a
2828        // warning; cancellation is silent (same as the receipt writer).
2829        if let Some(handle) = rpc_watchdog_handle
2830            && let Err(err) = handle.await
2831            && !err.is_cancelled()
2832        {
2833            tracing::warn!(error = %err, "RPC watchdog task panicked during shutdown");
2834        }
2835    };
2836    if tokio::time::timeout(SHUTDOWN_DEADLINE, drain).await.is_ok() {
2837        tracing::info!("graceful shutdown complete");
2838    } else {
2839        tracing::warn!(
2840            deadline = ?SHUTDOWN_DEADLINE,
2841            out_of_joinset_aborts = usize::from(watchdog_abort.is_some()) + 2,
2842            "graceful shutdown timed out; aborting remaining tasks",
2843        );
2844        tasks.abort_all();
2845        // The RPC watchdog lives outside `tasks`; dropping the timed-out
2846        // `drain` only detached it, so abort explicitly. Unlike the
2847        // `JoinSet`, it is not reaped/awaited afterwards (see the rationale
2848        // where the abort handle is collected) — the count above is its
2849        // only per-shutdown record.
2850        if let Some(abort) = &watchdog_abort {
2851            abort.abort();
2852        }
2853        // Aborting the writer mid-tail-drain discards any still-enqueued audit
2854        // receipts. This only happens once shutdown has already blown its
2855        // deadline (an abnormal, already-warned event), but name it specifically
2856        // so a post-mortem can correlate a gap in `download_receipts.jsonl` with
2857        // the overrun — the drop counter does not cover this path (#803).
2858        tracing::warn!(
2859            event = "receipt_writer_aborted",
2860            "receipt writer aborted at shutdown deadline; enqueued audit receipts may be lost"
2861        );
2862        receipt_writer_abort.abort();
2863        // Aborting the aggregator mid-drain discards the still-queued warming
2864        // credits. Conservative in the same direction as a `Full` drop — the
2865        // affected sources stay more negative than reality until the time refill
2866        // catches up — and the allowance is in-memory bookkeeping a restart
2867        // rebuilds anyway.
2868        tracing::warn!(
2869            event = "warming_creditor_aborted",
2870            "warming-credit aggregator aborted at shutdown deadline; \
2871             queued serve credits are lost"
2872        );
2873        warming_creditor_abort.abort();
2874        while let Some(result) = tasks.join_next().await {
2875            log_join_result(result, "abort");
2876        }
2877    }
2878
2879    tracing::info!("node stopped");
2880    match cache_shutdown_err {
2881        None => Ok(()),
2882        Some(err) => Err(anyhow::Error::from(err).context("cache shutdown failed")),
2883    }
2884}
2885
2886/// Build an iroh `Endpoint` bound to `bind_port` on both address families:
2887/// `0.0.0.0:bind_port` and `[::]:bind_port`. ALPNs are set by the `Router`
2888/// when it spawns. The bind addresses are included in the error message so a
2889/// port collision surfaces as a config-level diagnostic (matching
2890/// `metrics::bind` and `admin::bind`), rather than an opaque "endpoint bind
2891/// failed".
2892///
2893/// The IPv6 bind replaces iroh's default `[::]:0` socket, so a dual-stack node
2894/// listens on one stable port per family and an operator can register an
2895/// `/ip6/<addr>/udp/<bind_port>/quic-v1` multiaddr. The two sockets do not
2896/// collide: netwatch sets `IPV6_V6ONLY` on every IPv6 socket. The IPv4 bind is
2897/// required. The IPv6 bind is not: a host without IPv6 (disabled by sysctl, or
2898/// a container without a v6 stack) or with `[::]:bind_port` taken still starts
2899/// on IPv4 only, and [`warn_if_no_ipv6_socket`] logs the degraded state.
2900///
2901/// No transient pre-bind probe: it would introduce a TOCTOU window
2902/// between the probe and the real bind, and iroh's
2903/// `Endpoint::builder().bind_addr(...).bind()` owns its UDP socket
2904/// internally — there is nothing to hand off. Single-bind matches the
2905/// existing TCP listeners.
2906async fn build_endpoint(
2907    secret_key: &SecretKey,
2908    bind_port: u16,
2909    relay_urls: &[String],
2910    discovery: &ResolvedDiscovery,
2911    transport_config: QuicTransportConfig,
2912) -> anyhow::Result<Endpoint> {
2913    let bind_v4 = SocketAddrV4::new(Ipv4Addr::UNSPECIFIED, bind_port);
2914    let bind_v6 = SocketAddrV6::new(Ipv6Addr::UNSPECIFIED, bind_port, 0, 0);
2915
2916    // Discovery selection is the discovery-provider seam (#818, see
2917    // adr/appendix-poc-production-seams.md). Both branches start from
2918    // `presets::Minimal` (crypto provider only) and compose the lookup legs
2919    // explicitly, so the node's own pkarr publisher carries
2920    // `node_publish_filter` (relay URL plus public IPs) rather than iroh's
2921    // relay-only default (ADR 001 § Node Discovery). With no `network.discovery`
2922    // keys the legs are the n0-hosted pkarr publisher and DNS lookup that
2923    // `presets::N0` wires; with operator discovery configured they are exactly
2924    // the configured legs. Discovery and relay are independent legs: `Minimal`
2925    // sets NO relay mode, so the n0 relay default is restored here, and a custom
2926    // `network.relay_urls` map (if any) replaces it below.
2927    let mut builder = if discovery.is_empty() {
2928        Endpoint::builder(presets::Minimal)
2929            .address_lookup(PkarrPublisher::n0_dns().addr_filter(node_publish_filter()))
2930            .address_lookup(DnsAddressLookup::n0_dns())
2931    } else {
2932        add_discovery_lookups(Endpoint::builder(presets::Minimal), discovery)?
2933    };
2934    builder = builder.relay_mode(RelayMode::Default);
2935
2936    builder = builder
2937        .secret_key(secret_key.clone())
2938        .transport_config(transport_config);
2939
2940    // A configured `network.relay_urls` list swaps the n0 default relay map for
2941    // the operator's self-hosted relays (`RelayMode::Custom`). The relay leg is
2942    // independent of the discovery leg selected above: a custom relay map routes
2943    // connectivity through the operator's relays while NodeId→address discovery
2944    // uses whichever provider the base-preset branch wired (n0 pkarr/DNS, or the
2945    // operator's `network.discovery` providers). Multiple entries give
2946    // redundancy/failover.
2947    if !relay_urls.is_empty() {
2948        let relays = parse_relay_urls(relay_urls)?;
2949        builder = builder.relay_mode(RelayMode::Custom(RelayMap::from_iter(relays)));
2950    }
2951
2952    let ep = builder
2953        .bind_addr(bind_v4)
2954        .map_err(|e| anyhow::anyhow!("invalid bind addr {bind_v4}: {e}"))?
2955        .bind_addr_with_opts(bind_v6, BindOpts::default().set_is_required(false))
2956        .map_err(|e| anyhow::anyhow!("invalid bind addr {bind_v6}: {e}"))?
2957        .bind()
2958        .await
2959        .map_err(|e| anyhow::anyhow!("endpoint bind {bind_v4} + {bind_v6} failed: {e}"))?;
2960    warn_if_no_ipv6_socket(&ep.bound_sockets(), bind_v6);
2961    // A relay sits on the NAT-traversal critical path, so bring-up reports
2962    // whether one connected. The check is advisory and runs in the background:
2963    // a transient relay outage during a rolling restart must not delay or
2964    // wedge startup, and iroh keeps retrying the relay on its own.
2965    tokio::spawn(report_home_relay(ep.home_relay_status(), HOME_RELAY_GRACE));
2966    Ok(ep)
2967}
2968
2969/// How long after bind the node waits for a home relay connection before
2970/// [`report_home_relay`] warns.
2971const HOME_RELAY_GRACE: Duration = Duration::from_secs(15);
2972
2973/// Wait up to `grace` for any home relay to connect, and warn with each
2974/// relay's last connection error when none does. Returns whether a relay
2975/// connected in time.
2976///
2977/// The status comes from iroh's relay client, so it reflects relay-protocol
2978/// health end to end: a fronting load balancer that accepts TCP while the
2979/// relay behind it is down reports as not connected. A relay that denies this
2980/// node's authentication is called out separately, since iroh's retries
2981/// present the same credentials and never recover from it. Relay URLs and
2982/// error chains are logged without credentials.
2983async fn report_home_relay(
2984    mut status: impl iroh::Watcher<Value = Vec<iroh::endpoint::RelayStatus>>,
2985    grace: Duration,
2986) -> bool {
2987    let deadline = tokio::time::sleep(grace);
2988    tokio::pin!(deadline);
2989    let mut relays = status.get();
2990    loop {
2991        if let Some(up) = relays.iter().find(|relay| relay.is_connected()) {
2992            tracing::info!(
2993                relay = %redact_userinfo(up.url().as_str()),
2994                "home relay connected"
2995            );
2996            return true;
2997        }
2998        tokio::select! {
2999            () = &mut deadline => break,
3000            next = status.updated() => match next {
3001                Ok(value) => relays = value,
3002                // The last endpoint handle is gone; nothing left to report on.
3003                Err(_) => return false,
3004            },
3005        }
3006    }
3007    warn_no_home_relay(&relays, grace);
3008    false
3009}
3010
3011/// The warning half of [`report_home_relay`]: one line per home relay with
3012/// its last error, or one line when iroh has not selected a relay at all.
3013fn warn_no_home_relay(relays: &[iroh::endpoint::RelayStatus], grace: Duration) {
3014    if relays.is_empty() {
3015        tracing::warn!(
3016            ?grace,
3017            "no home relay selected after bring-up; relay-assisted connectivity is down \
3018             while iroh keeps retrying. Check network.relay_urls and outbound HTTPS"
3019        );
3020    }
3021    for relay in relays {
3022        let url = redact_userinfo(relay.url().as_str());
3023        if let Some(reason) = relay.auth_denied_reason() {
3024            tracing::warn!(
3025                relay = %url,
3026                reason = %sanitize_rpc_display(reason),
3027                "home relay denied this node's authentication; retries will not recover \
3028                 until the relay's access policy changes"
3029            );
3030        } else {
3031            tracing::warn!(
3032                relay = %url,
3033                ?grace,
3034                error = relay.last_error().map(|e| sanitize_error_sources(e)),
3035                "home relay not connected after bring-up; iroh keeps retrying in the \
3036                 background. Check the relay URL and that the relay is up"
3037            );
3038        }
3039    }
3040}
3041
3042/// Log a warning when the endpoint holds no IPv6 socket. `build_endpoint`
3043/// binds `[::]:bind_port` as optional, so iroh drops a failed IPv6 bind with
3044/// only an info-level note. This warning tells the operator that the node is
3045/// IPv4-only and that an `/ip6/...` multiaddr does not reach it. Returns
3046/// whether an IPv6 socket is bound.
3047fn warn_if_no_ipv6_socket(bound: &[SocketAddr], bind_v6: SocketAddrV6) -> bool {
3048    let has_v6 = bound.iter().any(SocketAddr::is_ipv6);
3049    if !has_v6 {
3050        tracing::warn!(
3051            %bind_v6,
3052            "IPv6 bind failed; the node listens on IPv4 only — an /ip6/ multiaddr does not \
3053             reach it. Enable IPv6 on the host or free the port, or register /ip4/ \
3054             multiaddrs only"
3055        );
3056    }
3057    has_v6
3058}
3059
3060/// Compose the operator-configured `network.discovery` address-lookup legs onto
3061/// `builder` (#818 scope 1) — the `node`-side half of the discovery-provider
3062/// seam: config resolution holds plain shape-validated Strings, and this is
3063/// where they become iroh providers.
3064///
3065/// - `pkarr_url` + `dns_origin` wire a `PkarrPublisher` (this node publishes its
3066///   signed address record, filtered by `node_publish_filter`) and a
3067///   `DnsAddressLookup` (it resolves peers via DNS TXT). The endpoint builder pulls this node's secret key and TLS config into
3068///   the publisher automatically, so we do not pass the key here.
3069/// - `peers` seed a `MemoryLookup` static address book.
3070///
3071/// The `pkarr_url` (parsed as `url::Url`) and peer `node_id` (parsed as
3072/// `iroh::PublicKey`) are re-parsed here with the exact parsers config
3073/// resolution used, so a failure on those legs is an internal invariant break.
3074/// A peer `relay_url` is parsed as the stricter `iroh::RelayUrl` here while
3075/// resolution only checked it as a generic `url::Url` (mirroring the top-level
3076/// `network.relay_urls` path, where bring-up's `RelayUrl` parse is the
3077/// authoritative relay-shape gate) — so that leg can legitimately fail here on a
3078/// relay-specific shape issue. Every failure is surfaced as a bring-up error
3079/// (never a panic), and any echoed URL is `redact_userinfo`'d, upholding the
3080/// same no-credentials-in-errors invariant as the relay path.
3081fn add_discovery_lookups(
3082    mut builder: iroh::endpoint::Builder,
3083    discovery: &ResolvedDiscovery,
3084) -> anyhow::Result<iroh::endpoint::Builder> {
3085    if let Some(pkarr) = &discovery.pkarr_url {
3086        let url = pkarr.parse::<url::Url>().map_err(|e| {
3087            anyhow::anyhow!(
3088                "invalid network.discovery.pkarr_url {:?}: {e}",
3089                redact_userinfo(pkarr)
3090            )
3091        })?;
3092        builder =
3093            builder.address_lookup(PkarrPublisher::builder(url).addr_filter(node_publish_filter()));
3094    }
3095    if let Some(origin) = &discovery.dns_origin {
3096        builder = builder.address_lookup(DnsAddressLookup::builder(origin.clone()));
3097    }
3098    if !discovery.peers.is_empty() {
3099        let mut infos = Vec::with_capacity(discovery.peers.len());
3100        for peer in &discovery.peers {
3101            let id = peer.node_id.parse::<PublicKey>().map_err(|e| {
3102                anyhow::anyhow!("invalid network.discovery peer id {}: {e}", peer.node_id)
3103            })?;
3104            let mut addr = EndpointAddr::new(id);
3105            if let Some(relay) = &peer.relay_url {
3106                let relay_url = relay.parse::<RelayUrl>().map_err(|e| {
3107                    anyhow::anyhow!(
3108                        "invalid relay_url {:?} for network.discovery peer {}: {e}",
3109                        redact_userinfo(relay),
3110                        peer.node_id
3111                    )
3112                })?;
3113                addr = addr.with_relay_url(relay_url);
3114            }
3115            for a in &peer.addrs {
3116                let sock = a.parse::<std::net::SocketAddr>().map_err(|e| {
3117                    anyhow::anyhow!(
3118                        "invalid addr {a:?} for network.discovery peer {}: {e}",
3119                        peer.node_id
3120                    )
3121                })?;
3122                addr = addr.with_ip_addr(sock);
3123            }
3124            infos.push(addr);
3125        }
3126        builder = builder.address_lookup(MemoryLookup::from_endpoint_info(infos));
3127    }
3128    Ok(builder)
3129}
3130
3131/// Parse the configured relay URL strings into `RelayUrl`s, surfacing the
3132/// offending entry on a parse failure. The entry is `redact_userinfo`'d first:
3133/// a malformed URL can still carry `user:pass@` credentials, and this node
3134/// never lets relay userinfo reach the logs (same invariant
3135/// [`report_home_relay`] upholds).
3136fn parse_relay_urls(urls: &[String]) -> anyhow::Result<Vec<RelayUrl>> {
3137    urls.iter()
3138        .map(|u| {
3139            u.parse::<RelayUrl>().map_err(|e| {
3140                anyhow::anyhow!(
3141                    "invalid network.relay_urls entry {:?}: {e}",
3142                    redact_userinfo(u)
3143                )
3144            })
3145        })
3146        .collect()
3147}
3148
3149/// Resolve the keystore password, then decrypt the keystore JSON via alloy's
3150/// KDF on a blocking thread.
3151///
3152/// Sources in precedence order: the `DECDN_KEYSTORE_PASSWORD` env var, then
3153/// `blockchain.keystore_password_file`, then an interactive prompt on a TTY.
3154/// Presence decides at each step (see [`eth_identity::read_password`]): a
3155/// variable that is set and a file that exists each supply their value, the
3156/// empty string included, so a headless host reaches one of the first two or
3157/// the startup fails. The list comes from
3158/// [`eth_identity::standard_sources`], the single builder every binary shares.
3159///
3160/// The Arbitrum Sepolia
3161/// chain id is bound on the signer so EIP-712 signers and any
3162/// `eth_sendTransaction` paths inherit a deterministic value. To target a
3163/// different chain, thread the value through `ResolvedBlockchain` next to
3164/// `rpc_url` (chain-id-keyed config is already a seam pattern; see
3165/// `appendix-poc-production-seams.md` §Seam 8).
3166async fn load_eth_signer(cfg: &ResolvedConfig) -> anyhow::Result<PrivateKeySigner> {
3167    use alloy::signers::Signer;
3168
3169    let sources = eth_identity::standard_sources(
3170        cfg.blockchain.keystore_password_file.clone(),
3171        eth_identity::PasswordUse::Unlock,
3172    );
3173    let resolved = eth_identity::read_password(&sources, "eth keystore password")?;
3174    // Surface a configured-but-unused password source (a typo'd or shadowed
3175    // password file). The daemon has a `tracing` subscriber, so unlike the CLI
3176    // it logs rather than printing.
3177    for warning in resolved.warnings() {
3178        tracing::warn!(%warning, "keystore password source ignored");
3179    }
3180    let password = resolved.into_secret();
3181
3182    let path = cfg.blockchain.eth_keystore.clone();
3183    // `spawn_blocking` because alloy's `decrypt_keystore` runs scrypt /
3184    // argon2 (synchronous, CPU-bound). Calling it on the runtime's worker
3185    // thread would stall every other task for the duration of the KDF.
3186    let signer = tokio::task::spawn_blocking(move || eth_identity::load_signer(&path, &password))
3187        .await
3188        .map_err(|e| anyhow::anyhow!("keystore decrypt task panicked: {e}"))??;
3189    // Bind the signer to the *configured* chain id, not a hardcoded
3190    // constant, so the signer's chain id and the `slash_sig` EIP-712 domain
3191    // chain id (also `cfg.blockchain.chain_id`, see the slash domain build)
3192    // can never silently diverge — e.g. when the chain target changes via
3193    // the seam in `appendix-poc-production-seams.md` §Seam 8. Note: raw
3194    // EIP-712 `sign_hash_sync` does not consult the signer's bound chain
3195    // id, so this binding only matters for any future `eth_sendTransaction`
3196    // path; keeping it single-sourced is defensive against that future code.
3197    Ok(signer.with_chain_id(Some(cfg.blockchain.chain_id)))
3198}
3199
3200/// Log a `JoinError` from a shutdown-drained task with a phase label.
3201fn log_join_result(result: Result<(), tokio::task::JoinError>, phase: &'static str) {
3202    if let Err(err) = result {
3203        if err.is_cancelled() {
3204            tracing::debug!(phase, "task cancelled during shutdown");
3205        } else {
3206            tracing::warn!(phase, error = %err, "task failed during shutdown");
3207        }
3208    }
3209}
3210
3211/// Admission/eviction policy selection (ADR 040) plus the ADR 041 estimator
3212/// decoupling. One shared frequency estimator feeds both the engine's
3213/// hit-signal sink and whichever policy objects need it; the engine itself
3214/// owns no policy knowledge beyond the estimator handle. The ADR 041 `margin`
3215/// serve-economics policy also consumes the estimator's heat signal (as its
3216/// buy-ceiling `n_hat` input), independent of which cache eviction/admission
3217/// policy is selected — so a node running plain `lru`/`always` cache policy
3218/// with `margin` serve economics still needs the estimator built and wired
3219/// into the cache. Returns the selected eviction policy and the estimator
3220/// itself (`None` when no consumer requested one) — callers thread the latter
3221/// on to `Infra::frequency_estimator` for the ADR 041 buy-side gate.
3222fn wire_cache_policies(
3223    cache_cfg: &decdn_common::config::resolved::ResolvedCache,
3224    cache: &CacheEngine,
3225) -> (
3226    Arc<dyn decdn_cache::EvictionPolicy>,
3227    Option<Arc<dyn decdn_cache::FrequencyEstimator>>,
3228) {
3229    let want_estimator = cache_cfg.eviction_policy == "tinylfu"
3230        || cache_cfg.admission_policy == "tinylfu"
3231        || cache_cfg.serve_economics.policy == "margin";
3232    let estimator: Option<Arc<dyn decdn_cache::FrequencyEstimator>> = want_estimator.then(|| {
3233        Arc::new(decdn_cache::policy::tinylfu::TinyLfuEstimator::new(
3234            cache_cfg.tinylfu.sketch_bytes,
3235        )) as Arc<dyn decdn_cache::FrequencyEstimator>
3236    });
3237    if let Some(est) = &estimator {
3238        cache.set_frequency_estimator(est.clone());
3239    }
3240    if cache_cfg.admission_policy == "tinylfu"
3241        && let Some(est) = &estimator
3242    {
3243        cache.set_admission_policy(Arc::new(decdn_cache::policy::tinylfu::ProbationAdmission {
3244            freq: est.clone(),
3245            promotion_threshold: cache_cfg.tinylfu.promotion_threshold,
3246        }));
3247    }
3248    // promotion_threshold + probation_target_pct live entirely on the policy
3249    // object, not on the engine or the eviction driver's `EvictionParams`.
3250    let eviction_policy: Arc<dyn decdn_cache::EvictionPolicy> =
3251        match cache_cfg.eviction_policy.as_str() {
3252            "tinylfu" => match &estimator {
3253                Some(est) => Arc::new(decdn_cache::policy::tinylfu::TinyLfuEviction::new(
3254                    est.clone(),
3255                    cache_cfg.tinylfu.promotion_threshold,
3256                    cache_cfg.tinylfu.probation_target_pct,
3257                )),
3258                // Unreachable: `want_estimator` is true whenever eviction_policy
3259                // == "tinylfu", so `estimator` is always `Some` here.
3260                None => Arc::new(decdn_cache::policy::LruEviction),
3261            },
3262            _ => Arc::new(decdn_cache::policy::LruEviction),
3263        };
3264
3265    // `tinylfu` admission only does useful work paired with `tinylfu` eviction:
3266    // promotion out of probation and the probation cap both live in
3267    // `TinyLfuEviction::plan`. With `lru` eviction the probation labels are set
3268    // but never promoted or capped, and the estimator pays a per-serve cost for
3269    // no effect. Warn rather than silently no-op (the resolver already rejects
3270    // typos; this valid-but-inert combination deserves a heads-up).
3271    if cache_cfg.admission_policy == "tinylfu" && cache_cfg.eviction_policy != "tinylfu" {
3272        tracing::warn!(
3273            admission_policy = %cache_cfg.admission_policy,
3274            eviction_policy = %cache_cfg.eviction_policy,
3275            "cache.admission_policy = \"tinylfu\" is inert unless cache.eviction_policy is \
3276             also \"tinylfu\": probation admission relies on the tinylfu eviction policy to \
3277             promote and cap probation members; under lru eviction the labels do nothing and \
3278             the frequency estimator runs for no effect",
3279        );
3280    }
3281
3282    (eviction_policy, estimator)
3283}
3284
3285/// Construct the cache engine from resolved config. The
3286/// `[cache.origins]` array picks an ordered list of backends — HTTP,
3287/// filesystem, or S3 (#437, #284). The singular `[cache.origin]` TOML
3288/// form is collapsed by the resolver into a single-element vec, so
3289/// this function sees one canonical representation. An empty vec means
3290/// no pull-through is configured; the engine then serves only
3291/// already-cached content and cache misses surface as
3292/// `CacheError::NoOrigin`.
3293///
3294/// Init failure is fail-fast: if origin #i can't be constructed (bad
3295/// path, S3 auth failure, etc.) the node refuses to start and the
3296/// context chain identifies which entry. Skip-and-warn would let a
3297/// misconfigured fallback rot silently until the primary fails — the
3298/// opposite of operator intent.
3299///
3300/// `node_metrics` provides the shared `Arc<CacheMetrics>` that the
3301/// engine bumps on origin fetches, retry exhaustions (#285), and
3302/// chain-walk fallback advances (#284).
3303async fn build_cache(
3304    cfg: &ResolvedConfig,
3305    node_metrics: Arc<metrics::Metrics>,
3306    node_origin: Option<Arc<dyn Origin>>,
3307) -> anyhow::Result<CacheEngine> {
3308    // HTTP and S3 origins capture the runtime they are built on and run every
3309    // request send there, so their pooled connections outlive the pull-leg
3310    // runtimes that call them. This runs on the node's main runtime.
3311    let mut origins: Vec<Arc<dyn Origin>> = Vec::with_capacity(cfg.cache.origins.len());
3312    for (idx, resolved) in cfg.cache.origins.iter().enumerate() {
3313        let backend: Arc<dyn Origin> = match resolved {
3314            ResolvedOrigin::Http { url, decompress } => Arc::new(
3315                HttpOrigin::new_with_user_agent(url.clone(), &cfg.cache.user_agent)
3316                    .with_context(|| {
3317                        format!("failed to build HTTP origin client for cache.origins[{idx}]")
3318                    })?
3319                    .with_decompress_mode(*decompress),
3320            ),
3321            ResolvedOrigin::Fs { path } => {
3322                Arc::new(FilesystemOrigin::new(path.clone()).await.with_context(|| {
3323                    format!("failed to open filesystem origin for cache.origins[{idx}]")
3324                })?)
3325            }
3326            // S3-compatible origin (#437). Conversion from the resolved-
3327            // config form to the cache-crate's runtime form happens here
3328            // because `decdn-cache` deliberately doesn't depend on
3329            // `decdn-common` (#578); `node` is the crate that sees both.
3330            ResolvedOrigin::S3(s3_cfg) => Arc::new(
3331                S3Origin::new(&s3_origin_config_from_resolved(s3_cfg))
3332                    .await
3333                    .with_context(|| {
3334                        format!("failed to construct S3 origin client for cache.origins[{idx}]")
3335                    })?
3336                    .with_decompress_mode(s3_cfg.decompress),
3337            ),
3338        };
3339        origins.push(backend);
3340    }
3341    // Append the node-to-node pull origin LAST so configured HTTP/FS/S3 origins
3342    // are tried first and the paid network pull is the final fallback (#831).
3343    if let Some(node_origin) = node_origin {
3344        origins.push(node_origin);
3345    }
3346    let engine = CacheEngine::open_full(
3347        &cfg.cache.cache_dir,
3348        origins,
3349        cfg.cache.max_blob_size_mb,
3350        cfg.cache.pinned_hashes.clone(),
3351        cfg.cache.origin_retry,
3352        cfg.cache.circuit_breaker,
3353        Some(node_metrics.cache_metrics()),
3354        std::time::Duration::from_secs(cfg.cache.gc_interval_sec),
3355    )
3356    .await
3357    .context("failed to open cache engine")?;
3358    // `cache.*` is restart-required (not hot-reloaded), so applying the
3359    // probe-hold budget once here is sufficient (ADR 005 §Hold budget, #318).
3360    engine.set_max_probe_holds(cfg.cache.max_probe_holds);
3361    // One origin read on the range-pull path (an outboard fetch or one data
3362    // window) gets the streaming stage's throughput floor: the stall window as a
3363    // head start, plus the read size at the minimum throughput. The budget
3364    // scales with the read, so it never caps the blob size, and a stuck origin
3365    // ends the fill rather than parking it until the client gives up (ADR 037).
3366    engine.set_origin_read_budget(
3367        std::time::Duration::from_secs(cfg.cache.node_pull_stall_window_sec),
3368        cfg.cache.node_pull_min_throughput_bps,
3369    );
3370    // Live-origin probe memo (#1130 pt3) — likewise restart-configured once.
3371    engine.set_origin_probe_config(decdn_cache::origin_probe::OriginProbePolicy {
3372        positive_ttl: std::time::Duration::from_secs(cfg.cache.origin_probe_ttl_sec),
3373        negative_ttl: std::time::Duration::from_secs(cfg.cache.origin_probe_negative_ttl_sec),
3374        fault_ttl: std::time::Duration::from_secs(cfg.cache.origin_probe_fault_ttl_sec),
3375        timeout: std::time::Duration::from_millis(cfg.cache.origin_probe_timeout_ms),
3376        capacity: usize::try_from(cfg.cache.origin_probe_memo_capacity).unwrap_or(usize::MAX),
3377    });
3378    Ok(engine)
3379}
3380
3381/// Translate the `decdn-common` resolved-config S3 form into the
3382/// `decdn-cache` runtime form. The two types carry the same data —
3383/// they exist as separate types only because `decdn-cache` deliberately
3384/// doesn't pull `decdn-common` (the dep direction is `common -> cache`).
3385///
3386/// Credential redaction (`SecretString::expose`) happens here, at the
3387/// crate boundary: the SDK call site gets plain `String`s, and the
3388/// `Debug`-redacted wrapper stays inside the config layer where it
3389/// guards against incidental log leaks.
3390fn s3_origin_config_from_resolved(cfg: &decdn_common::config::ResolvedS3Config) -> S3OriginConfig {
3391    let credentials = cfg.credentials.as_ref().map(|c| match c {
3392        ResolvedS3Credentials::Static {
3393            access_key_id,
3394            secret_access_key,
3395            session_token,
3396        } => S3Credentials::Static {
3397            access_key_id: access_key_id.expose().to_string(),
3398            secret_access_key: secret_access_key.expose().to_string(),
3399            session_token: session_token.as_ref().map(|t| t.expose().to_string()),
3400        },
3401        ResolvedS3Credentials::DefaultChain { profile } => S3Credentials::DefaultChain {
3402            profile: profile.clone(),
3403        },
3404    });
3405    S3OriginConfig {
3406        bucket: cfg.bucket.clone(),
3407        region: cfg.region.clone(),
3408        endpoint_url: cfg.endpoint_url.clone(),
3409        path_style: cfg.path_style,
3410        prefix: cfg.prefix.clone(),
3411        credentials,
3412    }
3413}
3414
3415/// Stable comma-separated label for the resolved-origin chain, used
3416/// in startup logs so operators can grep for `origin_kinds=http,s3`
3417/// without parsing the structured fields back out. Returns `"none"`
3418/// when the chain is empty (rather than emitting an empty string) so
3419/// the field is always present and machine-parseable.
3420fn origin_kinds_label(origins: &[ResolvedOrigin]) -> String {
3421    if origins.is_empty() {
3422        return "none".to_string();
3423    }
3424    origins
3425        .iter()
3426        .map(|o| match o {
3427            ResolvedOrigin::Http { .. } => "http",
3428            ResolvedOrigin::Fs { .. } => "fs",
3429            ResolvedOrigin::S3(_) => "s3",
3430        })
3431        .collect::<Vec<_>>()
3432        .join(",")
3433}
3434
3435/// Which OS signal (or admin RPC call) triggered shutdown. Returned by
3436/// [`ShutdownStreams::recv`] or synthesised by the `drain_trigger` arm so
3437/// the "shutdown signal received" log line records the cause (SIGINT,
3438/// SIGTERM, or admin drain) — operators grep that field for post-incident
3439/// analysis. `Sigterm` is unreachable on non-unix targets.
3440#[derive(Debug, Clone, Copy)]
3441enum ShutdownSignal {
3442    Sigint,
3443    #[cfg(unix)]
3444    Sigterm,
3445    /// Graceful shutdown requested via `admin_v1_drain` (issue #244).
3446    AdminDrain,
3447}
3448
3449impl std::fmt::Display for ShutdownSignal {
3450    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
3451        match self {
3452            Self::Sigint => f.write_str("SIGINT"),
3453            #[cfg(unix)]
3454            Self::Sigterm => f.write_str("SIGTERM"),
3455            Self::AdminDrain => f.write_str("admin-drain"),
3456        }
3457    }
3458}
3459
3460/// Whether the admin server's stop signal fires before or after
3461/// `router.shutdown()` for this drain (issue #604). `Early` is the
3462/// SIGTERM-equivalent default; `AfterRouter` opts in to keeping the
3463/// admin port alive so `decdn node drain --wait` can poll
3464/// `admin_v1_health.in_flight_streams` until it reaches 0.
3465#[derive(Debug, Clone, Copy, PartialEq, Eq)]
3466enum AdminStopOrder {
3467    Early,
3468    AfterRouter,
3469}
3470
3471/// Decide the admin shutdown ordering for a drain.
3472///
3473/// `AfterRouter` is selected **only** when the drain originated from
3474/// the admin RPC (`AdminDrain`) *and* the in-flight RPC asked for
3475/// `wait_admin: true`. Signal-driven shutdowns (SIGINT/SIGTERM)
3476/// always pick `Early` — even if an `admin_v1_drain { wait_admin:
3477/// true }` call was in-flight when the signal landed, the operator's
3478/// stated intent wins. This prevents a "sticky `wait_admin`" race
3479/// where an RPC stored the flag, SIGTERM won the select, and the
3480/// runtime would otherwise take the `AfterRouter` path against the
3481/// operator's signal (issue #604 review).
3482fn admin_stop_order(signal: ShutdownSignal, drain_trigger: &admin::DrainTrigger) -> AdminStopOrder {
3483    if matches!(signal, ShutdownSignal::AdminDrain) && drain_trigger.wait_admin() {
3484        AdminStopOrder::AfterRouter
3485    } else {
3486        AdminStopOrder::Early
3487    }
3488}
3489
3490/// Persistent SIGHUP stream, installed once at startup. The
3491/// `Signal` instance lives on `self` for the lifetime of the runtime
3492/// so successive SIGHUPs delivered while a reload is in progress are
3493/// queued by tokio (kernel-coalesced) rather than dropped. Re-creating
3494/// the `Signal` per iteration of the select loop would race signal
3495/// delivery — that's the bug this struct exists to prevent.
3496///
3497/// On non-Unix targets `recv` returns a `pending` future since SIGHUP
3498/// doesn't exist there.
3499struct HupStream {
3500    #[cfg(unix)]
3501    hup: Option<tokio::signal::unix::Signal>,
3502}
3503
3504impl HupStream {
3505    fn install() -> Self {
3506        #[cfg(unix)]
3507        {
3508            use tokio::signal::unix::{SignalKind, signal};
3509            let hup = match signal(SignalKind::hangup()) {
3510                Ok(s) => Some(s),
3511                Err(err) => {
3512                    tracing::warn!(error = %err, "failed to install SIGHUP handler; hot-reload disabled");
3513                    None
3514                }
3515            };
3516            Self { hup }
3517        }
3518        #[cfg(not(unix))]
3519        {
3520            Self {}
3521        }
3522    }
3523
3524    /// Wait for the next SIGHUP on the persistent stream. If
3525    /// installation failed (`None`) or the platform isn't Unix, this
3526    /// future never resolves.
3527    ///
3528    /// `Signal::recv` resolves to `Option<()>`. `None` indicates the
3529    /// underlying stream has been closed (the driver dropped, signal
3530    /// handler torn down) — once that happens the future would resolve
3531    /// *immediately, forever*, and the runtime select-loop would
3532    /// hot-spin. Park on `pending` in that branch instead so hot-reload
3533    /// is silently disabled rather than turning into a spin-loop.
3534    async fn recv(&mut self) {
3535        #[cfg(unix)]
3536        {
3537            // Three cases:
3538            //   1. install succeeded + stream yielded a real signal →
3539            //      return so the caller runs the reload arm.
3540            //   2. install succeeded but the stream is now closed →
3541            //      `recv()` returns `None`, which would otherwise
3542            //      resolve *immediately, forever* and hot-spin the
3543            //      runtime's select. Take ownership, log once, and park
3544            //      on `pending` for the rest of the process.
3545            //   3. install failed at startup (`self.hup` is `None`) →
3546            //      already logged a warning at install time; just park
3547            //      on `pending`.
3548            if let Some(stream) = self.hup.as_mut() {
3549                if stream.recv().await.is_some() {
3550                    return;
3551                }
3552                self.hup = None;
3553                tracing::warn!("SIGHUP stream closed; hot-reload disabled for this process");
3554            }
3555            std::future::pending::<()>().await;
3556        }
3557        #[cfg(not(unix))]
3558        {
3559            std::future::pending::<()>().await
3560        }
3561    }
3562}
3563
3564/// Persistent SIGINT/SIGTERM streams. Installed once at startup so
3565/// rapid-fire shutdown signals (e.g. operator pressing Ctrl-C twice)
3566/// are coalesced by the kernel rather than potentially lost between
3567/// re-registrations of `Signal`.
3568struct ShutdownStreams {
3569    /// `tokio::signal::ctrl_c` is itself a one-shot future that
3570    /// re-registers internally — but `tokio::signal::unix::signal`
3571    /// (used for SIGTERM) is not. We park `ctrl_c()` inside `recv`
3572    /// directly and rely on tokio's own implementation for SIGINT.
3573    #[cfg(unix)]
3574    term: Option<tokio::signal::unix::Signal>,
3575}
3576
3577impl ShutdownStreams {
3578    fn install() -> Self {
3579        #[cfg(unix)]
3580        {
3581            use tokio::signal::unix::{SignalKind, signal};
3582            let term = match signal(SignalKind::terminate()) {
3583                Ok(s) => Some(s),
3584                Err(err) => {
3585                    tracing::warn!(
3586                        error = %err,
3587                        "failed to install SIGTERM handler; falling back to SIGINT only",
3588                    );
3589                    None
3590                }
3591            };
3592            Self { term }
3593        }
3594        #[cfg(not(unix))]
3595        {
3596            Self {}
3597        }
3598    }
3599
3600    /// Wait for the next SIGINT or (on Unix) SIGTERM and report which
3601    /// one fired. The persistent SIGTERM stream lives on `self`, so
3602    /// successive calls observe successive signals — there's no
3603    /// race window between iterations of the runtime's select loop.
3604    ///
3605    /// `Signal::recv` resolves to `Option<()>`. A `None` from the
3606    /// SIGTERM arm would otherwise resolve immediately forever and
3607    /// short-circuit the select to a spurious shutdown; demote that
3608    /// branch to a `pending` future so we wait for a real SIGINT
3609    /// instead. (`tokio::signal::ctrl_c` re-registers internally and
3610    /// is safe to await directly.)
3611    async fn recv(&mut self) -> ShutdownSignal {
3612        #[cfg(unix)]
3613        {
3614            if let Some(term) = self.term.as_mut() {
3615                let race = tokio::select! {
3616                    _ = tokio::signal::ctrl_c() => Some(ShutdownSignal::Sigint),
3617                    res = term.recv() => res.map(|()| ShutdownSignal::Sigterm),
3618                };
3619                if let Some(sig) = race {
3620                    return sig;
3621                }
3622                // SIGTERM arm closed (`None`). Drop the stream so we
3623                // don't keep racing a future that resolves immediately
3624                // forever, then wait for a real SIGINT.
3625                tracing::warn!("SIGTERM stream closed; falling back to SIGINT only");
3626                self.term = None;
3627            }
3628            let _ = tokio::signal::ctrl_c().await;
3629            ShutdownSignal::Sigint
3630        }
3631        #[cfg(not(unix))]
3632        {
3633            let _ = tokio::signal::ctrl_c().await;
3634            ShutdownSignal::Sigint
3635        }
3636    }
3637}
3638
3639/// Classification of an RPC preflight/watchdog probe (#1106/#1108). A
3640/// rate-limited (`429`) or server-side (`5xx`) response — and any
3641/// timeout/connection error — is [`Transient`](RpcProbe::Transient): the startup
3642/// preflight retries it with backoff rather than aborting the process, so a
3643/// commodity endpoint that throttles the startup burst does not crash-loop the
3644/// node. Any other non-2xx (a `4xx` such as a bad path/auth) is
3645/// [`Fatal`](RpcProbe::Fatal) — retrying will not help.
3646enum RpcProbe {
3647    Healthy,
3648    Transient(String),
3649    Fatal(String),
3650}
3651
3652/// Retry budget for [`check_deployment_preflight`]'s chain reads. Its own
3653/// [`BootRetry`] budget, independent of the shared boot deadline the chain
3654/// bootstraps below share: the RPC endpoint has just passed
3655/// [`check_rpc_reachability`], so a transient hiccup clears fast, and a real
3656/// outage should not hold the lane store hostage for long.
3657const DEPLOYMENT_PREFLIGHT_BUDGET: Duration = Duration::from_mins(1);
3658
3659/// Verify the configured `PaymentPool` deployment against the live chain:
3660/// the RPC's `eth_chainId` must equal `blockchain.chain_id`, the configured
3661/// address must have code, and that code must answer
3662/// `PaymentPool.getRateBounds()` — a code-presence check alone would pass a
3663/// sibling contract pasted from the same deploy manifest (`CapacityBond`,
3664/// `FeeRouter`, `BuybackBurner`, `DecdnGovernor`, the USDC token), and that is
3665/// the likeliest wrong-address typo. `getRateBounds()` is the probe because
3666/// only `PaymentPool` declares it: `usdc()` is also a view on `FeeRouter` and
3667/// `BuybackBurner`, and `feeRouter()` is also a view on `DecdnGovernor`. The
3668/// probe holds only while that stays true: no other contract in the deployment
3669/// may declare a function with the `getRateBounds()` selector, and
3670/// `contracts/src/PaymentPool.sol` carries the same warning on the view. The
3671/// `deployment_preflight_guard` e2e boots against every contract in the deploy
3672/// manifest to catch a collision, and the `payment_pool_probe_selector` e2e
3673/// fails if any contract in `contracts/src` other than `PaymentPool` exposes
3674/// the selector, deployed or not. Runs BEFORE the lane store opens, because
3675/// the store's deployment binding
3676/// ([`crate::channel_store::Deployment`]) drops the seller lane state when
3677/// the stamp differs — a typo in either field must abort bring-up while the
3678/// store is untouched, not destroy unredeemed lane state on a WARN. The
3679/// reads retry transient RPC errors on `retry`; the mismatch verdicts are
3680/// deterministic and fail at once, and a non-`PaymentPool` target surfaces
3681/// from `getRateBounds()` as a permanent contract error, not a retry spin.
3682async fn check_deployment_preflight<P: Provider + Clone>(
3683    provider: P,
3684    deployment: crate::channel_store::Deployment,
3685    retry: &BootRetry,
3686) -> anyhow::Result<()> {
3687    let (rpc_chain_id, code_len) = retry
3688        .run(
3689            "deployment preflight (eth_chainId + PaymentPool code)",
3690            || {
3691                let provider = provider.clone();
3692                async move {
3693                    let chain_id = provider.get_chain_id().await.context("eth_chainId")?;
3694                    let code = provider
3695                        .get_code_at(deployment.payment_pool)
3696                        .await
3697                        .context("eth_getCode(payment_pool)")?;
3698                    Ok((chain_id, code.len()))
3699                }
3700            },
3701        )
3702        .await?;
3703    anyhow::ensure!(
3704        rpc_chain_id == deployment.chain_id,
3705        "blockchain.chain_id is {} but the RPC endpoint serves chain {rpc_chain_id}; a \
3706         mismatched chain id would rebind the lane store and drop its seller state — fix \
3707         blockchain.chain_id or blockchain.rpc_url",
3708        deployment.chain_id,
3709    );
3710    anyhow::ensure!(
3711        code_len > 0,
3712        "blockchain.payment_pool_address {} has no code on chain {rpc_chain_id}; a wrong \
3713         address would rebind the lane store and drop its seller state — fix \
3714         blockchain.payment_pool_address",
3715        deployment.payment_pool,
3716    );
3717    // Contract-identity probe: `getRateBounds()` is a cheap view every
3718    // `PaymentPool` answers and no sibling contract declares. A contract
3719    // without it reverts (or, with no code path at all, returns no data), and
3720    // either answer is a permanent contract error that aborts at once.
3721    let contract =
3722        decdn_incentive::payment_pool::PaymentPool::new(deployment.payment_pool, provider);
3723    retry
3724        .run(
3725            "deployment preflight (PaymentPool.getRateBounds())",
3726            || async {
3727                contract.getRateBounds().call().await.with_context(|| {
3728                    format!(
3729                        "blockchain.payment_pool_address {} does not answer \
3730                     PaymentPool.getRateBounds() on chain {rpc_chain_id}; the address hosts \
3731                     some other contract, and a wrong address would rebind the lane store and \
3732                     drop its seller state — fix blockchain.payment_pool_address",
3733                        deployment.payment_pool,
3734                    )
3735                })
3736            },
3737        )
3738        .await?;
3739    Ok(())
3740}
3741
3742/// Number of preflight attempts before a persistent transient failure is treated
3743/// as fatal (a real outage still aborts bring-up). Backoff caps at 8s, so the
3744/// worst-case wait is bounded (~5×5s request timeouts + 1+2+4+8s backoff).
3745const RPC_PREFLIGHT_MAX_ATTEMPTS: u32 = 5;
3746
3747/// Verify that the JSON-RPC endpoint is reachable by sending a lightweight
3748/// `net_version` request with a short timeout, retrying a transient (429/5xx/
3749/// timeout) response with bounded backoff (#1108) — a rate-limited endpoint must
3750/// not crash-loop the node at startup. A fatal (other 4xx) response, or an
3751/// exhausted retry budget, aborts bring-up so operators still catch typos and
3752/// dead endpoints before the node binds ports and starts serving.
3753async fn check_rpc_reachability(rpc_url: &str) -> anyhow::Result<()> {
3754    let client = reqwest::Client::builder()
3755        .timeout(Duration::from_secs(5))
3756        .build()
3757        .context("failed to build HTTP client for RPC check")?;
3758    preflight_retry(&client, rpc_url, Duration::from_secs(1)).await
3759}
3760
3761/// Retry loop behind [`check_rpc_reachability`], split out so the retry/classify
3762/// policy is testable with a small `initial_backoff` (the production caller
3763/// passes 1s). Retries a [`RpcProbe::Transient`] with doubling backoff (capped at
3764/// 8s) up to [`RPC_PREFLIGHT_MAX_ATTEMPTS`]; a [`RpcProbe::Fatal`] bails at once.
3765async fn preflight_retry(
3766    client: &reqwest::Client,
3767    rpc_url: &str,
3768    initial_backoff: Duration,
3769) -> anyhow::Result<()> {
3770    let mut backoff = initial_backoff;
3771    for attempt in 1..=RPC_PREFLIGHT_MAX_ATTEMPTS {
3772        match probe_rpc_classified(client, rpc_url).await {
3773            RpcProbe::Healthy => {
3774                tracing::info!("RPC endpoint reachable");
3775                return Ok(());
3776            }
3777            RpcProbe::Fatal(msg) => anyhow::bail!("blockchain.rpc_url {msg}"),
3778            RpcProbe::Transient(msg) if attempt == RPC_PREFLIGHT_MAX_ATTEMPTS => {
3779                anyhow::bail!(
3780                    "blockchain.rpc_url {msg}; still failing after {RPC_PREFLIGHT_MAX_ATTEMPTS} attempts"
3781                );
3782            }
3783            RpcProbe::Transient(msg) => {
3784                tracing::warn!(
3785                    attempt,
3786                    backoff_secs = backoff.as_secs(),
3787                    "RPC preflight transient failure ({msg}); retrying after backoff"
3788                );
3789                tokio::time::sleep(backoff).await;
3790                backoff = (backoff * 2).min(Duration::from_secs(8));
3791            }
3792        }
3793    }
3794    // The loop returns or bails on every path; this is unreachable but keeps the
3795    // signature total without an `unwrap`/`unreachable!` (anti-panic policy).
3796    anyhow::bail!("blockchain.rpc_url preflight exhausted retries")
3797}
3798
3799/// Issue a single `net_version` JSON-RPC probe and classify the outcome (see
3800/// [`RpcProbe`]). Shared by the startup preflight and the watchdog so their
3801/// health verdict can never diverge.
3802async fn probe_rpc_classified(client: &reqwest::Client, rpc_url: &str) -> RpcProbe {
3803    let body = serde_json::json!({
3804        "jsonrpc": "2.0",
3805        "method": "net_version",
3806        "params": [],
3807        "id": 1
3808    });
3809
3810    let resp = match client.post(rpc_url).json(&body).send().await {
3811        Ok(resp) => resp,
3812        // A timeout or connection error is transient — the endpoint may be
3813        // momentarily overloaded (the #1108 startup-burst 429 arrives here as a
3814        // reset on some gateways) rather than misconfigured.
3815        Err(err) => {
3816            return RpcProbe::Transient(format!(
3817                "is not reachable (timeout or connection error): {}",
3818                sanitize_error_sources(&err)
3819            ));
3820        }
3821    };
3822
3823    let status = resp.status();
3824    if status.is_success() {
3825        RpcProbe::Healthy
3826    } else if status.as_u16() == 429 || status.is_server_error() {
3827        RpcProbe::Transient(format!(
3828            "returned status {status} (rate-limited or server error)"
3829        ))
3830    } else {
3831        RpcProbe::Fatal(format!(
3832            "returned unexpected status {status}; verify the endpoint is a valid JSON-RPC server"
3833        ))
3834    }
3835}
3836
3837/// Issue a single `net_version` JSON-RPC probe against `rpc_url`. Returns
3838/// `Ok(())` on a healthy (2xx) response, an error otherwise — a thin adapter over
3839/// [`probe_rpc_classified`] preserving the watchdog's original pass/fail
3840/// semantics (it treats any non-2xx as unhealthy for the gauge, transient or not).
3841pub(crate) async fn probe_rpc(client: &reqwest::Client, rpc_url: &str) -> anyhow::Result<()> {
3842    match probe_rpc_classified(client, rpc_url).await {
3843        RpcProbe::Healthy => Ok(()),
3844        RpcProbe::Transient(msg) | RpcProbe::Fatal(msg) => {
3845            anyhow::bail!("blockchain.rpc_url {msg}")
3846        }
3847    }
3848}
3849
3850/// Spawn the RPC connectivity watchdog. On each tick of `interval`, calls
3851/// [`probe_rpc`] and updates `metrics.rpc_healthy(...)`. Logs a `warn`
3852/// when health transitions down and an `info` when it transitions back
3853/// up; steady-state ticks are silent. Stops when `shutdown` resolves.
3854///
3855/// The caller is responsible for seeding the gauge before this is
3856/// spawned — `run()` does so right after the successful
3857/// `check_rpc_reachability` startup probe. The watchdog only writes the
3858/// gauge on tick boundaries. The previous-state tracking starts
3859/// assuming the endpoint is healthy for the same reason, which prevents
3860/// a spurious "recovered" log on the first tick.
3861fn spawn_rpc_watchdog(
3862    client: reqwest::Client,
3863    rpc_url: String,
3864    interval: Duration,
3865    metrics: Arc<metrics::Metrics>,
3866    mut shutdown: oneshot::Receiver<()>,
3867) -> tokio::task::JoinHandle<()> {
3868    tokio::spawn(async move {
3869        // Caller seeded the gauge from the startup probe; see fn docs.
3870        let mut prev_healthy = true;
3871        loop {
3872            tokio::select! {
3873                biased;
3874                _ = &mut shutdown => {
3875                    tracing::debug!("RPC watchdog shutdown signal received");
3876                    return;
3877                }
3878                () = tokio::time::sleep(interval) => {}
3879            }
3880
3881            let now_healthy = match probe_rpc(&client, &rpc_url).await {
3882                Ok(()) => {
3883                    if !prev_healthy {
3884                        // Deliberately omit the URL: the node configures one
3885                        // RPC endpoint, and it may carry basic-auth credentials
3886                        // in the userinfo component.
3887                        tracing::info!("RPC endpoint healthy");
3888                    }
3889                    true
3890                }
3891                Err(err) => {
3892                    if prev_healthy {
3893                        // `probe_rpc_classified` already strips the URL from the
3894                        // transport error before `probe_rpc` wraps it. Strip again
3895                        // here as a backstop: an `rpc_url` can hold an API key in
3896                        // its path or query, not only in userinfo.
3897                        tracing::warn!(error = %sanitize_err_chain(&err), "RPC endpoint unhealthy");
3898                    }
3899                    false
3900                }
3901            };
3902            metrics.rpc_healthy(now_healthy);
3903            prev_healthy = now_healthy;
3904        }
3905    })
3906}
3907
3908#[cfg(test)]
3909#[allow(
3910    clippy::unwrap_used,
3911    clippy::expect_used,
3912    clippy::panic,
3913    clippy::indexing_slicing
3914)]
3915mod tests;