1use std::io::{self, Write};
19use std::path::Path;
20use std::time::{Duration, SystemTime, UNIX_EPOCH};
21
22use alloy::primitives::{Address, B256, U256};
23use alloy::providers::{Provider, ProviderBuilder};
24use anyhow::Context;
25use decdn_common::cli;
26use decdn_common::identity;
27use decdn_incentive::Erc20;
28use decdn_incentive::capacity_bond::CapacityBond;
29use decdn_incentive::eth_identity;
30
31use crate::commands::{bond, chain_ctx, key_gen, register, terms};
32
33const CLOCK_SKEW_LIMIT_SECS: i64 = 10;
37
38const PREFLIGHT_GAS_UNITS: u64 = 600_000;
42
43#[derive(Clone, Copy, PartialEq, Eq, Debug)]
47enum CheckStatus {
48 Ok,
49 Warn,
50 Fail,
51}
52
53impl CheckStatus {
54 const fn mark(self) -> &'static str {
56 match self {
57 Self::Ok => "ok",
58 Self::Warn => "WARN",
59 Self::Fail => "FAIL",
60 }
61 }
62
63 const fn blocks(self) -> bool {
65 matches!(self, Self::Fail)
66 }
67}
68
69#[derive(Clone, Copy, PartialEq, Eq, Debug)]
72enum KeyAction {
73 Present,
74 Generate,
75}
76
77struct Preflight {
81 rpc_chain_id: u64,
82 signing_chain_id: u64,
83 clock_skew: Option<i64>,
84 token_balance: U256,
85 shortfall: U256,
86 native_balance: U256,
87 gas_needed: U256,
88}
89
90impl Preflight {
91 const fn chain_id_ok(&self) -> bool {
94 self.rpc_chain_id == self.signing_chain_id
95 }
96
97 const fn clock_status(&self) -> CheckStatus {
99 match self.clock_skew {
100 None => CheckStatus::Warn,
101 Some(s) if s.abs() <= CLOCK_SKEW_LIMIT_SECS => CheckStatus::Ok,
102 Some(_) => CheckStatus::Fail,
103 }
104 }
105
106 fn token_ok(&self) -> bool {
108 self.token_balance >= self.shortfall
109 }
110
111 fn native_ok(&self) -> bool {
113 self.native_balance >= self.gas_needed
114 }
115
116 fn all_ok(&self) -> bool {
118 self.chain_id_ok() && !self.clock_status().blocks() && self.token_ok() && self.native_ok()
119 }
120}
121
122#[allow(clippy::too_many_lines, clippy::cognitive_complexity)]
128pub async fn run(args: &cli::SetupArgs, global_config: Option<&Path>) -> anyhow::Result<()> {
129 let config_path = args.chain.common.config.as_deref().or(global_config);
130 let file = chain_ctx::load_optional_config(config_path)?;
131 let resolved = chain_ctx::resolve(&args.chain, &file)?;
132 let cb_addr = resolved.capacity_bond_address;
133 let json = args.chain.common.json;
134 let dry_run = args.chain.common.dry_run;
135
136 let key_path = identity::key_path(&resolved.data_dir);
138 let default_keystore = eth_identity::keystore_path(&resolved.data_dir);
139 let mut keys_generated = false;
140 match precheck_keys(
141 key_path.exists(),
142 resolved.keystore.exists(),
143 &resolved.keystore,
144 &default_keystore,
145 )? {
146 KeyAction::Present => hline(json, "keys: present"),
147 KeyAction::Generate => {
148 if dry_run {
149 hline(
150 json,
151 "keys: would generate (node key + eth keystore absent)",
152 );
153 return dry_run_without_keys(&resolved, cb_addr, args.mbps, json).await;
154 }
155 hline(json, "keys: generating (node key + eth keystore absent)");
156 let kg = cli::KeyGenArgs {
157 output_dir: Some(resolved.data_dir.clone()),
158 force: false,
159 keystore_password_file: args.chain.common.keystore_password_file.clone(),
160 };
161 key_gen::key_gen(&kg).context("key generation failed")?;
162 keys_generated = true;
163 }
164 }
165
166 let signer = chain_ctx::load_operator_signer(&args.chain.common, &resolved.keystore).await?;
169 let operator = signer.address();
170 let provider = decdn_client::provider::build_provider(&resolved.rpc_url, &signer)?;
171 let bond_contract = CapacityBond::new(cb_addr, &provider);
172 let node_secret = identity::load_or_generate(&resolved.data_dir).with_context(|| {
173 format!(
174 "failed to load node key from {}",
175 resolved.data_dir.display()
176 )
177 })?;
178 let local_node_id = B256::from_slice(node_secret.public().as_bytes());
179
180 if !json {
182 println!("pre-flight checks:");
183 }
184 let rpc_chain_id = provider
187 .get_chain_id()
188 .await
189 .context("rpc_url is not reachable (failed to read chainId)")?;
190 let plan = bond::build_plan(&bond_contract, operator, U256::from(args.mbps), cb_addr).await?;
192 let token_balance = Erc20::new(plan.token, &provider)
193 .balanceOf(operator)
194 .call()
195 .await
196 .with_context(|| format!("failed to read TOKEN balance from {}", plan.token))?;
197 let native_balance = provider
198 .get_balance(operator)
199 .await
200 .context("failed to read native-gas balance")?;
201 let gas_price = provider
202 .get_gas_price()
203 .await
204 .context("failed to read gas price")?;
205 let clock_skew = measure_clock_skew(&resolved.rpc_url).await;
206
207 let gas_needed = U256::from(PREFLIGHT_GAS_UNITS) * U256::from(gas_price);
209
210 let pf = Preflight {
211 rpc_chain_id,
212 signing_chain_id: resolved.chain_id,
213 clock_skew,
214 token_balance,
215 shortfall: plan.shortfall,
216 native_balance,
217 gas_needed,
218 };
219
220 check_line(
221 json,
222 "rpc",
223 status(pf.chain_id_ok()),
224 &format!("chainId {rpc_chain_id}, signing for {}", resolved.chain_id),
225 );
226 check_line(json, "clock", pf.clock_status(), &clock_detail(clock_skew));
227
228 check_line(
229 json,
230 "token_balance",
231 status(pf.token_ok()),
232 &format!("need {} base units, hold {token_balance}", plan.shortfall),
233 );
234 check_line(
235 json,
236 "native_gas",
237 status(pf.native_ok()),
238 &format!("est need ~{gas_needed} wei, hold {native_balance}"),
239 );
240
241 if dry_run {
243 if !json {
246 println!("bond (dry-run):");
247 let mut out = io::stdout().lock();
248 bond::write_plan(&mut out, &plan, false, &bond::Outcome::default(), true)
249 .context("failed to write dry-run bond plan")?;
250 }
251 let readiness = read_readiness(&bond_contract, operator).await?;
252 let multiaddr_count = register::registration_multiaddrs(&args.multiaddrs, &file).len();
253 emit_readiness(json, &readiness, &args.region, multiaddr_count);
254 if json {
255 println!(
256 "{}",
257 build_summary(
258 &pf,
259 keys_generated,
260 &plan,
261 &bond::Outcome::default(),
262 None,
263 true,
264 Some(&readiness),
265 None,
266 None,
267 &args.region,
268 multiaddr_count,
269 )
270 );
271 }
272 anyhow::ensure!(
273 pf.all_ok(),
274 "pre-flight checks failed (see above); resolve before a live run"
275 );
276 return Ok(());
277 }
278
279 anyhow::ensure!(
281 pf.all_ok(),
282 "pre-flight checks failed (see above); nothing was submitted",
283 );
284
285 let bound = bond_contract
289 .nodeIdOf(operator)
290 .call()
291 .await
292 .with_context(|| format!("failed to read nodeIdOf from CapacityBond at {cb_addr}"))?;
293 let already_registered = match registration_status(bound.nodeId, bound.active, local_node_id) {
294 RegistrationStatus::Divergent => anyhow::bail!(
295 "operator {operator:#x} is already bound on-chain to node {:#x}, but the local \
296 node key is {local_node_id:#x}; setup will not register a different key. Restore \
297 the bound key under {}, or run `decdn node deregister` first",
298 bound.nodeId,
299 resolved.data_dir.display(),
300 ),
301 RegistrationStatus::AlreadyRegistered => true,
302 RegistrationStatus::Due => false,
303 };
304
305 let terms_hash = accept_terms_if_registering(
310 &bond_contract,
311 cb_addr,
312 already_registered,
313 args.accept_terms,
314 )
315 .await?;
316
317 if !args.yes
319 && !confirm(plan.shortfall, plan.target, args.mbps)
320 .context("failed to read confirmation from stdin")?
321 {
322 anyhow::bail!(
323 "aborted by operator (no confirmation); re-run with --yes for non-interactive use"
324 );
325 }
326
327 let mut bond_outcome = bond::Outcome::default();
337 let mut register_outcome: Option<register::RegisterOutcome> = None;
338 let mut register_tx: Option<B256> = None;
343 let mut bond_reported = false;
344 let mut bond_done = false;
347
348 let live: anyhow::Result<Readiness> = async {
349 if !json {
351 println!("bond:");
352 }
353 bond::execute(
354 &bond_contract,
355 &provider,
356 &plan,
357 operator,
358 cb_addr,
359 U256::from(args.mbps),
360 &mut bond_outcome,
361 )
362 .await?;
363 bond_done = true;
364 if !json {
365 let mut out = io::stdout().lock();
366 bond::write_plan(&mut out, &plan, false, &bond_outcome, false)
367 .context("failed to write bond result")?;
368 bond_reported = true;
369 }
370
371 if let Some(terms_hash) = terms_hash {
375 if !json {
376 println!("register:");
377 }
378 let multiaddrs = register::registration_multiaddrs(&args.multiaddrs, &file);
379 let outcome = register::submit_registration(
380 &provider,
381 &signer,
382 &resolved.data_dir,
383 cb_addr,
384 resolved.chain_id,
385 &args.region,
386 &multiaddrs,
387 terms_hash,
388 false,
389 &mut register_tx,
390 )
391 .await?;
392 if !json {
393 let mut out = io::stdout().lock();
394 register::write_outcome(&mut out, &outcome, false)
395 .context("failed to write register result")?;
396 }
397 register_outcome = Some(outcome);
398 } else {
399 hline(
400 json,
401 "register: skipped (this node key already registered on-chain)",
402 );
403 }
404
405 read_readiness(&bond_contract, operator).await
407 }
408 .await;
409
410 let multiaddr_count = register_outcome
412 .as_ref()
413 .map_or(args.multiaddrs.len(), |o| o.multiaddr_count);
414 let summary = |readiness: Option<&Readiness>| {
415 build_summary(
416 &pf,
417 keys_generated,
418 &plan,
419 &bond_outcome,
420 register_outcome.as_ref(),
421 false,
422 readiness,
423 Some(terms_hash.is_some()),
424 register_tx,
425 &args.region,
426 multiaddr_count,
427 )
428 };
429
430 let readiness = match live {
431 Ok(readiness) => readiness,
432 Err(err) => {
433 let write_result = if json {
444 writeln!(io::stdout().lock(), "{}", summary(None))
445 } else if bond_reported {
446 Ok(())
447 } else {
448 bond::write_plan(&mut io::stdout().lock(), &plan, false, &bond_outcome, false)
449 };
450 if let Err(write_err) = write_result {
451 eprintln!("warning: failed to write the partial setup report: {write_err}");
452 }
453 if bond_done {
459 return Err(err);
460 }
461 return Err(err).context(bond::resume_hint(&bond_outcome));
462 }
463 };
464
465 emit_readiness(json, &readiness, &args.region, multiaddr_count);
466 if json {
467 println!("{}", summary(Some(&readiness)));
468 }
469
470 Ok(())
471}
472
473fn precheck_keys(
478 node_present: bool,
479 keystore_present: bool,
480 keystore: &Path,
481 default_keystore: &Path,
482) -> anyhow::Result<KeyAction> {
483 if node_present && keystore_present {
484 return Ok(KeyAction::Present);
485 }
486 anyhow::ensure!(
487 node_present == keystore_present,
488 "partial key material (node key {}, eth keystore {}); resolve manually before setup — \
489 restore the missing file, or run `decdn key-gen --force` to regenerate both",
490 present_label(node_present),
491 present_label(keystore_present),
492 );
493 anyhow::ensure!(
496 keystore == default_keystore,
497 "no keys yet and a custom keystore path is set ({}); run `decdn key-gen` to create keys \
498 at that path first, then re-run setup",
499 keystore.display(),
500 );
501 Ok(KeyAction::Generate)
502}
503
504enum RegistrationStatus {
508 Divergent,
512 AlreadyRegistered,
514 Due,
519}
520
521fn registration_status(
527 bound_node_id: B256,
528 bound_active: bool,
529 local_node_id: B256,
530) -> RegistrationStatus {
531 if bound_node_id != B256::ZERO && bound_node_id != local_node_id {
532 return RegistrationStatus::Divergent;
533 }
534 if bound_node_id == local_node_id && bound_active {
535 RegistrationStatus::AlreadyRegistered
536 } else {
537 RegistrationStatus::Due
538 }
539}
540
541async fn accept_terms_if_registering<P: Provider + Clone>(
548 bond_contract: &CapacityBond::CapacityBondInstance<P>,
549 cb_addr: Address,
550 already_registered: bool,
551 accept_flag: bool,
552) -> anyhow::Result<Option<B256>> {
553 if already_registered {
554 return Ok(None);
555 }
556 let hash = bond_contract
557 .currentTermsHash()
558 .call()
559 .await
560 .with_context(|| {
561 format!("failed to read currentTermsHash from CapacityBond at {cb_addr}")
562 })?;
563 terms::ensure_accepted_async(hash, accept_flag).await?;
564 Ok(Some(hash))
565}
566
567struct Readiness {
569 active: bool,
570 active_bond: U256,
571 declared_mbps: U256,
572 node_id: B256,
573}
574
575async fn read_readiness<P: Provider + Clone>(
577 bond_contract: &CapacityBond::CapacityBondInstance<P>,
578 operator: Address,
579) -> anyhow::Result<Readiness> {
580 let ctx = || "failed to read CapacityBond readiness state".to_string();
581 let active = bond_contract
582 .isActive(operator)
583 .call()
584 .await
585 .with_context(ctx)?;
586 let active_bond = bond_contract
587 .activeBond(operator)
588 .call()
589 .await
590 .with_context(ctx)?;
591 let declared_mbps = bond_contract
592 .declaredMbps(operator)
593 .call()
594 .await
595 .with_context(ctx)?;
596 let bound = bond_contract
597 .nodeIdOf(operator)
598 .call()
599 .await
600 .with_context(ctx)?;
601 Ok(Readiness {
602 active,
603 active_bond,
604 declared_mbps,
605 node_id: bound.nodeId,
606 })
607}
608
609async fn dry_run_without_keys(
614 resolved: &chain_ctx::Resolved,
615 cb_addr: Address,
616 mbps: u64,
617 json: bool,
618) -> anyhow::Result<()> {
619 let provider =
620 ProviderBuilder::new().connect_http(resolved.rpc_url.parse().with_context(|| {
621 format!(
625 "rpc_url is not a valid URL (<redacted>, {} chars)",
626 resolved.rpc_url.len()
627 )
628 })?);
629 let bond_contract = CapacityBond::new(cb_addr, &provider);
630 let m = U256::from(mbps);
631 let min_cap = bond_contract
632 .minCapacityMbps()
633 .call()
634 .await
635 .context("failed to read minCapacityMbps (RPC reachable?)")?;
636 let max_cap = bond_contract
637 .maxCapacityMbps()
638 .call()
639 .await
640 .context("failed to read maxCapacityMbps")?;
641 anyhow::ensure!(
642 m >= min_cap && m <= max_cap,
643 "declared capacity {mbps} Mbps is outside the on-chain band [{min_cap}, {max_cap}]; \
644 declareMbps would revert",
645 );
646 let required = bond_contract
647 .bondRequired(m)
648 .call()
649 .await
650 .context("failed to read bondRequired")?;
651 let min_bond = bond_contract
652 .minBond()
653 .call()
654 .await
655 .context("failed to read minBond")?;
656 let target = min_bond.max(required);
657 if json {
658 let value = serde_json::json!({
659 "dry_run": true,
660 "keys_generated": false,
661 "would_generate_keys": true,
662 "mbps": mbps,
663 "bond_required_base": required.to_string(),
664 "target_bond_base": target.to_string(),
665 });
666 println!("{value}");
667 } else {
668 println!("bond (dry-run, no keys yet):");
669 println!(" mbps={mbps}");
670 println!(" bond_required_base={required}");
671 println!(" target_bond_base={target}");
672 println!("note: run setup without --dry-run to generate keys and submit");
673 }
674 Ok(())
675}
676
677fn hline(json: bool, line: &str) {
679 if !json {
680 println!("{line}");
681 }
682}
683
684const fn status(ok: bool) -> CheckStatus {
686 if ok {
687 CheckStatus::Ok
688 } else {
689 CheckStatus::Fail
690 }
691}
692
693fn check_line(json: bool, label: &str, status: CheckStatus, detail: &str) {
697 if json {
698 return;
699 }
700 let mark = status.mark();
701 if detail.is_empty() {
702 println!(" {label}: {mark}");
703 } else {
704 println!(" {label}: {mark} ({detail})");
705 }
706}
707
708const fn present_label(present: bool) -> &'static str {
710 if present { "present" } else { "absent" }
711}
712
713fn clock_detail(skew: Option<i64>) -> String {
715 match skew {
716 Some(s) => format!("local clock offset {s}s vs reference"),
717 None => "reference time unavailable — could not verify, proceeding".to_string(),
718 }
719}
720
721async fn measure_clock_skew(rpc_url: &str) -> Option<i64> {
726 let client = reqwest::Client::builder()
727 .timeout(Duration::from_secs(5))
728 .build()
729 .ok()?;
730 let date = match fetch_date_header(&client, rpc_url, true).await {
733 Some(d) => d,
734 None => fetch_date_header(&client, rpc_url, false).await?,
735 };
736 let reference = parse_http_date(&date)?;
737 let local = SystemTime::now().duration_since(UNIX_EPOCH).ok()?.as_secs();
738 let local = i64::try_from(local).unwrap_or(i64::MAX);
739 Some(local - reference)
740}
741
742async fn fetch_date_header(client: &reqwest::Client, url: &str, head: bool) -> Option<String> {
744 let req = if head {
745 client.head(url)
746 } else {
747 client.get(url)
748 };
749 let resp = req.send().await.ok()?;
750 resp.headers()
751 .get(reqwest::header::DATE)?
752 .to_str()
753 .ok()
754 .map(str::to_string)
755}
756
757fn parse_http_date(s: &str) -> Option<i64> {
765 let tokens: Vec<&str> = s.split_whitespace().collect();
766 let day: i64 = tokens.get(1)?.parse().ok()?;
767 let month = month_num(tokens.get(2)?)?;
768 let year: i64 = tokens.get(3)?.parse().ok()?;
769 let mut hms = tokens.get(4)?.split(':');
770 let hour: i64 = hms.next()?.parse().ok()?;
771 let min: i64 = hms.next()?.parse().ok()?;
772 let sec: i64 = hms.next()?.parse().ok()?;
773 if !(0..=23).contains(&hour)
774 || !(0..=59).contains(&min)
775 || !(0..=60).contains(&sec)
776 || !(1..=31).contains(&day)
777 || !(1970..=9999).contains(&year)
781 {
782 return None;
783 }
784 let days = days_from_civil(year, month, day);
785 Some(days * 86_400 + hour * 3_600 + min * 60 + sec)
786}
787
788fn month_num(m: &str) -> Option<i64> {
790 Some(match m {
791 "Jan" => 1,
792 "Feb" => 2,
793 "Mar" => 3,
794 "Apr" => 4,
795 "May" => 5,
796 "Jun" => 6,
797 "Jul" => 7,
798 "Aug" => 8,
799 "Sep" => 9,
800 "Oct" => 10,
801 "Nov" => 11,
802 "Dec" => 12,
803 _ => return None,
804 })
805}
806
807const fn days_from_civil(year: i64, month: i64, day: i64) -> i64 {
810 let y = if month <= 2 { year - 1 } else { year };
811 let era = if y >= 0 { y } else { y - 399 } / 400;
812 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
817}
818
819fn confirm(shortfall: U256, target: U256, mbps: u64) -> io::Result<bool> {
824 if shortfall == U256::ZERO {
825 print!(
828 "Bond already at target {target} (tier {mbps} Mbps); proceed with on-chain setup \
829 (declare tier and/or register, as needed)? [y/N] "
830 );
831 } else {
832 print!(
833 "Submit bond of {shortfall} base units (target {target}, tier {mbps} Mbps) and complete \
834 on-chain setup (declare + register, as needed)? [y/N] "
835 );
836 }
837 io::stdout().flush()?;
838 let mut line = String::new();
839 if io::stdin().read_line(&mut line)? == 0 {
840 return Ok(false);
841 }
842 Ok(matches!(line.trim(), "y" | "Y" | "yes" | "Yes" | "YES"))
843}
844
845fn emit_readiness(json: bool, r: &Readiness, region: &str, multiaddrs: usize) {
849 if json {
850 return;
851 }
852 println!("readiness:");
853 println!(" registry_active={}", r.active);
854 println!(" active_bond_base={}", r.active_bond);
855 println!(" declared_mbps={}", r.declared_mbps);
856 println!(" node_id={:#x}", r.node_id);
857 println!(" region={region}");
858 println!(" multiaddrs={multiaddrs}");
859}
860
861#[allow(clippy::too_many_arguments)]
865fn build_summary(
866 pf: &Preflight,
867 keys_generated: bool,
868 plan: &bond::Plan,
869 bond_outcome: &bond::Outcome,
870 register_outcome: Option<®ister::RegisterOutcome>,
871 dry_run: bool,
872 readiness: Option<&Readiness>,
875 register_due: Option<bool>,
879 register_tx: Option<B256>,
883 region: &str,
884 multiaddrs: usize,
885) -> serde_json::Value {
886 let tx_hex = |h: Option<B256>| h.map(|v| format!("{v:#x}"));
887 serde_json::json!({
888 "dry_run": dry_run,
889 "preflight": {
890 "rpc_chain_id": pf.rpc_chain_id,
891 "signing_chain_id": pf.signing_chain_id,
892 "chain_id_ok": pf.chain_id_ok(),
893 "clock_skew_secs": pf.clock_skew,
894 "clock_ok": !pf.clock_status().blocks(),
895 "token_balance_base": pf.token_balance.to_string(),
896 "token_ok": pf.token_ok(),
897 "native_balance_wei": pf.native_balance.to_string(),
898 "native_gas_needed_wei": pf.gas_needed.to_string(),
899 "native_ok": pf.native_ok(),
900 },
901 "keys_generated": keys_generated,
902 "bond": {
903 "mbps": plan.mbps,
904 "target_bond_base": plan.target.to_string(),
905 "bonded_base": plan.shortfall.to_string(),
906 "approve_tx": tx_hex(bond_outcome.approve),
907 "bond_tx": tx_hex(bond_outcome.bond),
908 "declare_tx": tx_hex(bond_outcome.declare),
909 },
910 "register": match register_outcome {
916 Some(o) => serde_json::json!({
917 "skipped": false,
918 "due": true,
919 "submitted": o.tx.is_some(),
920 "tx": tx_hex(o.tx),
921 "node_id": format!("{:#x}", o.node_id),
922 }),
923 None => serde_json::json!({
924 "skipped": true,
925 "due": register_due,
926 "tx": tx_hex(register_tx),
927 }),
928 },
929 "readiness": match readiness {
932 Some(r) => serde_json::json!({
933 "registry_active": r.active,
934 "active_bond_base": r.active_bond.to_string(),
935 "declared_mbps": r.declared_mbps.to_string(),
936 "node_id": format!("{:#x}", r.node_id),
937 "region": region,
938 "multiaddrs": multiaddrs,
939 }),
940 None => serde_json::Value::Null,
941 },
942 "partial": readiness.is_none(),
948 })
949}
950
951#[cfg(test)]
952#[allow(clippy::unwrap_used, clippy::expect_used, clippy::indexing_slicing)]
953mod tests;