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;