Skip to main content

decdn_cli/commands/
bond.rs

1//! `decdn node bond` — stake to a capacity tier (ADR 019 § Step 2.1–2.2).
2//!
3//! Idempotent "stake to capacity tier N". The operator passes only `--mbps`;
4//! the TOKEN amount is read from the on-chain `bondRequired` curve. The
5//! command tops the active bond up to `max(minBond, bondRequired(mbps))`,
6//! approving and bonding only the shortfall, then `declareMbps(mbps)`. Bonding
7//! the shortfall (rather than a flat amount) is what makes a re-run after a
8//! partial failure converge instead of over-bonding. A precondition for
9//! `decdn node register`.
10
11use std::io;
12use std::path::Path;
13
14use alloy::primitives::{Address, B256, U256};
15use alloy::providers::Provider;
16use anyhow::Context;
17use decdn_common::cli;
18use decdn_incentive::Erc20;
19use decdn_incentive::capacity_bond::CapacityBond;
20
21use crate::commands::chain_ctx;
22
23/// Entry point for `decdn node bond`.
24pub async fn run(args: &cli::BondArgs, global_config: Option<&Path>) -> anyhow::Result<()> {
25    let config_path = args.chain.common.config.as_deref().or(global_config);
26    let file = chain_ctx::load_optional_config(config_path)?;
27    let resolved = chain_ctx::resolve(&args.chain, &file)?;
28    let cb_addr = resolved.capacity_bond_address;
29
30    let signer = chain_ctx::load_operator_signer(&args.chain.common, &resolved.keystore).await?;
31    let operator = signer.address();
32    let provider = decdn_client::provider::build_provider(&resolved.rpc_url, &signer)?;
33    let bond = CapacityBond::new(cb_addr, &provider);
34
35    let mbps = U256::from(args.mbps);
36    let plan = build_plan(&bond, operator, mbps, cb_addr).await?;
37
38    if args.chain.common.dry_run {
39        let mut out = io::stdout().lock();
40        write_plan(
41            &mut out,
42            &plan,
43            args.chain.common.json,
44            &Outcome::default(),
45            true,
46        )
47        .context("failed to write dry-run output")?;
48        return Ok(());
49    }
50
51    // The outcome is owned HERE, not inside `execute`, so a partially-applied
52    // sequence survives the error (#1355, mirroring what #1353 did for
53    // `unbond`). `execute` sends up to three transactions; if `declareMbps`
54    // fails after `bond` mined, those hashes are the operator's only record that
55    // their TOKEN moved into `CapacityBond`. Returning `Err` with the outcome
56    // still inside `execute` would drop it and print nothing at all.
57    let mut outcome = Outcome::default();
58    let result = execute(
59        &bond,
60        &provider,
61        &plan,
62        operator,
63        cb_addr,
64        mbps,
65        &mut outcome,
66    )
67    .await;
68
69    let mut out = io::stdout().lock();
70    let write_result = write_plan(&mut out, &plan, args.chain.common.json, &outcome, false);
71    drop(out);
72    // A failed stdout write must never displace the chain error: under
73    // `decdn node bond --json | head`, Rust ignores SIGPIPE and `writeln!`
74    // returns EPIPE, so `?`-ing this first would swallow the very
75    // "bond ALREADY LANDED" message #1355 exists to deliver. Echo it to stderr
76    // (stdout is broken by hypothesis) and let the chain error win.
77    if let Err(err) = &write_result {
78        eprintln!("warning: failed to write bond result to stdout: {err}");
79    }
80    result.with_context(|| resume_hint(&outcome))?;
81    write_result.context("failed to write result")?;
82    Ok(())
83}
84
85/// Operator-facing guidance attached to a failed [`execute`], naming the latest
86/// step that may have taken effect.
87///
88/// Reads [`Outcome`] as [`chain_ctx::send`] populates it: `Some` means "may have
89/// taken effect" — confirmed success OR broadcast-with-an-unreadable-receipt —
90/// and `None` means "definitively not applied" (never sent, or a confirmed
91/// revert). That distinction is the whole point (#1355 review): the earlier
92/// wording asserted strong negatives ("no TOKEN was transferred", "on-chain
93/// state is unchanged") and told the operator to re-run immediately, which is
94/// unsafe. alloy's nonce filler resolves against the *pending* block, so
95/// re-running while a `bond` tx is still in the mempool takes the next nonce,
96/// re-reads a stale `activeBond`, and submits a *second* top-up — doubling the
97/// bond rather than converging.
98///
99/// Matched over all three steps rather than the two that can be outstanding
100/// today. `declare` is currently unreachable in an `Err` (it is the last thing
101/// `execute` does), but that is a control-flow fact this function cannot see; a
102/// fourth step added later would otherwise silently make the guidance name the
103/// wrong transaction.
104pub(crate) fn resume_hint(outcome: &Outcome) -> String {
105    let pending = |step: &str, tx: B256, rest: &str| {
106        format!(
107            "{step} tx {tx:#x} may have taken effect — it was submitted, and if the error \
108             below is a receipt-fetch timeout rather than a revert its outcome is still \
109             unknown. {rest} Do NOT re-run until {tx:#x} has settled: re-running while it \
110             is pending takes the next nonce and submits a second transaction, which for a \
111             bond doubles it."
112        )
113    };
114    match (outcome.declare, outcome.bond, outcome.approve) {
115        (Some(tx), ..) => pending(
116            "declareMbps",
117            tx,
118            "The bond itself is in place; only a step after the tier declaration failed.",
119        ),
120        (_, Some(tx), _) => pending(
121            "bond",
122            tx,
123            "If it mined, the TOKEN is held by CapacityBond (confirm with `activeBond`) and \
124             only the capacity tier is missing; a re-run then tops up just the remaining \
125             shortfall.",
126        ),
127        (_, _, Some(tx)) => pending(
128            "approve",
129            tx,
130            "It only grants an allowance — no TOKEN moves until the bond transaction \
131             itself lands, and none is recorded as having done so.",
132        ),
133        (None, None, None) => "no transaction is recorded as having taken effect: either \
134             nothing was broadcast (a pre-flight gas estimate or a pre-flight balance check \
135             rejecting the call), or one was broadcast and confirmed reverted. Check the \
136             cause below for a tx hash, and the pending nonce, before re-running"
137            .to_string(),
138    }
139}
140
141/// What `bond` intends to do, computed from chain state. `pub(crate)` so
142/// `decdn setup` can reuse the same top-up plan for its pre-flight checks and
143/// confirmation prompt without re-deriving the curve math (#933).
144pub(crate) struct Plan {
145    pub(crate) mbps: u64,
146    pub(crate) token: Address,
147    pub(crate) capacity_bond: Address,
148    /// `bondRequired(mbps)` under the current curve (base units).
149    pub(crate) required: U256,
150    /// `max(minBond, required)` — what `registerNode` needs in place.
151    pub(crate) target: U256,
152    /// Active bond before this command.
153    pub(crate) prior: U256,
154    /// `target − prior` (saturating) — what we approve + bond.
155    pub(crate) shortfall: U256,
156    /// Whether `declareMbps` is needed (the operator isn't already at `mbps`).
157    pub(crate) needs_declare: bool,
158}
159
160/// Transaction hashes from a non-dry run; `None` for steps that were skipped
161/// (no shortfall, allowance already sufficient, tier already declared).
162#[derive(Default)]
163pub(crate) struct Outcome {
164    pub(crate) approve: Option<B256>,
165    pub(crate) bond: Option<B256>,
166    pub(crate) declare: Option<B256>,
167}
168
169/// Read chain state and compute the top-up plan. Validates the tier against
170/// the on-chain capacity band up front, so an out-of-band `--mbps` is rejected
171/// before any bond is posted rather than stranding one behind a `declareMbps`
172/// revert.
173pub(crate) async fn build_plan<P: Provider + Clone>(
174    bond: &CapacityBond::CapacityBondInstance<P>,
175    operator: Address,
176    mbps: U256,
177    cb_addr: Address,
178) -> anyhow::Result<Plan> {
179    let ctx = || format!("failed to read CapacityBond state at {cb_addr}");
180    let min_cap = bond.minCapacityMbps().call().await.with_context(ctx)?;
181    let max_cap = bond.maxCapacityMbps().call().await.with_context(ctx)?;
182    anyhow::ensure!(
183        mbps >= min_cap && mbps <= max_cap,
184        "declared capacity {mbps} Mbps is outside the on-chain band [{min_cap}, {max_cap}]; \
185         declareMbps would revert",
186    );
187
188    let required = bond.bondRequired(mbps).call().await.with_context(ctx)?;
189    let min_bond = bond.minBond().call().await.with_context(ctx)?;
190    let prior = bond.activeBond(operator).call().await.with_context(ctx)?;
191    let declared = bond.declaredMbps(operator).call().await.with_context(ctx)?;
192    let token = bond.token().call().await.with_context(ctx)?;
193
194    let target = min_bond.max(required);
195    Ok(Plan {
196        mbps: u64::try_from(mbps).unwrap_or(u64::MAX),
197        token,
198        capacity_bond: cb_addr,
199        required,
200        target,
201        prior,
202        shortfall: target.saturating_sub(prior),
203        needs_declare: declared != mbps,
204    })
205}
206
207/// Submit the needed transactions in order: approve (if allowance short) →
208/// bond (if shortfall) → declareMbps (if not already at the tier). Each step
209/// is independently skippable, which is what makes the command idempotent.
210///
211/// `outcome` is a caller-owned accumulator rather than a return value so a
212/// partial sequence survives an error — the caller reports what landed before
213/// propagating (#1355).
214#[allow(clippy::too_many_arguments)]
215pub(crate) async fn execute<P: Provider + Clone>(
216    bond: &CapacityBond::CapacityBondInstance<P>,
217    provider: P,
218    plan: &Plan,
219    operator: Address,
220    cb_addr: Address,
221    mbps: U256,
222    outcome: &mut Outcome,
223) -> anyhow::Result<()> {
224    if plan.shortfall > U256::ZERO {
225        let token = Erc20::new(plan.token, provider);
226        // Balance check before any send so a doomed run fails clean.
227        let balance = token
228            .balanceOf(operator)
229            .call()
230            .await
231            .with_context(|| format!("failed to read TOKEN balance from {}", plan.token))?;
232        anyhow::ensure!(
233            balance >= plan.shortfall,
234            "insufficient TOKEN: need {} base units to reach the tier-{} bond, hold {}",
235            plan.shortfall,
236            plan.mbps,
237            balance,
238        );
239
240        let allowance = token
241            .allowance(operator, cb_addr)
242            .call()
243            .await
244            .with_context(|| format!("failed to read TOKEN allowance from {}", plan.token))?;
245        if allowance < plan.shortfall {
246            chain_ctx::send(
247                token.approve(cb_addr, plan.shortfall),
248                "approve",
249                None,
250                &mut outcome.approve,
251            )
252            .await?;
253        }
254
255        chain_ctx::send(bond.bond(plan.shortfall), "bond", None, &mut outcome.bond).await?;
256    }
257
258    if plan.needs_declare {
259        // The band is pre-validated in `build_plan`, so this fires only on a
260        // mid-run governance change — hence "still", to stop it reading as the
261        // first thing to suspect.
262        let hint = format!("is {} Mbps still within the capacity band?", plan.mbps);
263        chain_ctx::send(
264            bond.declareMbps(mbps),
265            "declareMbps",
266            Some(&hint),
267            &mut outcome.declare,
268        )
269        .await?;
270    }
271
272    Ok(())
273}
274
275/// Write the plan + outcome as JSON or grep-friendly `key=value` lines. Pure
276/// (`&mut impl Write`) so the output shape is unit-testable without a chain.
277/// `dry_run` distinguishes a `--dry-run` preview from a real run that was a
278/// no-op (already at the target tier) — both submit nothing, but only the
279/// former is a dry run.
280pub(crate) fn write_plan(
281    w: &mut impl io::Write,
282    p: &Plan,
283    json: bool,
284    o: &Outcome,
285    dry_run: bool,
286) -> io::Result<()> {
287    let submitted = o.approve.is_some() || o.bond.is_some() || o.declare.is_some();
288    let tx_hex = |h: Option<&B256>| h.map(|v| format!("{v:#x}"));
289    if json {
290        let value = serde_json::json!({
291            "submitted": submitted,
292            "dry_run": dry_run,
293            "mbps": p.mbps,
294            "capacity_bond": format!("{:#x}", p.capacity_bond),
295            "token": format!("{:#x}", p.token),
296            "bond_required_base": p.required.to_string(),
297            "target_bond_base": p.target.to_string(),
298            "prior_bond_base": p.prior.to_string(),
299            "bonded_base": p.shortfall.to_string(),
300            "needs_declare": p.needs_declare,
301            "approve_tx": tx_hex(o.approve.as_ref()),
302            "bond_tx": tx_hex(o.bond.as_ref()),
303            "declare_tx": tx_hex(o.declare.as_ref()),
304        });
305        return writeln!(w, "{value}");
306    }
307    // Base units: 1 TOKEN = 1e18 base units.
308    writeln!(w, "mbps={}", p.mbps)?;
309    writeln!(w, "capacity_bond={:#x}", p.capacity_bond)?;
310    writeln!(w, "token={:#x}", p.token)?;
311    writeln!(w, "bond_required_base={}", p.required)?;
312    writeln!(w, "target_bond_base={}", p.target)?;
313    writeln!(w, "prior_bond_base={}", p.prior)?;
314    writeln!(w, "bonded_base={}", p.shortfall)?;
315    writeln!(w, "needs_declare={}", p.needs_declare)?;
316    write_tx_line(w, "approve_tx", o.approve.as_ref())?;
317    write_tx_line(w, "bond_tx", o.bond.as_ref())?;
318    write_tx_line(w, "declare_tx", o.declare.as_ref())?;
319    // A real run already at the target reports `submitted=false dry_run=false`
320    // (a genuine no-op), distinct from a `--dry-run` preview.
321    writeln!(w, "submitted={submitted} dry_run={dry_run}")
322}
323
324/// One `<key>=<tx|skipped>` line.
325///
326/// `skipped` means "no hash recorded", which since #1355 covers two cases: the
327/// step was a genuine no-op (no shortfall, allowance already sufficient, tier
328/// already declared), OR the run failed at or before that step — `run` now
329/// prints the outcome on the error path too. The exit status and the
330/// accompanying stderr error are the discriminator; this line alone is not.
331fn write_tx_line(w: &mut impl io::Write, key: &str, tx: Option<&B256>) -> io::Result<()> {
332    match tx {
333        Some(h) => writeln!(w, "{key}={h:#x}"),
334        None => writeln!(w, "{key}=skipped"),
335    }
336}
337
338#[cfg(test)]
339#[allow(clippy::unwrap_used, clippy::expect_used)]
340mod tests;