decdn_node/chain_freshness.rs
1//! Liveness of the node's chain-derived compliance state (ADR 011 § Serving
2//! while chain-stale).
3//!
4//! Every serve-path guard that reads on-chain state — the blacklist deny-set,
5//! the pool-solvency recheck, the per-signer capability cap — answers from a
6//! projection that only advances while the chain watchers can reach the RPC.
7//! During an RPC outage each of those guards silently passes on stale data: a
8//! takedown, a drained pool, or a spent capability that landed on-chain during
9//! the blind window is invisible, so the node keeps signing serve and probe
10//! responses it can no longer vouch for. Serving a hash past its compliance
11//! window is slashable (ADR 011 § Slashing), and the offense is exactly a signed
12//! `StreamResponse{ok:true}` or `ProbeResponse{has_blob:true}` — the node
13//! produces its own evidence.
14//!
15//! [`ChainFreshness`] is the single backstop over that whole family: it holds
16//! the wall-clock time of the blacklist watcher's last successful poll tick and
17//! answers one question — has the node been unable to reach the chain for longer
18//! than the operator's grace window? While it has, the serve and probe paths
19//! refuse rather than sign. The signal is the *successful tick*, not the last
20//! event: a quiet chain produces no blacklist events for hours legitimately, and
21//! keying on events would take every node dark on any quiet stretch. The tick
22//! stamps on every successful poll including idle ones (the same liveness the
23//! `decdn_blacklist_watcher_last_tick_timestamp_seconds` gauge exports), and the
24//! boot enumeration stamps it too, so a freshly-booted node that has just
25//! vetted its deny-set is never treated as stale.
26//!
27//! **Grace, not a switch.** There is one knob — the grace window in seconds — and
28//! it is always evaluated. An operator who wants the old fail-open behavior sets
29//! the window large (a year); there is no separate enable flag, so there is no
30//! "enabled but zero-grace" state to reason about.
31//!
32//! **Unwired = fail-open.** A node with no chain configured (dev/test) has no
33//! blacklist watcher to stamp the cell, so the runtime wires no
34//! [`ChainFreshness`] at all and the guards read `None` — the same fail-open
35//! shape the pool-view guards use when no chain is present.
36
37use std::sync::Arc;
38use std::sync::atomic::{AtomicU64, Ordering};
39use std::time::Duration;
40
41/// Live wall clock in seconds since the Unix epoch, or `0` if the system clock
42/// is before the epoch. Away from that boundary both the stamp and the staleness
43/// read use this, so a backward clock step affects the difference only by the
44/// step size — well within a grace window measured in minutes. The `0` case is
45/// the exception: it collides with the "never stamped" sentinel
46/// [`ChainFreshness::is_stale`] reads, so a clock stuck before the epoch keeps
47/// the node stale even after [`ChainFreshness::stamp`] runs. That is the
48/// fail-closed direction — a node that cannot read a sane clock refuses to serve
49/// rather than trust a staleness comparison it cannot make — so it needs no
50/// separate guard.
51fn live_secs() -> u64 {
52 std::time::SystemTime::now()
53 .duration_since(std::time::UNIX_EPOCH)
54 .map_or(0, |d| d.as_secs())
55}
56
57/// Shared liveness of the node's chain reads. Cloned into the blacklist watcher
58/// (which stamps it) and the serve and probe handlers (which read it); every
59/// clone shares one cell.
60#[derive(Clone, Debug)]
61pub struct ChainFreshness {
62 /// Unix seconds of the last successful blacklist poll tick (or boot
63 /// enumeration). `0` means "never stamped", which reads as stale.
64 last_ok_secs: Arc<AtomicU64>,
65 /// How long the node may go without a successful chain read before the serve
66 /// and probe paths refuse. Always evaluated; a large value is how an operator
67 /// opts out.
68 grace_secs: u64,
69}
70
71impl ChainFreshness {
72 /// A freshness handle with the given grace window, not yet stamped (so it
73 /// reads stale until the boot enumeration or the first successful tick stamps
74 /// it).
75 #[must_use]
76 pub fn new(grace: Duration) -> Self {
77 Self {
78 last_ok_secs: Arc::new(AtomicU64::new(0)),
79 grace_secs: grace.as_secs(),
80 }
81 }
82
83 /// Record that a chain read just succeeded (a successful blacklist poll tick,
84 /// or the clean boot enumeration). Stamps the current wall clock.
85 pub fn stamp(&self) {
86 self.last_ok_secs.store(live_secs(), Ordering::Relaxed);
87 }
88
89 /// Has the node been unable to reach the chain for longer than the grace
90 /// window? A `0` cell is stale by definition — either never stamped, or
91 /// stamped from a pre-epoch (or unreadable) clock, which the wall-clock
92 /// reader also reports as `0`; both fold into this one fail-closed branch,
93 /// because a node with no confirmed-fresh compliance state has nothing sound
94 /// to serve against.
95 #[must_use]
96 pub fn is_stale(&self) -> bool {
97 let last = self.last_ok_secs.load(Ordering::Relaxed);
98 if last == 0 {
99 return true;
100 }
101 live_secs().saturating_sub(last) > self.grace_secs
102 }
103}
104
105#[cfg(test)]
106mod tests;