Skip to main content

decdn_cli/commands/
unbond.rs

1//! `decdn node unbond` — lower the bond (ADR 026 § Capacity-bond curve).
2//!
3//! The reverse of `decdn node bond`. Lowering a bond is a two-phase on-chain
4//! operation — `requestUnbond(amount)` starts a `unbondingPeriod` window and
5//! `unbond()` withdraws once it matures — with no atomic refund. Rather than
6//! exposing both calls, this command reads chain state and picks the phase. A
7//! run that lost its `requestUnbond` after `declareMbps` landed resumes on
8//! re-run, the way `bond`'s top-up converges; once `requestUnbond` HAS landed,
9//! a re-run carrying an amount flag is a deliberate error rather than a resume,
10//! since the pending request can no longer be changed.
11//!
12//! Two contract facts drive the shape of this command, and both are surfaced
13//! to the operator rather than left to a revert:
14//!
15//! - A request in flight makes `isActive` false for the WHOLE window (it is a
16//!   conjunct of the predicate, independent of the remaining bond). Starting
17//!   one therefore takes the node out of the active set for ~14 days, which is
18//!   why a terminal run confirms first.
19//! - `requestUnbond`'s floor is `bondRequired(declaredMbps)` — the curve, NOT
20//!   `minBond`. So the contract permits unbonding into an inactive state. The
21//!   three amount flags pick deliberately different floors: `--to-mbps` keeps
22//!   the operator eligible, `--all` goes to the contract's own floor.
23//!
24//! `--all` releases everything the curve permits *while the node is still
25//! registered*, which is not the same as an exit: the declared tier pins
26//! `bondRequired(declaredMbps)` in place. A full exit therefore starts with
27//! `decdn node deregister` (#1359), which clears the tier (ADR 003 § Node
28//! Registry, ADR 026 § Capacity-bond curve); `--all` then releases the whole
29//! bond.
30//!
31//! An operator who is already INACTIVE cannot deregister — the contract reverts
32//! `NodeNotActive` — so this command clears their tier itself, via
33//! `declareMbps(0)` (#1361, accepted only from an inactive operator). That is
34//! what lets an ejected operator, or one who declared a tier without ever
35//! registering, reach a full exit with `--all` alone.
36
37use std::io::{self, IsTerminal, Write};
38use std::path::Path;
39
40use alloy::primitives::{Address, B256, U256};
41use alloy::providers::Provider;
42use anyhow::Context;
43use decdn_common::cli;
44use decdn_incentive::capacity_bond::CapacityBond;
45
46use crate::commands::chain_ctx;
47
48/// Entry point for `decdn node unbond`.
49pub async fn run(args: &cli::UnbondArgs, global_config: Option<&Path>) -> anyhow::Result<()> {
50    let config_path = args.chain.common.config.as_deref().or(global_config);
51    let file = chain_ctx::load_optional_config(config_path)?;
52    let resolved = chain_ctx::resolve(&args.chain, &file)?;
53    let cb_addr = resolved.capacity_bond_address;
54
55    let signer = chain_ctx::load_operator_signer(&args.chain.common, &resolved.keystore).await?;
56    let operator = signer.address();
57    let provider = decdn_client::provider::build_provider(&resolved.rpc_url, &signer)?;
58    let bond = CapacityBond::new(cb_addr, &provider);
59
60    let request = AmountRequest::from_args(args)?;
61    let plan = build_plan(&bond, &provider, operator, cb_addr, request).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, &Outcome::default(), true)
67            .context("failed to write dry-run output")?;
68        return Ok(());
69    }
70
71    // `Waiting` submits nothing: report and exit non-zero so a scripted retry
72    // loop can tell "not yet" from "withdrawn".
73    if let Action::Waiting { unlock_at, .. } = plan.action {
74        let mut out = io::stdout().lock();
75        write_plan(&mut out, &plan, json, &Outcome::default(), false)
76            .context("failed to write result")?;
77        anyhow::bail!(
78            "unbonding request is still maturing: unlocks at {unlock_at} ({}); \
79             re-run `decdn node unbond` then to withdraw",
80            format_remaining(plan.remaining_secs()),
81        );
82    }
83
84    confirm_or_bail(&plan, args.yes)?;
85
86    // The outcome is owned HERE, not inside `execute`, so a partially-applied
87    // sequence survives the error. `execute` sends `declareMbps` then
88    // `requestUnbond`; if the second fails after the first mined, the operator's
89    // declared tier is already lowered and that tx hash is the only record of
90    // it. Returning `Err` with the outcome still inside `execute` would drop it
91    // and print nothing at all — leaving them to conclude "nothing happened"
92    // while their tier is halved.
93    let mut outcome = Outcome::default();
94    let result = execute(&bond, &plan, &mut outcome).await;
95
96    let mut out = io::stdout().lock();
97    write_plan(&mut out, &plan, json, &outcome, false).context("failed to write result")?;
98    drop(out);
99
100    result.with_context(|| match outcome.declare {
101        Some(tx) => format!(
102            "declareMbps ALREADY LANDED (tx {tx:#x}): the declared tier is now lowered, but \
103             NO unbonding request was started and no TOKEN was released. Re-run the identical \
104             command to resume — it skips the declare and requests the unbond"
105        ),
106        None => "no transaction landed; on-chain state is unchanged".to_string(),
107    })
108}
109
110/// How much the operator asked to release, normalised from the three mutually
111/// exclusive flags. Clap's `ArgGroup` enforces the exclusivity; this maps the
112/// absent case to `Unspecified`, which is legal only on the withdraw path —
113/// `build_plan` is what enforces that.
114#[derive(Clone, Copy, Debug, PartialEq, Eq)]
115pub(crate) enum AmountRequest {
116    /// Reduce the declared tier to this many Mbps and release the surplus.
117    ToMbps(u64),
118    /// Release everything the curve permits.
119    All,
120    /// Release exactly this many base units.
121    Exact(U256),
122    /// No amount flag — only valid when a request is already in flight.
123    Unspecified,
124}
125
126impl AmountRequest {
127    fn from_args(args: &cli::UnbondArgs) -> anyhow::Result<Self> {
128        match (args.to_mbps, args.all, args.amount) {
129            (Some(mbps), false, None) => Ok(Self::ToMbps(mbps)),
130            (None, true, None) => Ok(Self::All),
131            (None, false, Some(amount)) => Ok(Self::Exact(U256::from(amount))),
132            (None, false, None) => Ok(Self::Unspecified),
133            // Clap's ArgGroup rejects multiple amount flags before we get here;
134            // this arm exists so a future flag added outside the group can't
135            // silently take one branch.
136            _ => anyhow::bail!(
137                "--to-mbps, --all and --amount are mutually exclusive; pass exactly one"
138            ),
139        }
140    }
141}
142
143/// Which phase of the unbonding window this invocation is in, decided from
144/// chain state rather than a flag.
145#[derive(Clone, Copy, Debug, PartialEq, Eq)]
146pub(crate) enum Action {
147    /// No request in flight: declare down (if needed), then `requestUnbond`.
148    Request {
149        /// Base units to release.
150        release: U256,
151        /// Bond retained afterwards.
152        retained: U256,
153        /// `declareMbps(mbps)` must land first, or `requestUnbond` reverts
154        /// `BondBelowCurve` against the still-current tier.
155        declare_to: Option<u64>,
156    },
157    /// A request exists but has not matured — nothing to submit.
158    Waiting {
159        amount: U256,
160        unlock_at: u64,
161        now: u64,
162    },
163    /// A matured request is withdrawable via `unbond()`.
164    Withdraw { amount: U256 },
165}
166
167/// What `unbond` intends to do, computed from chain state.
168pub(crate) struct Plan {
169    pub(crate) capacity_bond: Address,
170    pub(crate) action: Action,
171    /// Active bond before this command.
172    pub(crate) prior: U256,
173    /// Currently declared capacity tier (Mbps).
174    pub(crate) declared_mbps: u64,
175    /// Governable unbonding window (seconds), for the unlock-time report.
176    pub(crate) unbonding_period: u64,
177    /// True when the post-request bond would sit below `minBond`, i.e. the
178    /// node cannot return to the active set without re-bonding.
179    pub(crate) below_min_bond: bool,
180}
181
182impl Plan {
183    /// Seconds until a `Waiting` request matures; `0` for every other action.
184    pub(crate) const fn remaining_secs(&self) -> u64 {
185        match self.action {
186            Action::Waiting { unlock_at, now, .. } => unlock_at.saturating_sub(now),
187            _ => 0,
188        }
189    }
190}
191
192/// Transaction hashes from a non-dry run; `None` for steps that were skipped
193/// (tier already at the target) or not part of this action.
194#[derive(Default)]
195pub(crate) struct Outcome {
196    pub(crate) declare: Option<B256>,
197    pub(crate) request: Option<B256>,
198    pub(crate) withdraw: Option<B256>,
199}
200
201/// Read chain state and decide the action. Every *operator-controllable*
202/// precondition the contract enforces is checked here first, so an impossible
203/// request fails before any transaction is sent rather than as a raw revert.
204/// The exception is `whenNotPaused`, which is not pre-checked — a paused
205/// contract still surfaces as a revert from [`chain_ctx::send`].
206pub(crate) async fn build_plan<P: Provider + Clone, Q: Provider>(
207    bond: &CapacityBond::CapacityBondInstance<P>,
208    provider: &Q,
209    operator: Address,
210    cb_addr: Address,
211    request: AmountRequest,
212) -> anyhow::Result<Plan> {
213    let ctx = || format!("failed to read CapacityBond state at {cb_addr}");
214    let pending = bond.unbondingOf(operator).call().await.with_context(ctx)?;
215    let prior = bond.activeBond(operator).call().await.with_context(ctx)?;
216    let declared = bond.declaredMbps(operator).call().await.with_context(ctx)?;
217    let min_bond = bond.minBond().call().await.with_context(ctx)?;
218    let unbonding_period = bond.unbondingPeriod().call().await.with_context(ctx)?;
219    // Loud, not saturating: `declared_mbps` gates `tier_step`, so a silent
220    // clamp to `u64::MAX` would make that guard accept ANY `--to-mbps`. The
221    // contract's own `maxCapacityMbps` ceiling (1e6) guarantees this fits, so
222    // failing here means we are not talking to a `CapacityBond` at all.
223    let declared_mbps = u64::try_from(declared).with_context(|| {
224        format!(
225            "CapacityBond at {cb_addr} returned declaredMbps={declared}, far above its own \
226             maxCapacityMbps ceiling — is `capacity_bond_address` pointing at the right contract?"
227        )
228    })?;
229    // Display-only (the unlock-time report), so a clamp here cannot mis-decide.
230    let period_secs = u64::try_from(unbonding_period).unwrap_or(u64::MAX);
231
232    // A request in flight blocks a new one (`UnbondingInProgress`), so the
233    // amount flags cannot apply — say so instead of ignoring them.
234    if pending.amount != U256::ZERO {
235        anyhow::ensure!(
236            request == AmountRequest::Unspecified,
237            "an unbonding request for {} base units is already in flight; \
238             `requestUnbond` reverts while one is pending. Re-run \
239             `decdn node unbond` with no amount flag to withdraw it once it matures.",
240            pending.amount,
241        );
242        let now = chain_ctx::head_timestamp(provider).await?;
243        // Compare in U256 so the maturity decision doesn't depend on the
244        // saturation constant of a narrowing conversion. `unlock_at` is narrowed
245        // only afterwards, for display.
246        let matured = U256::from(now) >= pending.unlockAt;
247        let unlock_at = u64::try_from(pending.unlockAt).unwrap_or(u64::MAX);
248        let action = if matured {
249            Action::Withdraw {
250                amount: pending.amount,
251            }
252        } else {
253            Action::Waiting {
254                amount: pending.amount,
255                unlock_at,
256                now,
257            }
258        };
259        return Ok(Plan {
260            capacity_bond: cb_addr,
261            action,
262            prior,
263            declared_mbps,
264            unbonding_period: period_secs,
265            below_min_bond: prior < min_bond,
266        });
267    }
268
269    anyhow::ensure!(
270        request != AmountRequest::Unspecified,
271        "no unbonding request in flight and no amount given: pass --to-mbps <MBPS> \
272         to reduce the declared tier, --all to release everything the bond curve \
273         permits, or --amount <BASE_UNITS> for an exact figure",
274    );
275    anyhow::ensure!(prior > U256::ZERO, "no active bond to unbond");
276
277    // `_nodes[operator].active`, NOT the composite `isActive`: the latter also
278    // requires bond over `minBond` and no request in flight, so it would report
279    // a merely-under-bonded operator as inactive and let `--all` send a
280    // `declareMbps(0)` the contract rejects. Registered-set membership is the
281    // exact flag `declareMbps`'s release branch gates on.
282    let active = bond
283        .getNodeByAddress(operator)
284        .call()
285        .await
286        .with_context(ctx)?
287        .active;
288
289    let (release, retained, declare_to) =
290        resolve_release(bond, cb_addr, request, prior, declared, min_bond, active).await?;
291
292    Ok(Plan {
293        capacity_bond: cb_addr,
294        action: Action::Request {
295            release,
296            retained,
297            declare_to,
298        },
299        prior,
300        declared_mbps,
301        unbonding_period: period_secs,
302        below_min_bond: retained < min_bond,
303    })
304}
305
306/// Validate a `--to-mbps` target against the currently declared tier and
307/// decide whether `declareMbps` still has to run.
308///
309/// `target == declared` is **accepted, not rejected**, and this is what makes
310/// the command converge: `execute` sends `declareMbps` then `requestUnbond` as
311/// two transactions, so a run that lands the first and loses the second leaves
312/// the tier already reduced while the bond is still high. Rejecting the equal
313/// case would strand exactly the operator this command's idempotence is for —
314/// and would point them at `decdn node bond`, which cannot release anything.
315/// The `release > 0` check downstream is what catches the genuinely-nothing-
316/// to-do case, so accepting equality here costs no safety.
317fn tier_step(target_mbps: u64, declared_mbps: u64) -> anyhow::Result<Option<u64>> {
318    anyhow::ensure!(
319        target_mbps <= declared_mbps,
320        "--to-mbps {target_mbps} is above the current declared tier \
321         ({declared_mbps} Mbps); raise capacity with `decdn node bond --mbps \
322         {target_mbps}` instead",
323    );
324    Ok((target_mbps < declared_mbps).then_some(target_mbps))
325}
326
327/// `--all`'s two coupled decisions: whether to clear the declared tier first,
328/// and which tier the retained-bond curve is then evaluated against.
329///
330/// An INACTIVE operator releases the tier as part of the same run (#1361):
331/// `declareMbps(0)` is accepted only from them, and it is the only route to 0
332/// they have — `deregisterNode` reverts `NodeNotActive`. `bondRequired(0) == 0`,
333/// so the retarget is what turns `--all` into a genuine full exit rather than a
334/// release down to the standing tier's floor. An ACTIVE operator keeps the old
335/// behavior: for them `declareMbps(0)` reverts, and clearing the tier is
336/// `decdn node deregister`'s job.
337///
338/// Pure, and split out for that reason. The decision is only observable through
339/// `bondRequired`'s ARGUMENT, and the `Asserter` the tests below use is a FIFO
340/// value queue with no argument awareness — it returns the same queued response
341/// whether the call was `bondRequired(0)` or `bondRequired(1000)`. So a test
342/// driving `resolve_release` cannot tell the retarget from its absence;
343/// mutating it away left every mocked test green (verified). Testing the
344/// decision directly is the only thing that actually pins it.
345fn all_target(active: bool, declared: U256) -> (Option<u64>, U256) {
346    if !active && declared != U256::ZERO {
347        (Some(0), U256::ZERO)
348    } else {
349        (None, declared)
350    }
351}
352
353/// Turn the requested amount into `(release, retained, declare_to)`, rejecting
354/// anything the contract would revert on. Separate from [`build_plan`] because
355/// this is where the three flags stop agreeing: each picks a different floor,
356/// and `--to-mbps` is the only one that moves the declared tier.
357async fn resolve_release<P: Provider + Clone>(
358    bond: &CapacityBond::CapacityBondInstance<P>,
359    cb_addr: Address,
360    request: AmountRequest,
361    prior: U256,
362    declared: U256,
363    min_bond: U256,
364    active: bool,
365) -> anyhow::Result<(U256, U256, Option<u64>)> {
366    let ctx = || format!("failed to read CapacityBond state at {cb_addr}");
367    let declared_mbps = u64::try_from(declared).unwrap_or(u64::MAX);
368    // The curve floor the contract itself enforces, evaluated against the tier
369    // that will be declared at `requestUnbond` time.
370    let curve_floor =
371        |mbps: U256| async move { bond.bondRequired(mbps).call().await.with_context(ctx) };
372
373    match request {
374        AmountRequest::ToMbps(target_mbps) => {
375            let target = U256::from(target_mbps);
376            let declare_to = tier_step(target_mbps, declared_mbps)?;
377            // Band-check ONLY when a `declareMbps` will actually be sent. The
378            // band constrains that call and nothing else — `requestUnbond`'s only
379            // floor is the curve — so checking it unconditionally would refuse
380            // something the contract accepts: if governance raises
381            // `minCapacityMbps` above an operator's declared tier, the resume
382            // path (`declare_to == None`) is still perfectly legal.
383            if declare_to.is_some() {
384                let min_cap = bond.minCapacityMbps().call().await.with_context(ctx)?;
385                let max_cap = bond.maxCapacityMbps().call().await.with_context(ctx)?;
386                anyhow::ensure!(
387                    target >= min_cap && target <= max_cap,
388                    "declared capacity {target_mbps} Mbps is outside the on-chain band \
389                     [{min_cap}, {max_cap}]; declareMbps would revert",
390                );
391            }
392            // Retain the same `max(minBond, bondRequired)` target `bond` tops up
393            // to, so the operator stays eligible at the reduced tier. This is the
394            // ONLY difference from `--all`, which takes the bare curve floor.
395            let retained = min_bond.max(curve_floor(target).await?);
396            let release = prior.saturating_sub(retained);
397            anyhow::ensure!(
398                release > U256::ZERO,
399                "nothing to release: the active bond ({prior}) is already at or below \
400                 the {target_mbps} Mbps target ({retained} base units)",
401            );
402            Ok((release, retained, declare_to))
403        }
404        AmountRequest::All => {
405            let (declare_to, target) = all_target(active, declared);
406            // The contract's own floor: the bare curve, with no `min_bond.max()`.
407            // That is independent of `minBond`, not below it — with the deployed
408            // `k`/`α` the curve crosses above `minBond` around 1000 Mbps, so
409            // whether this leaves the operator inactive is tier-dependent and is
410            // what `below_min_bond` reports.
411            let retained = curve_floor(target).await?;
412            let release = prior.saturating_sub(retained);
413            anyhow::ensure!(
414                release > U256::ZERO,
415                "nothing to release: the active bond ({prior}) is already at the curve \
416                 floor for {declared_mbps} Mbps ({retained} base units)",
417            );
418            Ok((release, retained, declare_to))
419        }
420        AmountRequest::Exact(amount) => {
421            anyhow::ensure!(amount > U256::ZERO, "--amount must be greater than zero");
422            anyhow::ensure!(
423                amount <= prior,
424                "--amount {amount} exceeds the active bond ({prior} base units)",
425            );
426            let retained = prior - amount;
427            let floor = curve_floor(declared).await?;
428            anyhow::ensure!(
429                retained >= floor,
430                "releasing {amount} base units would leave {retained}, below the \
431                 {declared_mbps} Mbps curve floor of {floor}; requestUnbond would revert \
432                 with BondBelowCurve. Lower the tier first with `decdn node bond --mbps \
433                 <lower>`, or use `--to-mbps <lower>` to do both in one run.",
434            );
435            Ok((amount, retained, None))
436        }
437        // `build_plan` rejects `Unspecified` before calling in, so this arm is
438        // unreachable. It returns an error rather than `unreachable!` because
439        // the workspace anti-panic policy (`Cargo.toml` `[workspace.lints]`
440        // denies `panic`) treats a broken assumption as something to surface at
441        // the call site, not to abort the process on.
442        AmountRequest::Unspecified => {
443            anyhow::bail!("internal: no amount requested for a new unbonding request")
444        }
445    }
446}
447
448/// Submit the action's transactions in order. For `Request` the `declareMbps`
449/// must precede `requestUnbond`, which evaluates the curve against whatever
450/// tier is current at call time.
451///
452/// `outcome` is borrowed rather than returned so each hash is recorded in the
453/// CALLER's value as it lands. Returning it would tie the record to the happy
454/// path, and the interesting case here is the unhappy one: a `declareMbps` that
455/// mined before `requestUnbond` failed is a real, resumable state change the
456/// operator has to be told about.
457pub(crate) async fn execute<P: Provider + Clone>(
458    bond: &CapacityBond::CapacityBondInstance<P>,
459    plan: &Plan,
460    outcome: &mut Outcome,
461) -> anyhow::Result<()> {
462    match plan.action {
463        Action::Request {
464            release,
465            declare_to,
466            ..
467        } => {
468            if let Some(mbps) = declare_to {
469                // `0` is the release path (#1361) and is legal precisely because
470                // it bypasses the band, so the band question would be actively
471                // misleading advice there.
472                let hint = if mbps == 0 {
473                    "declareMbps(0) is accepted only from an operator that is NOT in the \
474                     registered set; if this node is still registered, use `decdn node \
475                     deregister` instead"
476                        .to_string()
477                } else {
478                    format!("is {mbps} Mbps within the capacity band?")
479                };
480                chain_ctx::send(
481                    bond.declareMbps(U256::from(mbps)),
482                    "declareMbps",
483                    Some(&hint),
484                    &mut outcome.declare,
485                )
486                .await?;
487            }
488            chain_ctx::send(
489                bond.requestUnbond(release),
490                "requestUnbond",
491                None,
492                &mut outcome.request,
493            )
494            .await?;
495        }
496        Action::Withdraw { .. } => {
497            chain_ctx::send(bond.unbond(), "unbond", None, &mut outcome.withdraw).await?;
498        }
499        // `run` bails on `Waiting` before reaching here. Kept loud rather than a
500        // no-op: a silent `Ok` would report `submitted=false` and exit 0, which
501        // a wrapper doing `if decdn node unbond; then mark_withdrawn; fi` would
502        // read as a completed withdrawal. Same treatment as the unreachable
503        // `AmountRequest::Unspecified` arm above.
504        Action::Waiting { .. } => anyhow::bail!(
505            "internal: reached `execute` with a still-maturing request; nothing was submitted"
506        ),
507    }
508    Ok(())
509}
510
511/// What the confirmation gate decides, before any IO. Split from
512/// [`confirm_or_bail`] the way `terms::decide` is split from
513/// `terms::ensure_accepted`, so the gate that protects an operator from an
514/// accidental window-long outage is testable without a TTY.
515#[derive(Clone, Copy, Debug, PartialEq, Eq)]
516pub(crate) enum Confirmation {
517    /// Nothing to confirm — a withdrawal only returns TOKEN, and a maturing
518    /// request submits nothing.
519    NotNeeded,
520    /// `--yes` supplied: disclose the consequences, but skip the prompt.
521    Bypassed,
522    /// Ask on the terminal.
523    Prompt,
524    /// Headless and unconfirmed: refuse rather than assume consent.
525    NeedFlag,
526}
527
528/// Decide whether to confirm, from the action and the two ambient signals.
529/// Only `Request` costs anything irreversible-for-a-window, so only it asks.
530pub(crate) const fn decide_confirmation(
531    action: Action,
532    yes: bool,
533    interactive: bool,
534) -> Confirmation {
535    if !matches!(action, Action::Request { .. }) {
536        return Confirmation::NotNeeded;
537    }
538    if yes {
539        return Confirmation::Bypassed;
540    }
541    if interactive {
542        Confirmation::Prompt
543    } else {
544        Confirmation::NeedFlag
545    }
546}
547
548/// Confirm before the first send on a `Request`. Starting a request costs the
549/// operator active-set membership for the whole window, which is not obvious
550/// from the flags alone — so the consequences are always disclosed, and a
551/// terminal run additionally waits for `y`. `Withdraw` needs no confirmation:
552/// it only returns TOKEN.
553///
554/// `--yes` suppresses the *prompt*, never the disclosure. A scripted
555/// `--all --yes` can leave the node permanently inactive; "don't ask" must not
556/// silently become "don't tell", so the warning is written for `Bypassed` too.
557/// Every decision here comes from [`decide_confirmation`] — nothing is
558/// re-derived from `interactive`, so the tested function IS the enforcing one.
559fn confirm_or_bail(plan: &Plan, yes: bool) -> anyhow::Result<()> {
560    // Interactive only when BOTH streams are terminals: the warning goes to
561    // stderr and the answer is read from stdin, so if either is redirected the
562    // operator can't see what they'd be agreeing to (same rule as `terms`).
563    let interactive = io::stdin().is_terminal() && io::stderr().is_terminal();
564    let decision = decide_confirmation(plan.action, yes, interactive);
565    if decision == Confirmation::NotNeeded {
566        return Ok(());
567    }
568    let Action::Request {
569        release, retained, ..
570    } = plan.action
571    else {
572        return Ok(());
573    };
574
575    let mut err = io::stderr().lock();
576    writeln!(
577        err,
578        "About to release {release} base units (retaining {retained}). Your node will be \
579         INACTIVE for the full {} unbonding window, and the TOKEN is only withdrawable \
580         after it.",
581        format_remaining(plan.unbonding_period),
582    )?;
583    if plan.below_min_bond {
584        writeln!(
585            err,
586            "The retained bond is below minBond, so the node stays inactive after the \
587             withdrawal until it re-bonds."
588        )?;
589    }
590
591    match decision {
592        // Disclosed above; the operator asked not to be asked.
593        Confirmation::NotNeeded | Confirmation::Bypassed => Ok(()),
594        Confirmation::NeedFlag => anyhow::bail!(
595            "unbond not confirmed: no interactive terminal detected — re-run with `--yes` \
596             (or `--dry-run` to preview)."
597        ),
598        Confirmation::Prompt => {
599            write!(err, "Proceed? [y/N] ")?;
600            err.flush()?;
601            let mut line = String::new();
602            io::stdin().read_line(&mut line)?;
603            // Allowlist, not denylist: an empty line (Ctrl-D / EOF) lands on the
604            // deny side rather than being read as assent.
605            let answer = line.trim().to_ascii_lowercase();
606            anyhow::ensure!(answer == "y" || answer == "yes", "unbond cancelled.");
607            Ok(())
608        }
609    }
610}
611
612/// Write the plan + outcome as JSON or grep-friendly `key=value` lines. Pure
613/// (`&mut impl Write`) so the output shape is unit-testable without a chain.
614/// The `phase` key is what distinguishes the three actions for a machine
615/// consumer; the amount keys that don't apply to a phase are omitted rather
616/// than zeroed, so `release_base=0` never has to be read as "n/a".
617///
618/// `dry_run` is carried explicitly rather than inferred from `submitted`, for
619/// the reason `bond::write_plan` carries it: several real runs also submit
620/// nothing (a maturing `Waiting`, or a failed first send), so `submitted=false`
621/// alone cannot mean "preview".
622pub(crate) fn write_plan(
623    w: &mut impl io::Write,
624    p: &Plan,
625    json: bool,
626    o: &Outcome,
627    dry_run: bool,
628) -> io::Result<()> {
629    let submitted = o.declare.is_some() || o.request.is_some() || o.withdraw.is_some();
630    let tx_hex = |h: Option<&B256>| h.map(|v| format!("{v:#x}"));
631    let phase = match p.action {
632        Action::Request { .. } => "request",
633        Action::Waiting { .. } => "waiting",
634        Action::Withdraw { .. } => "withdraw",
635    };
636    if json {
637        let mut value = serde_json::json!({
638            "phase": phase,
639            "submitted": submitted,
640            "dry_run": dry_run,
641            "capacity_bond": format!("{:#x}", p.capacity_bond),
642            "prior_bond_base": p.prior.to_string(),
643            "declared_mbps": p.declared_mbps,
644            "unbonding_period_secs": p.unbonding_period,
645            "below_min_bond": p.below_min_bond,
646            "declare_tx": tx_hex(o.declare.as_ref()),
647            "request_tx": tx_hex(o.request.as_ref()),
648            "withdraw_tx": tx_hex(o.withdraw.as_ref()),
649        });
650        if let Some(obj) = value.as_object_mut() {
651            match p.action {
652                Action::Request {
653                    release,
654                    retained,
655                    declare_to,
656                } => {
657                    obj.insert("release_base".into(), release.to_string().into());
658                    obj.insert("retained_bond_base".into(), retained.to_string().into());
659                    obj.insert("declare_to_mbps".into(), declare_to.into());
660                }
661                Action::Waiting {
662                    amount, unlock_at, ..
663                } => {
664                    obj.insert("pending_base".into(), amount.to_string().into());
665                    obj.insert("unlock_at".into(), unlock_at.into());
666                    obj.insert("remaining_secs".into(), p.remaining_secs().into());
667                }
668                Action::Withdraw { amount } => {
669                    obj.insert("withdrawn_base".into(), amount.to_string().into());
670                }
671            }
672        }
673        return writeln!(w, "{value}");
674    }
675    // Base units: 1 TOKEN = 1e18 base units.
676    writeln!(w, "phase={phase}")?;
677    writeln!(w, "capacity_bond={:#x}", p.capacity_bond)?;
678    writeln!(w, "prior_bond_base={}", p.prior)?;
679    writeln!(w, "declared_mbps={}", p.declared_mbps)?;
680    match p.action {
681        Action::Request {
682            release,
683            retained,
684            declare_to,
685        } => {
686            writeln!(w, "release_base={release}")?;
687            writeln!(w, "retained_bond_base={retained}")?;
688            match declare_to {
689                Some(mbps) => writeln!(w, "declare_to_mbps={mbps}")?,
690                None => writeln!(w, "declare_to_mbps=none")?,
691            }
692            writeln!(
693                w,
694                "unbonding_period={}",
695                format_remaining(p.unbonding_period)
696            )?;
697            writeln!(w, "below_min_bond={}", p.below_min_bond)?;
698        }
699        Action::Waiting {
700            amount, unlock_at, ..
701        } => {
702            writeln!(w, "pending_base={amount}")?;
703            writeln!(w, "unlock_at={unlock_at}")?;
704            writeln!(w, "remaining={}", format_remaining(p.remaining_secs()))?;
705        }
706        Action::Withdraw { amount } => {
707            writeln!(w, "withdrawn_base={amount}")?;
708        }
709    }
710    write_tx_line(w, "declare_tx", o.declare.as_ref())?;
711    write_tx_line(w, "request_tx", o.request.as_ref())?;
712    write_tx_line(w, "withdraw_tx", o.withdraw.as_ref())?;
713    writeln!(w, "submitted={submitted} dry_run={dry_run}")
714}
715
716/// One `<key>=<tx|skipped>` line, matching `bond`'s output vocabulary so an
717/// operator reading both sees the same shape.
718fn write_tx_line(w: &mut impl io::Write, key: &str, tx: Option<&B256>) -> io::Result<()> {
719    match tx {
720        Some(h) => writeln!(w, "{key}={h:#x}"),
721        None => writeln!(w, "{key}=skipped"),
722    }
723}
724
725/// Forward-looking duration as a coarse `13d 4h` string. The counterpart to
726/// `node::format_age`, which renders elapsed time; kept local (and dependency
727/// free — the CLI's dependency set pulls in no `chrono`/`humantime`) because the
728/// unbonding window is the only place the CLI *formats* a future duration;
729/// `channel` reports its dispute deadline as a raw timestamp.
730pub(crate) fn format_remaining(secs: u64) -> String {
731    const MIN: u64 = 60;
732    const HOUR: u64 = 60 * MIN;
733    const DAY: u64 = 24 * HOUR;
734    if secs == 0 {
735        return "now".to_string();
736    }
737    if secs < MIN {
738        return format!("{secs}s");
739    }
740    if secs < HOUR {
741        return format!("{}m", secs / MIN);
742    }
743    if secs < DAY {
744        return format!("{}h {}m", secs / HOUR, (secs % HOUR) / MIN);
745    }
746    format!("{}d {}h", secs / DAY, (secs % DAY) / HOUR)
747}
748
749#[cfg(test)]
750#[allow(clippy::unwrap_used, clippy::expect_used)]
751mod tests;