use std::io::{self, Write};
use std::path::Path;
use std::time::{Duration, SystemTime, UNIX_EPOCH};
use alloy::primitives::{Address, B256, U256};
use alloy::providers::{Provider, ProviderBuilder};
use anyhow::Context;
use decdn_common::cli;
use decdn_common::identity;
use decdn_incentive::Erc20;
use decdn_incentive::capacity_bond::CapacityBond;
use decdn_incentive::eth_identity;
use crate::commands::{bond, chain_ctx, key_gen, register, terms};
const CLOCK_SKEW_LIMIT_SECS: i64 = 10;
const PREFLIGHT_GAS_UNITS: u64 = 600_000;
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
enum CheckStatus {
Ok,
Warn,
Fail,
}
impl CheckStatus {
const fn mark(self) -> &'static str {
match self {
Self::Ok => "ok",
Self::Warn => "WARN",
Self::Fail => "FAIL",
}
}
const fn blocks(self) -> bool {
matches!(self, Self::Fail)
}
}
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
enum KeyAction {
Present,
Generate,
}
struct Preflight {
rpc_chain_id: u64,
signing_chain_id: u64,
clock_skew: Option<i64>,
token_balance: U256,
shortfall: U256,
native_balance: U256,
gas_needed: U256,
}
impl Preflight {
const fn chain_id_ok(&self) -> bool {
self.rpc_chain_id == self.signing_chain_id
}
const fn clock_status(&self) -> CheckStatus {
match self.clock_skew {
None => CheckStatus::Warn,
Some(s) if s.abs() <= CLOCK_SKEW_LIMIT_SECS => CheckStatus::Ok,
Some(_) => CheckStatus::Fail,
}
}
fn token_ok(&self) -> bool {
self.token_balance >= self.shortfall
}
fn native_ok(&self) -> bool {
self.native_balance >= self.gas_needed
}
fn all_ok(&self) -> bool {
self.chain_id_ok() && !self.clock_status().blocks() && self.token_ok() && self.native_ok()
}
}
#[allow(clippy::too_many_lines, clippy::cognitive_complexity)]
pub async fn run(args: &cli::SetupArgs, 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 json = args.chain.common.json;
let dry_run = args.chain.common.dry_run;
let key_path = identity::key_path(&resolved.data_dir);
let default_keystore = eth_identity::keystore_path(&resolved.data_dir);
let mut keys_generated = false;
match precheck_keys(
key_path.exists(),
resolved.keystore.exists(),
&resolved.keystore,
&default_keystore,
)? {
KeyAction::Present => hline(json, "keys: present"),
KeyAction::Generate => {
if dry_run {
hline(
json,
"keys: would generate (node key + eth keystore absent)",
);
return dry_run_without_keys(&resolved, cb_addr, args.mbps, json).await;
}
hline(json, "keys: generating (node key + eth keystore absent)");
let kg = cli::KeyGenArgs {
output_dir: Some(resolved.data_dir.clone()),
force: false,
keystore_password_file: args.chain.common.keystore_password_file.clone(),
};
key_gen::key_gen(&kg).context("key generation failed")?;
keys_generated = true;
}
}
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_contract = CapacityBond::new(cb_addr, &provider);
let node_secret = identity::load_or_generate(&resolved.data_dir).with_context(|| {
format!(
"failed to load node key from {}",
resolved.data_dir.display()
)
})?;
let local_node_id = B256::from_slice(node_secret.public().as_bytes());
if !json {
println!("pre-flight checks:");
}
let rpc_chain_id = provider
.get_chain_id()
.await
.context("rpc_url is not reachable (failed to read chainId)")?;
let plan = bond::build_plan(&bond_contract, operator, U256::from(args.mbps), cb_addr).await?;
let token_balance = Erc20::new(plan.token, &provider)
.balanceOf(operator)
.call()
.await
.with_context(|| format!("failed to read TOKEN balance from {}", plan.token))?;
let native_balance = provider
.get_balance(operator)
.await
.context("failed to read native-gas balance")?;
let gas_price = provider
.get_gas_price()
.await
.context("failed to read gas price")?;
let clock_skew = measure_clock_skew(&resolved.rpc_url).await;
let gas_needed = U256::from(PREFLIGHT_GAS_UNITS) * U256::from(gas_price);
let pf = Preflight {
rpc_chain_id,
signing_chain_id: resolved.chain_id,
clock_skew,
token_balance,
shortfall: plan.shortfall,
native_balance,
gas_needed,
};
check_line(
json,
"rpc",
status(pf.chain_id_ok()),
&format!("chainId {rpc_chain_id}, signing for {}", resolved.chain_id),
);
check_line(json, "clock", pf.clock_status(), &clock_detail(clock_skew));
check_line(
json,
"token_balance",
status(pf.token_ok()),
&format!("need {} base units, hold {token_balance}", plan.shortfall),
);
check_line(
json,
"native_gas",
status(pf.native_ok()),
&format!("est need ~{gas_needed} wei, hold {native_balance}"),
);
if dry_run {
if !json {
println!("bond (dry-run):");
let mut out = io::stdout().lock();
bond::write_plan(&mut out, &plan, false, &bond::Outcome::default(), true)
.context("failed to write dry-run bond plan")?;
}
let readiness = read_readiness(&bond_contract, operator).await?;
let multiaddr_count = register::registration_multiaddrs(&args.multiaddrs, &file).len();
emit_readiness(json, &readiness, &args.region, multiaddr_count);
if json {
println!(
"{}",
build_summary(
&pf,
keys_generated,
&plan,
&bond::Outcome::default(),
None,
true,
Some(&readiness),
None,
None,
&args.region,
multiaddr_count,
)
);
}
anyhow::ensure!(
pf.all_ok(),
"pre-flight checks failed (see above); resolve before a live run"
);
return Ok(());
}
anyhow::ensure!(
pf.all_ok(),
"pre-flight checks failed (see above); nothing was submitted",
);
let bound = bond_contract
.nodeIdOf(operator)
.call()
.await
.with_context(|| format!("failed to read nodeIdOf from CapacityBond at {cb_addr}"))?;
let already_registered = match registration_status(bound.nodeId, bound.active, local_node_id) {
RegistrationStatus::Divergent => anyhow::bail!(
"operator {operator:#x} is already bound on-chain to node {:#x}, but the local \
node key is {local_node_id:#x}; setup will not register a different key. Restore \
the bound key under {}, or run `decdn node deregister` first",
bound.nodeId,
resolved.data_dir.display(),
),
RegistrationStatus::AlreadyRegistered => true,
RegistrationStatus::Due => false,
};
let terms_hash = accept_terms_if_registering(
&bond_contract,
cb_addr,
already_registered,
args.accept_terms,
)
.await?;
if !args.yes
&& !confirm(plan.shortfall, plan.target, args.mbps)
.context("failed to read confirmation from stdin")?
{
anyhow::bail!(
"aborted by operator (no confirmation); re-run with --yes for non-interactive use"
);
}
let mut bond_outcome = bond::Outcome::default();
let mut register_outcome: Option<register::RegisterOutcome> = None;
let mut register_tx: Option<B256> = None;
let mut bond_reported = false;
let mut bond_done = false;
let live: anyhow::Result<Readiness> = async {
if !json {
println!("bond:");
}
bond::execute(
&bond_contract,
&provider,
&plan,
operator,
cb_addr,
U256::from(args.mbps),
&mut bond_outcome,
)
.await?;
bond_done = true;
if !json {
let mut out = io::stdout().lock();
bond::write_plan(&mut out, &plan, false, &bond_outcome, false)
.context("failed to write bond result")?;
bond_reported = true;
}
if let Some(terms_hash) = terms_hash {
if !json {
println!("register:");
}
let multiaddrs = register::registration_multiaddrs(&args.multiaddrs, &file);
let outcome = register::submit_registration(
&provider,
&signer,
&resolved.data_dir,
cb_addr,
resolved.chain_id,
&args.region,
&multiaddrs,
terms_hash,
false,
&mut register_tx,
)
.await?;
if !json {
let mut out = io::stdout().lock();
register::write_outcome(&mut out, &outcome, false)
.context("failed to write register result")?;
}
register_outcome = Some(outcome);
} else {
hline(
json,
"register: skipped (this node key already registered on-chain)",
);
}
read_readiness(&bond_contract, operator).await
}
.await;
let multiaddr_count = register_outcome
.as_ref()
.map_or(args.multiaddrs.len(), |o| o.multiaddr_count);
let summary = |readiness: Option<&Readiness>| {
build_summary(
&pf,
keys_generated,
&plan,
&bond_outcome,
register_outcome.as_ref(),
false,
readiness,
Some(terms_hash.is_some()),
register_tx,
&args.region,
multiaddr_count,
)
};
let readiness = match live {
Ok(readiness) => readiness,
Err(err) => {
let write_result = if json {
writeln!(io::stdout().lock(), "{}", summary(None))
} else if bond_reported {
Ok(())
} else {
bond::write_plan(&mut io::stdout().lock(), &plan, false, &bond_outcome, false)
};
if let Err(write_err) = write_result {
eprintln!("warning: failed to write the partial setup report: {write_err}");
}
if bond_done {
return Err(err);
}
return Err(err).context(bond::resume_hint(&bond_outcome));
}
};
emit_readiness(json, &readiness, &args.region, multiaddr_count);
if json {
println!("{}", summary(Some(&readiness)));
}
Ok(())
}
fn precheck_keys(
node_present: bool,
keystore_present: bool,
keystore: &Path,
default_keystore: &Path,
) -> anyhow::Result<KeyAction> {
if node_present && keystore_present {
return Ok(KeyAction::Present);
}
anyhow::ensure!(
node_present == keystore_present,
"partial key material (node key {}, eth keystore {}); resolve manually before setup — \
restore the missing file, or run `decdn key-gen --force` to regenerate both",
present_label(node_present),
present_label(keystore_present),
);
anyhow::ensure!(
keystore == default_keystore,
"no keys yet and a custom keystore path is set ({}); run `decdn key-gen` to create keys \
at that path first, then re-run setup",
keystore.display(),
);
Ok(KeyAction::Generate)
}
enum RegistrationStatus {
Divergent,
AlreadyRegistered,
Due,
}
fn registration_status(
bound_node_id: B256,
bound_active: bool,
local_node_id: B256,
) -> RegistrationStatus {
if bound_node_id != B256::ZERO && bound_node_id != local_node_id {
return RegistrationStatus::Divergent;
}
if bound_node_id == local_node_id && bound_active {
RegistrationStatus::AlreadyRegistered
} else {
RegistrationStatus::Due
}
}
async fn accept_terms_if_registering<P: Provider + Clone>(
bond_contract: &CapacityBond::CapacityBondInstance<P>,
cb_addr: Address,
already_registered: bool,
accept_flag: bool,
) -> anyhow::Result<Option<B256>> {
if already_registered {
return Ok(None);
}
let hash = bond_contract
.currentTermsHash()
.call()
.await
.with_context(|| {
format!("failed to read currentTermsHash from CapacityBond at {cb_addr}")
})?;
terms::ensure_accepted_async(hash, accept_flag).await?;
Ok(Some(hash))
}
struct Readiness {
active: bool,
active_bond: U256,
declared_mbps: U256,
node_id: B256,
}
async fn read_readiness<P: Provider + Clone>(
bond_contract: &CapacityBond::CapacityBondInstance<P>,
operator: Address,
) -> anyhow::Result<Readiness> {
let ctx = || "failed to read CapacityBond readiness state".to_string();
let active = bond_contract
.isActive(operator)
.call()
.await
.with_context(ctx)?;
let active_bond = bond_contract
.activeBond(operator)
.call()
.await
.with_context(ctx)?;
let declared_mbps = bond_contract
.declaredMbps(operator)
.call()
.await
.with_context(ctx)?;
let bound = bond_contract
.nodeIdOf(operator)
.call()
.await
.with_context(ctx)?;
Ok(Readiness {
active,
active_bond,
declared_mbps,
node_id: bound.nodeId,
})
}
async fn dry_run_without_keys(
resolved: &chain_ctx::Resolved,
cb_addr: Address,
mbps: u64,
json: bool,
) -> anyhow::Result<()> {
let provider =
ProviderBuilder::new().connect_http(resolved.rpc_url.parse().with_context(|| {
format!(
"rpc_url is not a valid URL (<redacted>, {} chars)",
resolved.rpc_url.len()
)
})?);
let bond_contract = CapacityBond::new(cb_addr, &provider);
let m = U256::from(mbps);
let min_cap = bond_contract
.minCapacityMbps()
.call()
.await
.context("failed to read minCapacityMbps (RPC reachable?)")?;
let max_cap = bond_contract
.maxCapacityMbps()
.call()
.await
.context("failed to read maxCapacityMbps")?;
anyhow::ensure!(
m >= min_cap && m <= max_cap,
"declared capacity {mbps} Mbps is outside the on-chain band [{min_cap}, {max_cap}]; \
declareMbps would revert",
);
let required = bond_contract
.bondRequired(m)
.call()
.await
.context("failed to read bondRequired")?;
let min_bond = bond_contract
.minBond()
.call()
.await
.context("failed to read minBond")?;
let target = min_bond.max(required);
if json {
let value = serde_json::json!({
"dry_run": true,
"keys_generated": false,
"would_generate_keys": true,
"mbps": mbps,
"bond_required_base": required.to_string(),
"target_bond_base": target.to_string(),
});
println!("{value}");
} else {
println!("bond (dry-run, no keys yet):");
println!(" mbps={mbps}");
println!(" bond_required_base={required}");
println!(" target_bond_base={target}");
println!("note: run setup without --dry-run to generate keys and submit");
}
Ok(())
}
fn hline(json: bool, line: &str) {
if !json {
println!("{line}");
}
}
const fn status(ok: bool) -> CheckStatus {
if ok {
CheckStatus::Ok
} else {
CheckStatus::Fail
}
}
fn check_line(json: bool, label: &str, status: CheckStatus, detail: &str) {
if json {
return;
}
let mark = status.mark();
if detail.is_empty() {
println!(" {label}: {mark}");
} else {
println!(" {label}: {mark} ({detail})");
}
}
const fn present_label(present: bool) -> &'static str {
if present { "present" } else { "absent" }
}
fn clock_detail(skew: Option<i64>) -> String {
match skew {
Some(s) => format!("local clock offset {s}s vs reference"),
None => "reference time unavailable — could not verify, proceeding".to_string(),
}
}
async fn measure_clock_skew(rpc_url: &str) -> Option<i64> {
let client = reqwest::Client::builder()
.timeout(Duration::from_secs(5))
.build()
.ok()?;
let date = match fetch_date_header(&client, rpc_url, true).await {
Some(d) => d,
None => fetch_date_header(&client, rpc_url, false).await?,
};
let reference = parse_http_date(&date)?;
let local = SystemTime::now().duration_since(UNIX_EPOCH).ok()?.as_secs();
let local = i64::try_from(local).unwrap_or(i64::MAX);
Some(local - reference)
}
async fn fetch_date_header(client: &reqwest::Client, url: &str, head: bool) -> Option<String> {
let req = if head {
client.head(url)
} else {
client.get(url)
};
let resp = req.send().await.ok()?;
resp.headers()
.get(reqwest::header::DATE)?
.to_str()
.ok()
.map(str::to_string)
}
fn parse_http_date(s: &str) -> Option<i64> {
let tokens: Vec<&str> = s.split_whitespace().collect();
let day: i64 = tokens.get(1)?.parse().ok()?;
let month = month_num(tokens.get(2)?)?;
let year: i64 = tokens.get(3)?.parse().ok()?;
let mut hms = tokens.get(4)?.split(':');
let hour: i64 = hms.next()?.parse().ok()?;
let min: i64 = hms.next()?.parse().ok()?;
let sec: i64 = hms.next()?.parse().ok()?;
if !(0..=23).contains(&hour)
|| !(0..=59).contains(&min)
|| !(0..=60).contains(&sec)
|| !(1..=31).contains(&day)
|| !(1970..=9999).contains(&year)
{
return None;
}
let days = days_from_civil(year, month, day);
Some(days * 86_400 + hour * 3_600 + min * 60 + sec)
}
fn month_num(m: &str) -> Option<i64> {
Some(match m {
"Jan" => 1,
"Feb" => 2,
"Mar" => 3,
"Apr" => 4,
"May" => 5,
"Jun" => 6,
"Jul" => 7,
"Aug" => 8,
"Sep" => 9,
"Oct" => 10,
"Nov" => 11,
"Dec" => 12,
_ => return None,
})
}
const fn days_from_civil(year: i64, month: i64, day: i64) -> i64 {
let y = if month <= 2 { year - 1 } else { year };
let era = if y >= 0 { y } else { y - 399 } / 400;
let yoe = y - era * 400; let mp = if month > 2 { month - 3 } else { month + 9 }; let doy = (153 * mp + 2) / 5 + day - 1; let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy; era * 146_097 + doe - 719_468
}
fn confirm(shortfall: U256, target: U256, mbps: u64) -> io::Result<bool> {
if shortfall == U256::ZERO {
print!(
"Bond already at target {target} (tier {mbps} Mbps); proceed with on-chain setup \
(declare tier and/or register, as needed)? [y/N] "
);
} else {
print!(
"Submit bond of {shortfall} base units (target {target}, tier {mbps} Mbps) and complete \
on-chain setup (declare + register, as needed)? [y/N] "
);
}
io::stdout().flush()?;
let mut line = String::new();
if io::stdin().read_line(&mut line)? == 0 {
return Ok(false);
}
Ok(matches!(line.trim(), "y" | "Y" | "yes" | "Yes" | "YES"))
}
fn emit_readiness(json: bool, r: &Readiness, region: &str, multiaddrs: usize) {
if json {
return;
}
println!("readiness:");
println!(" registry_active={}", r.active);
println!(" active_bond_base={}", r.active_bond);
println!(" declared_mbps={}", r.declared_mbps);
println!(" node_id={:#x}", r.node_id);
println!(" region={region}");
println!(" multiaddrs={multiaddrs}");
}
#[allow(clippy::too_many_arguments)]
fn build_summary(
pf: &Preflight,
keys_generated: bool,
plan: &bond::Plan,
bond_outcome: &bond::Outcome,
register_outcome: Option<®ister::RegisterOutcome>,
dry_run: bool,
readiness: Option<&Readiness>,
register_due: Option<bool>,
register_tx: Option<B256>,
region: &str,
multiaddrs: usize,
) -> serde_json::Value {
let tx_hex = |h: Option<B256>| h.map(|v| format!("{v:#x}"));
serde_json::json!({
"dry_run": dry_run,
"preflight": {
"rpc_chain_id": pf.rpc_chain_id,
"signing_chain_id": pf.signing_chain_id,
"chain_id_ok": pf.chain_id_ok(),
"clock_skew_secs": pf.clock_skew,
"clock_ok": !pf.clock_status().blocks(),
"token_balance_base": pf.token_balance.to_string(),
"token_ok": pf.token_ok(),
"native_balance_wei": pf.native_balance.to_string(),
"native_gas_needed_wei": pf.gas_needed.to_string(),
"native_ok": pf.native_ok(),
},
"keys_generated": keys_generated,
"bond": {
"mbps": plan.mbps,
"target_bond_base": plan.target.to_string(),
"bonded_base": plan.shortfall.to_string(),
"approve_tx": tx_hex(bond_outcome.approve),
"bond_tx": tx_hex(bond_outcome.bond),
"declare_tx": tx_hex(bond_outcome.declare),
},
"register": match register_outcome {
Some(o) => serde_json::json!({
"skipped": false,
"due": true,
"submitted": o.tx.is_some(),
"tx": tx_hex(o.tx),
"node_id": format!("{:#x}", o.node_id),
}),
None => serde_json::json!({
"skipped": true,
"due": register_due,
"tx": tx_hex(register_tx),
}),
},
"readiness": match readiness {
Some(r) => serde_json::json!({
"registry_active": r.active,
"active_bond_base": r.active_bond.to_string(),
"declared_mbps": r.declared_mbps.to_string(),
"node_id": format!("{:#x}", r.node_id),
"region": region,
"multiaddrs": multiaddrs,
}),
None => serde_json::Value::Null,
},
"partial": readiness.is_none(),
})
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used, clippy::indexing_slicing)]
mod tests;