Skip to main content

decdn_node/
binding_check.rs

1//! Startup self-check: is the local iroh key the one bound on-chain? (#1034)
2//!
3//! `SlashJudge._checkRegistered` resolves an accused node through
4//! `CapacityBond.nodeIdOf`, so slashability follows the *binding*, not the key
5//! the daemon happens to serve under. A node whose `node.secret` was replaced
6//! without a matching `bindNodeId` therefore keeps serving and keeps getting
7//! paid while its bond is unreachable — it is unslashable. Nothing else in the
8//! runtime notices: the node is healthy, its channels settle, and its peers
9//! simply stop routing to an id no longer in the staker set, which reads like
10//! ordinary churn.
11//!
12//! That is the gap this check closes, and the reason it exists on the daemon at
13//! all rather than only in `decdn node rotate-key`. The command is written so
14//! the state is unreachable through it — the key file is committed only after
15//! the bind confirms — but the state is reachable *around* it, by an operator
16//! copying a key file, restoring the wrong backup, or running `decdn key-gen
17//! --force` in a data dir that was already registered.
18//!
19//! # Advisory, deliberately
20//!
21//! The result never blocks startup. A daemon that refused to boot on a
22//! mismatch would be brickable by a transient RPC failure, and — worse —
23//! unable to run during the very window an operator needs it up to diagnose
24//! and repair the binding. So the check reports: a `WARN` line at bring-up and
25//! a field on `admin_v1_health`, both naming the repair.
26//!
27//! # Sampled once
28//!
29//! The binding changes only by an explicit operator transaction, so this runs
30//! once during bring-up rather than per health poll — `admin_v1_health` is on
31//! the readiness path, and putting a chain round trip behind it would make
32//! every probe pay for a value that almost never moves.
33
34use alloy::primitives::{Address, B256};
35use alloy::providers::Provider;
36use decdn_common::admin::BindingStatus;
37use decdn_common::redact::sanitize_error_sources;
38use decdn_incentive::capacity_bond::CapacityBond;
39
40/// Outcome of the bring-up binding check, as carried into `AdminState`.
41#[derive(Debug, Clone, Copy, PartialEq, Eq)]
42pub struct BindingReport {
43    /// Whether the operator address is bound, unbound, or could not be read.
44    pub status: BindingStatus,
45    /// The node id the operator is bound to, when it could be read and is
46    /// non-zero. `None` for [`BindingStatus::Unbound`] and
47    /// [`BindingStatus::Unknown`].
48    pub bound_node_id: Option<[u8; 32]>,
49}
50
51impl BindingReport {
52    /// The report for a node with nothing to check against — no chain
53    /// configuration, or a read that failed.
54    #[must_use]
55    pub const fn unknown() -> Self {
56        Self {
57            status: BindingStatus::Unknown,
58            bound_node_id: None,
59        }
60    }
61}
62
63/// Classify a binding read. Pure so every arm is reachable from a unit test —
64/// the chain read that produces `bound` cannot be steered to these values from
65/// a fixture without deploying a contract per case.
66#[must_use]
67pub fn classify(bound: B256, local: B256) -> BindingReport {
68    if bound == B256::ZERO {
69        return BindingReport {
70            status: BindingStatus::Unbound,
71            bound_node_id: None,
72        };
73    }
74    let status = if bound == local {
75        BindingStatus::Bound
76    } else {
77        BindingStatus::Mismatch
78    };
79    BindingReport {
80        status,
81        bound_node_id: Some(bound.into()),
82    }
83}
84
85/// Read `CapacityBond.addressToNodeId(operator)` and compare it with the key
86/// the daemon is about to serve under, logging the outcome.
87///
88/// Never fails: a read error is [`BindingStatus::Unknown`], which is reported
89/// as its own state rather than folded into "fine" — an operator debugging an
90/// unslashable node has to be able to tell "we checked and it is bound" from
91/// "we could not check".
92pub async fn check<P: Provider>(
93    provider: P,
94    capacity_bond: Address,
95    operator: Address,
96    local_node_id: [u8; 32],
97) -> BindingReport {
98    let local = B256::from(local_node_id);
99    let bound = match CapacityBond::new(capacity_bond, provider)
100        .addressToNodeId(operator)
101        .call()
102        .await
103    {
104        Ok(bound) => bound,
105        Err(err) => {
106            tracing::warn!(
107                error = %sanitize_error_sources(&err),
108                %operator,
109                %capacity_bond,
110                "could not read the on-chain node-id binding at startup; slashability of this \
111                 node's key is UNVERIFIED. Check it with `decdn node health`."
112            );
113            return BindingReport::unknown();
114        }
115    };
116
117    let report = classify(bound, local);
118    log(report.status, local, bound, operator);
119    report
120}
121
122/// Emit the bring-up line for a classified binding.
123///
124/// Split from [`check`] so each arm's wording is one small function's worth of
125/// branching rather than a fourth branch inside the read-and-classify flow.
126fn log(status: BindingStatus, local: B256, bound: B256, operator: Address) {
127    match status {
128        BindingStatus::Bound => tracing::info!(
129            node_id = %local,
130            %operator,
131            "local node key matches the on-chain binding"
132        ),
133        // Both remaining arms are the unslashable state, and both name the fix.
134        // They are split because the repairs differ: a mismatch has a key to
135        // choose between, an unbound operator has no registration at all.
136        BindingStatus::Mismatch => tracing::warn!(
137            local_node_id = %local,
138            bound_node_id = %bound,
139            %operator,
140            "this node is serving under a key that is NOT the one bound on-chain, so it cannot be \
141             slashed and peers will not route to it. Either bind the local key with `decdn node \
142             rotate-key --key iroh --bind-existing`, or restore the key the binding names."
143        ),
144        BindingStatus::Unbound => tracing::warn!(
145            local_node_id = %local,
146            %operator,
147            "this operator address has no on-chain node-id binding, so this node cannot be \
148             slashed and peers will not route to it. Register it with `decdn node register` \
149             (after `decdn node bond`)."
150        ),
151        // `check` never produces `Unknown` past its early return.
152        BindingStatus::Unknown => {}
153    }
154}
155
156#[cfg(test)]
157#[allow(clippy::unwrap_used, clippy::expect_used)]
158mod tests;