Skip to main content

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;