use std::io::{self, IsTerminal, Write};
use std::path::Path;
use alloy::primitives::{Address, B256};
use anyhow::Context;
use decdn_common::cli;
use decdn_incentive::capacity_bond::CapacityBond;
use crate::commands::chain_ctx;
pub async fn run(args: &cli::DeregisterArgs, global_config: Option<&Path>) -> anyhow::Result<()> {
let config_path = args.chain.common.config.as_deref().or(global_config);
let file = chain_ctx::load_optional_config(config_path)?;
let resolved = chain_ctx::resolve(&args.chain, &file)?;
let cb_addr = resolved.capacity_bond_address;
let signer = chain_ctx::load_operator_signer(&args.chain.common, &resolved.keystore).await?;
let operator = signer.address();
let provider = decdn_client::provider::build_provider(&resolved.rpc_url, &signer)?;
let bond = CapacityBond::new(cb_addr, &provider);
let plan = build_plan(&bond, operator, cb_addr).await?;
let json = args.chain.common.json;
if args.chain.common.dry_run {
let mut out = io::stdout().lock();
write_plan(&mut out, &plan, json, None, true).context("failed to write dry-run output")?;
return Ok(());
}
ensure_active(&plan)?;
confirm_or_bail(&plan, args.yes)?;
let mut tx = None;
let result = chain_ctx::send(
bond.deregisterNode(),
"deregisterNode",
Some("the node must be active — a second deregistration reverts NodeNotActive"),
&mut tx,
)
.await;
let mut out = io::stdout().lock();
write_plan(&mut out, &plan, json, tx.as_ref(), false).context("failed to write result")?;
drop(out);
result.map(|_| ())
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) struct Plan {
pub(crate) capacity_bond: Address,
pub(crate) active: bool,
pub(crate) ejected: bool,
pub(crate) declared_mbps: u64,
pub(crate) retained_bond: alloy::primitives::U256,
}
async fn build_plan<P: alloy::providers::Provider + Clone>(
bond: &CapacityBond::CapacityBondInstance<P>,
operator: Address,
capacity_bond: Address,
) -> anyhow::Result<Plan> {
let ctx = |what: &str| format!("failed to read {what} from CapacityBond at {capacity_bond}");
let info = bond
.getNodeByAddress(operator)
.call()
.await
.with_context(|| ctx("getNodeByAddress"))?;
let ejected = bond
.ejected(operator)
.call()
.await
.with_context(|| ctx("ejected"))?;
let declared = bond
.declaredMbps(operator)
.call()
.await
.with_context(|| ctx("declaredMbps"))?;
let retained_bond = bond
.activeBond(operator)
.call()
.await
.with_context(|| ctx("activeBond"))?;
Ok(Plan {
capacity_bond,
active: info.active,
ejected,
declared_mbps: declared.saturating_to(),
retained_bond,
})
}
fn ensure_active(plan: &Plan) -> anyhow::Result<()> {
if plan.active {
return Ok(());
}
anyhow::ensure!(
!plan.ejected,
"this operator was ejected by the contract, so there is no registration to \
deregister — `deregisterNode` would revert NodeNotActive. Exit directly with \
`decdn node unbond --all`, which releases the declared tier for an inactive \
operator and starts the unbonding window."
);
anyhow::bail!(
"this operator is not in the active node set — either it never registered, or it \
has already been deregistered. `decdn node register` re-enters; \
`decdn node unbond --all` exits."
)
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) enum Confirmation {
Bypassed,
Prompt,
NeedFlag,
}
pub(crate) const fn decide_confirmation(yes: bool, interactive: bool) -> Confirmation {
if yes {
Confirmation::Bypassed
} else if interactive {
Confirmation::Prompt
} else {
Confirmation::NeedFlag
}
}
fn confirm_or_bail(plan: &Plan, yes: bool) -> anyhow::Result<()> {
let interactive = io::stdin().is_terminal() && io::stderr().is_terminal();
let decision = decide_confirmation(yes, interactive);
let mut err = io::stderr().lock();
write_disclosure(&mut err, plan)?;
match decision {
Confirmation::Bypassed => Ok(()),
Confirmation::NeedFlag => anyhow::bail!(
"deregistration not confirmed: no interactive terminal detected — re-run with \
`--yes` (or `--dry-run` to preview)."
),
Confirmation::Prompt => {
write!(err, "Proceed? [y/N] ")?;
err.flush()?;
let mut line = String::new();
io::stdin().read_line(&mut line)?;
let answer = line.trim().to_ascii_lowercase();
anyhow::ensure!(
answer == "y" || answer == "yes",
"deregistration cancelled."
);
Ok(())
}
}
}
pub(crate) fn write_disclosure(w: &mut impl io::Write, plan: &Plan) -> io::Result<()> {
writeln!(
w,
"About to deregister this node. It leaves the active node set immediately and stops \
being selected for delivery."
)?;
writeln!(
w,
"Your bond of {} base units is NOT returned by this call. It stays deposited and \
fully slashable. To withdraw it, follow with `decdn node unbond --all`, wait out \
the unbonding window, then re-run `decdn node unbond`.",
plan.retained_bond,
)?;
if plan.declared_mbps != 0 {
writeln!(
w,
"The declared tier of {} Mbps is cleared, which is what releases the bond curve's \
floor and makes that full withdrawal possible.",
plan.declared_mbps,
)?;
}
writeln!(
w,
"Re-entry needs a fresh `decdn node bond --mbps` + `decdn node register`: this bumps \
the registration nonce, invalidating any registration signature already produced."
)
}
pub(crate) fn write_plan(
w: &mut impl io::Write,
p: &Plan,
json: bool,
tx: Option<&B256>,
dry_run: bool,
) -> io::Result<()> {
let tx_hex = tx.map(|v| format!("{v:#x}"));
if json {
let value = serde_json::json!({
"submitted": tx.is_some(),
"dry_run": dry_run,
"capacity_bond": format!("{:#x}", p.capacity_bond),
"active": p.active,
"ejected": p.ejected,
"declared_mbps": p.declared_mbps,
"retained_bond_base": p.retained_bond.to_string(),
"deregister_tx": tx_hex,
});
return writeln!(w, "{value}");
}
writeln!(w, "capacity_bond={:#x}", p.capacity_bond)?;
writeln!(w, "active={}", p.active)?;
writeln!(w, "ejected={}", p.ejected)?;
writeln!(w, "declared_mbps={}", p.declared_mbps)?;
writeln!(w, "retained_bond_base={}", p.retained_bond)?;
match tx_hex {
Some(h) => writeln!(w, "deregister_tx={h}")?,
None => writeln!(w, "deregister_tx=skipped")?,
}
writeln!(w, "submitted={} dry_run={dry_run}", tx.is_some())
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used)]
mod tests;