1use 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
23pub 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 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 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
85pub(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
141pub(crate) struct Plan {
145 pub(crate) mbps: u64,
146 pub(crate) token: Address,
147 pub(crate) capacity_bond: Address,
148 pub(crate) required: U256,
150 pub(crate) target: U256,
152 pub(crate) prior: U256,
154 pub(crate) shortfall: U256,
156 pub(crate) needs_declare: bool,
158}
159
160#[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
169pub(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#[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 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 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
275pub(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 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 writeln!(w, "submitted={submitted} dry_run={dry_run}")
322}
323
324fn 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;