use std::io::{self, IsTerminal, Write};
use std::path::Path;
use alloy::primitives::{Address, B256, U256};
use alloy::providers::Provider;
use anyhow::Context;
use decdn_common::cli;
use decdn_incentive::capacity_bond::CapacityBond;
use crate::commands::chain_ctx;
pub async fn run(args: &cli::UnbondArgs, 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 request = AmountRequest::from_args(args)?;
let plan = build_plan(&bond, &provider, operator, cb_addr, request).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, &Outcome::default(), true)
.context("failed to write dry-run output")?;
return Ok(());
}
if let Action::Waiting { unlock_at, .. } = plan.action {
let mut out = io::stdout().lock();
write_plan(&mut out, &plan, json, &Outcome::default(), false)
.context("failed to write result")?;
anyhow::bail!(
"unbonding request is still maturing: unlocks at {unlock_at} ({}); \
re-run `decdn node unbond` then to withdraw",
format_remaining(plan.remaining_secs()),
);
}
confirm_or_bail(&plan, args.yes)?;
let mut outcome = Outcome::default();
let result = execute(&bond, &plan, &mut outcome).await;
let mut out = io::stdout().lock();
write_plan(&mut out, &plan, json, &outcome, false).context("failed to write result")?;
drop(out);
result.with_context(|| match outcome.declare {
Some(tx) => format!(
"declareMbps ALREADY LANDED (tx {tx:#x}): the declared tier is now lowered, but \
NO unbonding request was started and no TOKEN was released. Re-run the identical \
command to resume — it skips the declare and requests the unbond"
),
None => "no transaction landed; on-chain state is unchanged".to_string(),
})
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) enum AmountRequest {
ToMbps(u64),
All,
Exact(U256),
Unspecified,
}
impl AmountRequest {
fn from_args(args: &cli::UnbondArgs) -> anyhow::Result<Self> {
match (args.to_mbps, args.all, args.amount) {
(Some(mbps), false, None) => Ok(Self::ToMbps(mbps)),
(None, true, None) => Ok(Self::All),
(None, false, Some(amount)) => Ok(Self::Exact(U256::from(amount))),
(None, false, None) => Ok(Self::Unspecified),
_ => anyhow::bail!(
"--to-mbps, --all and --amount are mutually exclusive; pass exactly one"
),
}
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) enum Action {
Request {
release: U256,
retained: U256,
declare_to: Option<u64>,
},
Waiting {
amount: U256,
unlock_at: u64,
now: u64,
},
Withdraw { amount: U256 },
}
pub(crate) struct Plan {
pub(crate) capacity_bond: Address,
pub(crate) action: Action,
pub(crate) prior: U256,
pub(crate) declared_mbps: u64,
pub(crate) unbonding_period: u64,
pub(crate) below_min_bond: bool,
}
impl Plan {
pub(crate) const fn remaining_secs(&self) -> u64 {
match self.action {
Action::Waiting { unlock_at, now, .. } => unlock_at.saturating_sub(now),
_ => 0,
}
}
}
#[derive(Default)]
pub(crate) struct Outcome {
pub(crate) declare: Option<B256>,
pub(crate) request: Option<B256>,
pub(crate) withdraw: Option<B256>,
}
pub(crate) async fn build_plan<P: Provider + Clone, Q: Provider>(
bond: &CapacityBond::CapacityBondInstance<P>,
provider: &Q,
operator: Address,
cb_addr: Address,
request: AmountRequest,
) -> anyhow::Result<Plan> {
let ctx = || format!("failed to read CapacityBond state at {cb_addr}");
let pending = bond.unbondingOf(operator).call().await.with_context(ctx)?;
let prior = bond.activeBond(operator).call().await.with_context(ctx)?;
let declared = bond.declaredMbps(operator).call().await.with_context(ctx)?;
let min_bond = bond.minBond().call().await.with_context(ctx)?;
let unbonding_period = bond.unbondingPeriod().call().await.with_context(ctx)?;
let declared_mbps = u64::try_from(declared).with_context(|| {
format!(
"CapacityBond at {cb_addr} returned declaredMbps={declared}, far above its own \
maxCapacityMbps ceiling — is `capacity_bond_address` pointing at the right contract?"
)
})?;
let period_secs = u64::try_from(unbonding_period).unwrap_or(u64::MAX);
if pending.amount != U256::ZERO {
anyhow::ensure!(
request == AmountRequest::Unspecified,
"an unbonding request for {} base units is already in flight; \
`requestUnbond` reverts while one is pending. Re-run \
`decdn node unbond` with no amount flag to withdraw it once it matures.",
pending.amount,
);
let now = chain_ctx::head_timestamp(provider).await?;
let matured = U256::from(now) >= pending.unlockAt;
let unlock_at = u64::try_from(pending.unlockAt).unwrap_or(u64::MAX);
let action = if matured {
Action::Withdraw {
amount: pending.amount,
}
} else {
Action::Waiting {
amount: pending.amount,
unlock_at,
now,
}
};
return Ok(Plan {
capacity_bond: cb_addr,
action,
prior,
declared_mbps,
unbonding_period: period_secs,
below_min_bond: prior < min_bond,
});
}
anyhow::ensure!(
request != AmountRequest::Unspecified,
"no unbonding request in flight and no amount given: pass --to-mbps <MBPS> \
to reduce the declared tier, --all to release everything the bond curve \
permits, or --amount <BASE_UNITS> for an exact figure",
);
anyhow::ensure!(prior > U256::ZERO, "no active bond to unbond");
let active = bond
.getNodeByAddress(operator)
.call()
.await
.with_context(ctx)?
.active;
let (release, retained, declare_to) =
resolve_release(bond, cb_addr, request, prior, declared, min_bond, active).await?;
Ok(Plan {
capacity_bond: cb_addr,
action: Action::Request {
release,
retained,
declare_to,
},
prior,
declared_mbps,
unbonding_period: period_secs,
below_min_bond: retained < min_bond,
})
}
fn tier_step(target_mbps: u64, declared_mbps: u64) -> anyhow::Result<Option<u64>> {
anyhow::ensure!(
target_mbps <= declared_mbps,
"--to-mbps {target_mbps} is above the current declared tier \
({declared_mbps} Mbps); raise capacity with `decdn node bond --mbps \
{target_mbps}` instead",
);
Ok((target_mbps < declared_mbps).then_some(target_mbps))
}
fn all_target(active: bool, declared: U256) -> (Option<u64>, U256) {
if !active && declared != U256::ZERO {
(Some(0), U256::ZERO)
} else {
(None, declared)
}
}
async fn resolve_release<P: Provider + Clone>(
bond: &CapacityBond::CapacityBondInstance<P>,
cb_addr: Address,
request: AmountRequest,
prior: U256,
declared: U256,
min_bond: U256,
active: bool,
) -> anyhow::Result<(U256, U256, Option<u64>)> {
let ctx = || format!("failed to read CapacityBond state at {cb_addr}");
let declared_mbps = u64::try_from(declared).unwrap_or(u64::MAX);
let curve_floor =
|mbps: U256| async move { bond.bondRequired(mbps).call().await.with_context(ctx) };
match request {
AmountRequest::ToMbps(target_mbps) => {
let target = U256::from(target_mbps);
let declare_to = tier_step(target_mbps, declared_mbps)?;
if declare_to.is_some() {
let min_cap = bond.minCapacityMbps().call().await.with_context(ctx)?;
let max_cap = bond.maxCapacityMbps().call().await.with_context(ctx)?;
anyhow::ensure!(
target >= min_cap && target <= max_cap,
"declared capacity {target_mbps} Mbps is outside the on-chain band \
[{min_cap}, {max_cap}]; declareMbps would revert",
);
}
let retained = min_bond.max(curve_floor(target).await?);
let release = prior.saturating_sub(retained);
anyhow::ensure!(
release > U256::ZERO,
"nothing to release: the active bond ({prior}) is already at or below \
the {target_mbps} Mbps target ({retained} base units)",
);
Ok((release, retained, declare_to))
}
AmountRequest::All => {
let (declare_to, target) = all_target(active, declared);
let retained = curve_floor(target).await?;
let release = prior.saturating_sub(retained);
anyhow::ensure!(
release > U256::ZERO,
"nothing to release: the active bond ({prior}) is already at the curve \
floor for {declared_mbps} Mbps ({retained} base units)",
);
Ok((release, retained, declare_to))
}
AmountRequest::Exact(amount) => {
anyhow::ensure!(amount > U256::ZERO, "--amount must be greater than zero");
anyhow::ensure!(
amount <= prior,
"--amount {amount} exceeds the active bond ({prior} base units)",
);
let retained = prior - amount;
let floor = curve_floor(declared).await?;
anyhow::ensure!(
retained >= floor,
"releasing {amount} base units would leave {retained}, below the \
{declared_mbps} Mbps curve floor of {floor}; requestUnbond would revert \
with BondBelowCurve. Lower the tier first with `decdn node bond --mbps \
<lower>`, or use `--to-mbps <lower>` to do both in one run.",
);
Ok((amount, retained, None))
}
AmountRequest::Unspecified => {
anyhow::bail!("internal: no amount requested for a new unbonding request")
}
}
}
pub(crate) async fn execute<P: Provider + Clone>(
bond: &CapacityBond::CapacityBondInstance<P>,
plan: &Plan,
outcome: &mut Outcome,
) -> anyhow::Result<()> {
match plan.action {
Action::Request {
release,
declare_to,
..
} => {
if let Some(mbps) = declare_to {
let hint = if mbps == 0 {
"declareMbps(0) is accepted only from an operator that is NOT in the \
registered set; if this node is still registered, use `decdn node \
deregister` instead"
.to_string()
} else {
format!("is {mbps} Mbps within the capacity band?")
};
chain_ctx::send(
bond.declareMbps(U256::from(mbps)),
"declareMbps",
Some(&hint),
&mut outcome.declare,
)
.await?;
}
chain_ctx::send(
bond.requestUnbond(release),
"requestUnbond",
None,
&mut outcome.request,
)
.await?;
}
Action::Withdraw { .. } => {
chain_ctx::send(bond.unbond(), "unbond", None, &mut outcome.withdraw).await?;
}
Action::Waiting { .. } => anyhow::bail!(
"internal: reached `execute` with a still-maturing request; nothing was submitted"
),
}
Ok(())
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) enum Confirmation {
NotNeeded,
Bypassed,
Prompt,
NeedFlag,
}
pub(crate) const fn decide_confirmation(
action: Action,
yes: bool,
interactive: bool,
) -> Confirmation {
if !matches!(action, Action::Request { .. }) {
return Confirmation::NotNeeded;
}
if yes {
return Confirmation::Bypassed;
}
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(plan.action, yes, interactive);
if decision == Confirmation::NotNeeded {
return Ok(());
}
let Action::Request {
release, retained, ..
} = plan.action
else {
return Ok(());
};
let mut err = io::stderr().lock();
writeln!(
err,
"About to release {release} base units (retaining {retained}). Your node will be \
INACTIVE for the full {} unbonding window, and the TOKEN is only withdrawable \
after it.",
format_remaining(plan.unbonding_period),
)?;
if plan.below_min_bond {
writeln!(
err,
"The retained bond is below minBond, so the node stays inactive after the \
withdrawal until it re-bonds."
)?;
}
match decision {
Confirmation::NotNeeded | Confirmation::Bypassed => Ok(()),
Confirmation::NeedFlag => anyhow::bail!(
"unbond 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", "unbond cancelled.");
Ok(())
}
}
}
pub(crate) fn write_plan(
w: &mut impl io::Write,
p: &Plan,
json: bool,
o: &Outcome,
dry_run: bool,
) -> io::Result<()> {
let submitted = o.declare.is_some() || o.request.is_some() || o.withdraw.is_some();
let tx_hex = |h: Option<&B256>| h.map(|v| format!("{v:#x}"));
let phase = match p.action {
Action::Request { .. } => "request",
Action::Waiting { .. } => "waiting",
Action::Withdraw { .. } => "withdraw",
};
if json {
let mut value = serde_json::json!({
"phase": phase,
"submitted": submitted,
"dry_run": dry_run,
"capacity_bond": format!("{:#x}", p.capacity_bond),
"prior_bond_base": p.prior.to_string(),
"declared_mbps": p.declared_mbps,
"unbonding_period_secs": p.unbonding_period,
"below_min_bond": p.below_min_bond,
"declare_tx": tx_hex(o.declare.as_ref()),
"request_tx": tx_hex(o.request.as_ref()),
"withdraw_tx": tx_hex(o.withdraw.as_ref()),
});
if let Some(obj) = value.as_object_mut() {
match p.action {
Action::Request {
release,
retained,
declare_to,
} => {
obj.insert("release_base".into(), release.to_string().into());
obj.insert("retained_bond_base".into(), retained.to_string().into());
obj.insert("declare_to_mbps".into(), declare_to.into());
}
Action::Waiting {
amount, unlock_at, ..
} => {
obj.insert("pending_base".into(), amount.to_string().into());
obj.insert("unlock_at".into(), unlock_at.into());
obj.insert("remaining_secs".into(), p.remaining_secs().into());
}
Action::Withdraw { amount } => {
obj.insert("withdrawn_base".into(), amount.to_string().into());
}
}
}
return writeln!(w, "{value}");
}
writeln!(w, "phase={phase}")?;
writeln!(w, "capacity_bond={:#x}", p.capacity_bond)?;
writeln!(w, "prior_bond_base={}", p.prior)?;
writeln!(w, "declared_mbps={}", p.declared_mbps)?;
match p.action {
Action::Request {
release,
retained,
declare_to,
} => {
writeln!(w, "release_base={release}")?;
writeln!(w, "retained_bond_base={retained}")?;
match declare_to {
Some(mbps) => writeln!(w, "declare_to_mbps={mbps}")?,
None => writeln!(w, "declare_to_mbps=none")?,
}
writeln!(
w,
"unbonding_period={}",
format_remaining(p.unbonding_period)
)?;
writeln!(w, "below_min_bond={}", p.below_min_bond)?;
}
Action::Waiting {
amount, unlock_at, ..
} => {
writeln!(w, "pending_base={amount}")?;
writeln!(w, "unlock_at={unlock_at}")?;
writeln!(w, "remaining={}", format_remaining(p.remaining_secs()))?;
}
Action::Withdraw { amount } => {
writeln!(w, "withdrawn_base={amount}")?;
}
}
write_tx_line(w, "declare_tx", o.declare.as_ref())?;
write_tx_line(w, "request_tx", o.request.as_ref())?;
write_tx_line(w, "withdraw_tx", o.withdraw.as_ref())?;
writeln!(w, "submitted={submitted} dry_run={dry_run}")
}
fn write_tx_line(w: &mut impl io::Write, key: &str, tx: Option<&B256>) -> io::Result<()> {
match tx {
Some(h) => writeln!(w, "{key}={h:#x}"),
None => writeln!(w, "{key}=skipped"),
}
}
pub(crate) fn format_remaining(secs: u64) -> String {
const MIN: u64 = 60;
const HOUR: u64 = 60 * MIN;
const DAY: u64 = 24 * HOUR;
if secs == 0 {
return "now".to_string();
}
if secs < MIN {
return format!("{secs}s");
}
if secs < HOUR {
return format!("{}m", secs / MIN);
}
if secs < DAY {
return format!("{}h {}m", secs / HOUR, (secs % HOUR) / MIN);
}
format!("{}d {}h", secs / DAY, (secs % DAY) / HOUR)
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used)]
mod tests;