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;