decdn_cli/commands/deregister.rs
1//! `decdn node deregister` — leave the active set (ADR 003 § Node Registry).
2//!
3//! The reverse of `decdn node register`, and the first leg of a full bond exit.
4//! `deregisterNode()` clears `declaredMbps`, which is what releases the
5//! `bondRequired(declaredMbps)` floor `requestUnbond` enforces (#1351); the
6//! complete sequence is
7//!
8//! ```text
9//! decdn node deregister -> decdn node unbond --all -> [window] -> decdn node unbond
10//! ```
11//!
12//! Two things about that sequence drive this command's shape, and both are
13//! disclosed rather than left to be discovered:
14//!
15//! - **The bond does not move here.** `deregisterNode` touches the registration
16//! and the tier, nothing else: the deposit stays put and stays fully
17//! slashable. An operator who reads "deregister" as "exit and refund" has
18//! done only the first of three steps. This is the likeliest misconception,
19//! so the summary says so on every run.
20//! - **Re-entry is not free.** The call bumps `registrationNonce[nodeId]`,
21//! which invalidates any registration signature already signed against the
22//! old nonce, and clears the tier — so coming back costs a fresh
23//! `declareMbps` + `registerNode` (the bond itself is retained, so no new
24//! funds are needed; ADR 003 § Node Registry).
25//!
26//! Unlike `unbond` there is no phase to select from chain state — this is one
27//! transaction that either applies or reverts. There is still a pre-flight
28//! read, of registry state (`getNodeByAddress` + `ejected`, NOT the composite
29//! `isActive` — see `build_plan`), so the two states the operator can be in
30//! are named up front instead of surfacing as a bare `NodeNotActive` revert:
31//! already deregistered, or ejected.
32//!
33//! Both exit the same way — `decdn node unbond --all`, which releases the tier
34//! via `declareMbps(0)` for any inactive operator (#1361) — but they need
35//! different advice about RE-ENTRY, which is why the states are distinguished:
36//! a blacklist-ejected operator's `registerNode` reverts until governance calls
37//! `unEjectNode`, so pointing them at `decdn node register` would be wrong.
38
39use std::io::{self, IsTerminal, Write};
40use std::path::Path;
41
42use alloy::primitives::{Address, B256};
43use anyhow::Context;
44use decdn_common::cli;
45use decdn_incentive::capacity_bond::CapacityBond;
46
47use crate::commands::chain_ctx;
48
49/// Entry point for `decdn node deregister`.
50pub async fn run(args: &cli::DeregisterArgs, global_config: Option<&Path>) -> anyhow::Result<()> {
51 let config_path = args.chain.common.config.as_deref().or(global_config);
52 let file = chain_ctx::load_optional_config(config_path)?;
53 let resolved = chain_ctx::resolve(&args.chain, &file)?;
54 let cb_addr = resolved.capacity_bond_address;
55
56 let signer = chain_ctx::load_operator_signer(&args.chain.common, &resolved.keystore).await?;
57 let operator = signer.address();
58 let provider = decdn_client::provider::build_provider(&resolved.rpc_url, &signer)?;
59 let bond = CapacityBond::new(cb_addr, &provider);
60
61 let plan = build_plan(&bond, operator, cb_addr).await?;
62
63 let json = args.chain.common.json;
64 if args.chain.common.dry_run {
65 let mut out = io::stdout().lock();
66 write_plan(&mut out, &plan, json, None, true).context("failed to write dry-run output")?;
67 return Ok(());
68 }
69
70 // Pre-flight rather than a revert: `NodeNotActive` alone does not say WHICH
71 // of the two inactive states the operator is in, and the two have different
72 // exits.
73 ensure_active(&plan)?;
74 confirm_or_bail(&plan, args.yes)?;
75
76 // Caller-owned slot (#1355): from the send onward the transaction is
77 // broadcast and may take effect, so an unreadable receipt must still print
78 // the hash rather than read as "nothing happened".
79 let mut tx = None;
80 let result = chain_ctx::send(
81 bond.deregisterNode(),
82 "deregisterNode",
83 Some("the node must be active — a second deregistration reverts NodeNotActive"),
84 &mut tx,
85 )
86 .await;
87
88 let mut out = io::stdout().lock();
89 write_plan(&mut out, &plan, json, tx.as_ref(), false).context("failed to write result")?;
90 drop(out);
91
92 result.map(|_| ())
93}
94
95/// What `deregisterNode` will do, read from the chain before anything is sent.
96/// Owned so [`write_plan`] and [`confirm_or_bail`] are testable without one.
97#[derive(Clone, Copy, Debug, PartialEq, Eq)]
98pub(crate) struct Plan {
99 pub(crate) capacity_bond: Address,
100 /// Whether the node is currently in the active set — the gate
101 /// `deregisterNode` itself enforces.
102 pub(crate) active: bool,
103 /// Set when the operator was removed by the contract rather than by
104 /// choice. Distinguishes the two `!active` states, which need different
105 /// advice: an ejected operator has no `deregisterNode` to make.
106 pub(crate) ejected: bool,
107 /// The tier this call will clear, and therefore the `bondRequired` floor it
108 /// releases. `0` when none was ever declared.
109 pub(crate) declared_mbps: u64,
110 /// Bond that stays deposited and slashable afterwards, in base units.
111 pub(crate) retained_bond: alloy::primitives::U256,
112}
113
114/// Read the operator's registry state.
115///
116/// `isActive` is the contract's own composite predicate (registered, bonded
117/// over `minBond`, no unbonding request in flight), so it is NOT what
118/// `deregisterNode` gates on — that reads `_nodes[operator].active` alone. An
119/// operator mid-unbonding is `isActive == false` yet still deregisterable. The
120/// registered-set membership read below is the precise signal; `isActive` is
121/// not consulted.
122async fn build_plan<P: alloy::providers::Provider + Clone>(
123 bond: &CapacityBond::CapacityBondInstance<P>,
124 operator: Address,
125 capacity_bond: Address,
126) -> anyhow::Result<Plan> {
127 let ctx = |what: &str| format!("failed to read {what} from CapacityBond at {capacity_bond}");
128
129 let info = bond
130 .getNodeByAddress(operator)
131 .call()
132 .await
133 .with_context(|| ctx("getNodeByAddress"))?;
134 let ejected = bond
135 .ejected(operator)
136 .call()
137 .await
138 .with_context(|| ctx("ejected"))?;
139 let declared = bond
140 .declaredMbps(operator)
141 .call()
142 .await
143 .with_context(|| ctx("declaredMbps"))?;
144 let retained_bond = bond
145 .activeBond(operator)
146 .call()
147 .await
148 .with_context(|| ctx("activeBond"))?;
149
150 Ok(Plan {
151 capacity_bond,
152 active: info.active,
153 ejected,
154 // Saturating rather than `try_into`: the tier is bounded by the
155 // contract's own capacity band, so a value past `u64` is unreachable —
156 // and failing the whole command on an unreachable read would be worse
157 // than reporting the clamp.
158 declared_mbps: declared.saturating_to(),
159 retained_bond,
160 })
161}
162
163/// Refuse a run that the contract would revert, naming the state and its exit.
164/// Split from `run` so both branches are testable without a chain.
165fn ensure_active(plan: &Plan) -> anyhow::Result<()> {
166 if plan.active {
167 return Ok(());
168 }
169 anyhow::ensure!(
170 !plan.ejected,
171 "this operator was ejected by the contract, so there is no registration to \
172 deregister — `deregisterNode` would revert NodeNotActive. Exit directly with \
173 `decdn node unbond --all`, which releases the declared tier for an inactive \
174 operator and starts the unbonding window."
175 );
176 anyhow::bail!(
177 "this operator is not in the active node set — either it never registered, or it \
178 has already been deregistered. `decdn node register` re-enters; \
179 `decdn node unbond --all` exits."
180 )
181}
182
183/// What the confirmation gate decides, before any IO. Split from
184/// [`confirm_or_bail`] the way `unbond::decide_confirmation` is, so the gate is
185/// testable without a TTY.
186#[derive(Clone, Copy, Debug, PartialEq, Eq)]
187pub(crate) enum Confirmation {
188 /// `--yes` supplied: disclose the consequences, but skip the prompt.
189 Bypassed,
190 /// Ask on the terminal.
191 Prompt,
192 /// Headless and unconfirmed: refuse rather than assume consent.
193 NeedFlag,
194}
195
196/// Decide whether to prompt. Unlike `unbond` there is no `NotNeeded` arm —
197/// every run of this command that reaches the gate is submitting the
198/// deregistration, and there is no phase (`unbond`'s withdraw) that merely
199/// returns TOKEN.
200pub(crate) const fn decide_confirmation(yes: bool, interactive: bool) -> Confirmation {
201 if yes {
202 Confirmation::Bypassed
203 } else if interactive {
204 Confirmation::Prompt
205 } else {
206 Confirmation::NeedFlag
207 }
208}
209
210/// Disclose the consequences and, on a terminal, wait for `y`.
211///
212/// `--yes` suppresses the *prompt*, never the disclosure — "don't ask" must not
213/// silently become "don't tell", the same rule `unbond` follows. The bond line
214/// is the load-bearing one: it is the misconception this command most invites.
215fn confirm_or_bail(plan: &Plan, yes: bool) -> anyhow::Result<()> {
216 // Interactive only when BOTH streams are terminals: the warning goes to
217 // stderr and the answer is read from stdin, so if either is redirected the
218 // operator can't see what they'd be agreeing to (same rule as `unbond`).
219 let interactive = io::stdin().is_terminal() && io::stderr().is_terminal();
220 let decision = decide_confirmation(yes, interactive);
221
222 let mut err = io::stderr().lock();
223 write_disclosure(&mut err, plan)?;
224
225 match decision {
226 // Disclosed above; the operator asked not to be asked.
227 Confirmation::Bypassed => Ok(()),
228 Confirmation::NeedFlag => anyhow::bail!(
229 "deregistration not confirmed: no interactive terminal detected — re-run with \
230 `--yes` (or `--dry-run` to preview)."
231 ),
232 Confirmation::Prompt => {
233 write!(err, "Proceed? [y/N] ")?;
234 err.flush()?;
235 let mut line = String::new();
236 io::stdin().read_line(&mut line)?;
237 // Allowlist, not denylist: an empty line (Ctrl-D / EOF) lands on the
238 // deny side rather than being read as assent.
239 let answer = line.trim().to_ascii_lowercase();
240 anyhow::ensure!(
241 answer == "y" || answer == "yes",
242 "deregistration cancelled."
243 );
244 Ok(())
245 }
246 }
247}
248
249/// The consequence disclosure, pure so its wording is pinned by a test.
250pub(crate) fn write_disclosure(w: &mut impl io::Write, plan: &Plan) -> io::Result<()> {
251 writeln!(
252 w,
253 "About to deregister this node. It leaves the active node set immediately and stops \
254 being selected for delivery."
255 )?;
256 // The bond first, because "deregister" reads as "exit and refund".
257 writeln!(
258 w,
259 "Your bond of {} base units is NOT returned by this call. It stays deposited and \
260 fully slashable. To withdraw it, follow with `decdn node unbond --all`, wait out \
261 the unbonding window, then re-run `decdn node unbond`.",
262 plan.retained_bond,
263 )?;
264 if plan.declared_mbps != 0 {
265 writeln!(
266 w,
267 "The declared tier of {} Mbps is cleared, which is what releases the bond curve's \
268 floor and makes that full withdrawal possible.",
269 plan.declared_mbps,
270 )?;
271 }
272 writeln!(
273 w,
274 "Re-entry needs a fresh `decdn node bond --mbps` + `decdn node register`: this bumps \
275 the registration nonce, invalidating any registration signature already produced."
276 )
277}
278
279/// Write the plan + outcome as JSON or grep-friendly `key=value` lines. Pure
280/// (`&mut impl Write`) so the output shape is unit-testable without a chain.
281///
282/// `dry_run` is carried explicitly rather than inferred from `tx.is_none()`,
283/// for the reason `unbond::write_plan` carries it: a real run whose send failed
284/// also has no tx, so the absence alone cannot mean "preview".
285pub(crate) fn write_plan(
286 w: &mut impl io::Write,
287 p: &Plan,
288 json: bool,
289 tx: Option<&B256>,
290 dry_run: bool,
291) -> io::Result<()> {
292 let tx_hex = tx.map(|v| format!("{v:#x}"));
293 if json {
294 let value = serde_json::json!({
295 "submitted": tx.is_some(),
296 "dry_run": dry_run,
297 "capacity_bond": format!("{:#x}", p.capacity_bond),
298 "active": p.active,
299 "ejected": p.ejected,
300 "declared_mbps": p.declared_mbps,
301 "retained_bond_base": p.retained_bond.to_string(),
302 "deregister_tx": tx_hex,
303 });
304 return writeln!(w, "{value}");
305 }
306 // Base units: 1 TOKEN = 1e18 base units.
307 writeln!(w, "capacity_bond={:#x}", p.capacity_bond)?;
308 writeln!(w, "active={}", p.active)?;
309 writeln!(w, "ejected={}", p.ejected)?;
310 writeln!(w, "declared_mbps={}", p.declared_mbps)?;
311 writeln!(w, "retained_bond_base={}", p.retained_bond)?;
312 match tx_hex {
313 Some(h) => writeln!(w, "deregister_tx={h}")?,
314 None => writeln!(w, "deregister_tx=skipped")?,
315 }
316 writeln!(w, "submitted={} dry_run={dry_run}", tx.is_some())
317}
318
319#[cfg(test)]
320#[allow(clippy::unwrap_used, clippy::expect_used)]
321mod tests;