decdn-node 0.1.0

The deCDN node daemon (decdn-node): runtime, request handlers, payment settlement, and the admin RPC server
//! Liveness of the node's chain-derived compliance state (ADR 011 § Serving
//! while chain-stale).
//!
//! Every serve-path guard that reads on-chain state — the blacklist deny-set,
//! the pool-solvency recheck, the per-signer capability cap — answers from a
//! projection that only advances while the chain watchers can reach the RPC.
//! During an RPC outage each of those guards silently passes on stale data: a
//! takedown, a drained pool, or a spent capability that landed on-chain during
//! the blind window is invisible, so the node keeps signing serve and probe
//! responses it can no longer vouch for. Serving a hash past its compliance
//! window is slashable (ADR 011 § Slashing), and the offense is exactly a signed
//! `StreamResponse{ok:true}` or `ProbeResponse{has_blob:true}` — the node
//! produces its own evidence.
//!
//! [`ChainFreshness`] is the single backstop over that whole family: it holds
//! the wall-clock time of the blacklist watcher's last successful poll tick and
//! answers one question — has the node been unable to reach the chain for longer
//! than the operator's grace window? While it has, the serve and probe paths
//! refuse rather than sign. The signal is the *successful tick*, not the last
//! event: a quiet chain produces no blacklist events for hours legitimately, and
//! keying on events would take every node dark on any quiet stretch. The tick
//! stamps on every successful poll including idle ones (the same liveness the
//! `decdn_blacklist_watcher_last_tick_timestamp_seconds` gauge exports), and the
//! boot enumeration stamps it too, so a freshly-booted node that has just
//! vetted its deny-set is never treated as stale.
//!
//! **Grace, not a switch.** There is one knob — the grace window in seconds — and
//! it is always evaluated. An operator who wants the old fail-open behavior sets
//! the window large (a year); there is no separate enable flag, so there is no
//! "enabled but zero-grace" state to reason about.
//!
//! **Unwired = fail-open.** A node with no chain configured (dev/test) has no
//! blacklist watcher to stamp the cell, so the runtime wires no
//! [`ChainFreshness`] at all and the guards read `None` — the same fail-open
//! shape the pool-view guards use when no chain is present.

use std::sync::Arc;
use std::sync::atomic::{AtomicU64, Ordering};
use std::time::Duration;

/// Live wall clock in seconds since the Unix epoch, or `0` if the system clock
/// is before the epoch. Away from that boundary both the stamp and the staleness
/// read use this, so a backward clock step affects the difference only by the
/// step size — well within a grace window measured in minutes. The `0` case is
/// the exception: it collides with the "never stamped" sentinel
/// [`ChainFreshness::is_stale`] reads, so a clock stuck before the epoch keeps
/// the node stale even after [`ChainFreshness::stamp`] runs. That is the
/// fail-closed direction — a node that cannot read a sane clock refuses to serve
/// rather than trust a staleness comparison it cannot make — so it needs no
/// separate guard.
fn live_secs() -> u64 {
    std::time::SystemTime::now()
        .duration_since(std::time::UNIX_EPOCH)
        .map_or(0, |d| d.as_secs())
}

/// Shared liveness of the node's chain reads. Cloned into the blacklist watcher
/// (which stamps it) and the serve and probe handlers (which read it); every
/// clone shares one cell.
#[derive(Clone, Debug)]
pub struct ChainFreshness {
    /// Unix seconds of the last successful blacklist poll tick (or boot
    /// enumeration). `0` means "never stamped", which reads as stale.
    last_ok_secs: Arc<AtomicU64>,
    /// How long the node may go without a successful chain read before the serve
    /// and probe paths refuse. Always evaluated; a large value is how an operator
    /// opts out.
    grace_secs: u64,
}

impl ChainFreshness {
    /// A freshness handle with the given grace window, not yet stamped (so it
    /// reads stale until the boot enumeration or the first successful tick stamps
    /// it).
    #[must_use]
    pub fn new(grace: Duration) -> Self {
        Self {
            last_ok_secs: Arc::new(AtomicU64::new(0)),
            grace_secs: grace.as_secs(),
        }
    }

    /// Record that a chain read just succeeded (a successful blacklist poll tick,
    /// or the clean boot enumeration). Stamps the current wall clock.
    pub fn stamp(&self) {
        self.last_ok_secs.store(live_secs(), Ordering::Relaxed);
    }

    /// Has the node been unable to reach the chain for longer than the grace
    /// window? A `0` cell is stale by definition — either never stamped, or
    /// stamped from a pre-epoch (or unreadable) clock, which the wall-clock
    /// reader also reports as `0`; both fold into this one fail-closed branch,
    /// because a node with no confirmed-fresh compliance state has nothing sound
    /// to serve against.
    #[must_use]
    pub fn is_stale(&self) -> bool {
        let last = self.last_ok_secs.load(Ordering::Relaxed);
        if last == 0 {
            return true;
        }
        live_secs().saturating_sub(last) > self.grace_secs
    }
}

#[cfg(test)]
mod tests;