1use anyhow::{Result, anyhow};
2use bip32::{DerivationPath, XPrv};
3use blake2::Blake2bVar;
4use blake2::digest::{Update, VariableOutput};
5use casper_types::account::AccountHash;
6use casper_types::{AsymmetricType, PublicKey, SecretKey};
7use directories::ProjectDirs;
8use flate2::read::GzDecoder;
9use futures::StreamExt;
10use indicatif::{ProgressBar, ProgressStyle};
11use semver::Version;
12use serde::Deserialize;
13use sha2::{Digest, Sha512};
14use std::ffi::OsStr;
15use std::fs::File;
16use std::io::{Cursor, Read};
17use std::path::{Path, PathBuf};
18use std::str::FromStr;
19use std::sync::Arc;
20use std::time::Duration as StdDuration;
21use tar::Archive;
22use time::format_description::well_known::Rfc3339;
23use time::{Duration, OffsetDateTime};
24use tokio::fs as tokio_fs;
25use tokio::process::Command;
26use tokio::task;
27
28pub const BOOTSTRAP_NODES: u32 = 3;
29
30const DEVNET_BASE_PORT_RPC: u32 = 11000;
31const DEVNET_BASE_PORT_REST: u32 = 14000;
32const DEVNET_BASE_PORT_SSE: u32 = 18000;
33const DEVNET_BASE_PORT_NETWORK: u32 = 22000;
34const DEVNET_BASE_PORT_BINARY: u32 = 28000;
35const DEVNET_DIAGNOSTICS_PROXY_PORT: u32 = 32000;
36const DEVNET_NET_PORT_OFFSET: u32 = 100;
37
38const DEVNET_INITIAL_BALANCE_USER: u128 = 1_000_000_000_000_000_000_000_000_000_000_000_000;
39const DEVNET_INITIAL_BALANCE_VALIDATOR: u128 = 1_000_000_000_000_000_000_000_000_000_000_000_000;
40const DEVNET_INITIAL_DELEGATION_AMOUNT: u128 = 1_000_000_000_000_000_000;
41const DEVNET_VALIDATOR_BASE_WEIGHT: u128 = 1_000_000_000_000_000_000;
42const DEVNET_SEED_DOMAIN: &[u8] = b"casper-unsafe-devnet-v1";
43const DERIVATION_PATH_PREFIX: &str = "m/44'/506'/0'/0";
44const USER_DERIVATION_START: u32 = 100;
45const DERIVED_ACCOUNTS_FILE: &str = "derived-accounts.csv";
46const SECRET_KEY_PEM: &str = "secret_key.pem";
47const MOTE_PER_CSPR: u128 = 1_000_000_000;
48const PROGRESS_TICK_MS: u64 = 120;
49
50#[derive(Debug)]
51struct DerivedAccountMaterial {
52 path: DerivationPath,
53 public_key_hex: String,
54 account_hash: String,
55 secret_key_pem: Option<String>,
56}
57
58#[derive(Debug, Clone)]
59pub(crate) struct DerivedPathMaterial {
60 pub public_key_hex: String,
61 pub account_hash: String,
62 pub secret_key_pem: String,
63}
64
65#[derive(Debug, Clone)]
66struct DerivedAccountInfo {
67 kind: &'static str,
68 name: String,
69 id: u32,
70 path: DerivationPath,
71 public_key_hex: String,
72 account_hash: String,
73 balance_motes: u128,
74}
75
76#[derive(Debug)]
77struct DerivedAccounts {
78 nodes: Vec<DerivedAccountInfo>,
79 users: Vec<DerivedAccountInfo>,
80}
81
82impl DerivedAccountInfo {
83 fn line(&self) -> String {
84 format!(
85 "{},{},{},{},{},{},{}",
86 self.kind,
87 self.name,
88 "secp256k1",
89 "bip32",
90 self.path,
91 self.account_hash,
92 format_cspr(self.balance_motes)
93 )
94 }
95}
96
97#[derive(Clone, Debug)]
99pub struct AssetsLayout {
100 assets_root: PathBuf,
101 network_name: String,
102}
103
104impl AssetsLayout {
105 pub fn new(assets_root: PathBuf, network_name: String) -> Self {
107 Self {
108 assets_root,
109 network_name,
110 }
111 }
112
113 pub fn assets_root(&self) -> &Path {
115 &self.assets_root
116 }
117
118 pub fn network_name(&self) -> &str {
120 &self.network_name
121 }
122
123 pub fn net_dir(&self) -> PathBuf {
125 self.assets_root.join(&self.network_name)
126 }
127
128 pub fn nodes_dir(&self) -> PathBuf {
130 self.net_dir().join("nodes")
131 }
132
133 pub fn node_dir(&self, node_id: u32) -> PathBuf {
135 self.nodes_dir().join(format!("node-{}", node_id))
136 }
137
138 pub fn node_bin_dir(&self, node_id: u32) -> PathBuf {
140 self.node_dir(node_id).join("bin")
141 }
142
143 pub fn node_config_root(&self, node_id: u32) -> PathBuf {
145 self.node_dir(node_id).join("config")
146 }
147
148 pub fn node_logs_dir(&self, node_id: u32) -> PathBuf {
150 self.node_dir(node_id).join("logs")
151 }
152
153 pub fn daemon_dir(&self) -> PathBuf {
155 self.net_dir().join("daemon")
156 }
157
158 pub async fn exists(&self) -> bool {
160 tokio_fs::metadata(self.nodes_dir())
161 .await
162 .map(|meta| meta.is_dir())
163 .unwrap_or(false)
164 }
165
166 pub async fn count_nodes(&self) -> Result<u32> {
168 let nodes_dir = self.nodes_dir();
169 let mut count = 0u32;
170 if !is_dir(&nodes_dir).await {
171 return Ok(0);
172 }
173 let mut entries = tokio_fs::read_dir(&nodes_dir).await?;
174 while let Some(entry) = entries.next_entry().await? {
175 if !entry.file_type().await?.is_dir() {
176 continue;
177 }
178 let name = entry.file_name();
179 let name = name.to_string_lossy();
180 if name.starts_with("node-") {
181 count += 1;
182 }
183 }
184 Ok(count)
185 }
186
187 pub async fn latest_protocol_version_dir(&self, node_id: u32) -> Result<String> {
189 let bin_dir = self.node_bin_dir(node_id);
190 let mut versions: Vec<(Version, String)> = Vec::new();
191 let mut entries = tokio_fs::read_dir(&bin_dir).await?;
192 while let Some(entry) = entries.next_entry().await? {
193 if !entry.file_type().await?.is_dir() {
194 continue;
195 }
196 let name = entry.file_name().to_string_lossy().to_string();
197 let version_str = name.replace('_', ".");
198 if let Ok(version) = Version::parse(&version_str) {
199 versions.push((version, name));
200 }
201 }
202 versions.sort_by(|a, b| a.0.cmp(&b.0));
203 versions
204 .last()
205 .map(|(_, name)| name.clone())
206 .ok_or_else(|| {
207 anyhow!(
208 "no protocol version directories found in {}",
209 bin_dir.display()
210 )
211 })
212 }
213
214 pub async fn node_config_paths(&self, node_id: u32) -> Result<Vec<PathBuf>> {
216 let config_root = self.node_config_root(node_id);
217 let mut paths = Vec::new();
218 if !is_dir(&config_root).await {
219 return Ok(paths);
220 }
221 let mut entries = tokio_fs::read_dir(&config_root).await?;
222 while let Some(entry) = entries.next_entry().await? {
223 if !entry.file_type().await?.is_dir() {
224 continue;
225 }
226 let path = entry.path().join("config.toml");
227 if is_file(&path).await {
228 paths.push(path);
229 }
230 }
231 Ok(paths)
232 }
233}
234
235pub fn data_dir() -> Result<PathBuf> {
236 let project_dirs = ProjectDirs::from("xyz", "veleslabs", "casper-devnet")
237 .ok_or_else(|| anyhow!("unable to resolve data directory"))?;
238 Ok(project_dirs.data_dir().to_path_buf())
239}
240
241pub fn default_assets_root() -> Result<PathBuf> {
242 Ok(data_dir()?.join("networks"))
243}
244
245pub fn assets_bundle_root() -> Result<PathBuf> {
246 Ok(data_dir()?.join("assets"))
247}
248
249pub fn file_name(path: &Path) -> Option<&OsStr> {
250 path.file_name()
251}
252
253pub fn sse_endpoint(node_id: u32) -> String {
254 format!(
255 "http://127.0.0.1:{}/events",
256 node_port(DEVNET_BASE_PORT_SSE, node_id)
257 )
258}
259
260pub fn rest_endpoint(node_id: u32) -> String {
261 format!(
262 "http://127.0.0.1:{}",
263 node_port(DEVNET_BASE_PORT_REST, node_id)
264 )
265}
266
267pub fn rpc_endpoint(node_id: u32) -> String {
268 format!(
269 "http://127.0.0.1:{}/rpc",
270 node_port(DEVNET_BASE_PORT_RPC, node_id)
271 )
272}
273
274pub fn binary_address(node_id: u32) -> String {
275 format!("127.0.0.1:{}", node_port(DEVNET_BASE_PORT_BINARY, node_id))
276}
277
278pub fn network_address(node_id: u32) -> String {
279 format!("127.0.0.1:{}", node_port(DEVNET_BASE_PORT_NETWORK, node_id))
280}
281
282pub fn diagnostics_proxy_port() -> u32 {
283 DEVNET_DIAGNOSTICS_PROXY_PORT
284}
285
286pub fn diagnostics_ws_endpoint(node_id: u32) -> String {
287 format!(
288 "ws://127.0.0.1:{}/diagnostics/node-{}/",
289 DEVNET_DIAGNOSTICS_PROXY_PORT, node_id
290 )
291}
292
293pub fn diagnostics_socket_path(network_name: &str, node_id: u32) -> String {
294 let socket_name = format!("{}-{}.sock", network_name, node_id);
295 tempfile::env::temp_dir()
296 .join(socket_name)
297 .to_string_lossy()
298 .to_string()
299}
300
301pub struct SetupOptions {
303 pub nodes: u32,
304 pub users: Option<u32>,
305 pub delay_seconds: u64,
306 pub network_name: String,
307 pub protocol_version: String,
308 pub node_log_format: String,
309 pub seed: Arc<str>,
310}
311
312pub async fn setup_local(layout: &AssetsLayout, opts: &SetupOptions) -> Result<()> {
314 let genesis_nodes = opts.nodes;
315 if genesis_nodes == 0 {
316 return Err(anyhow!("nodes must be greater than 0"));
317 }
318 let total_nodes = genesis_nodes;
319 let users = opts.users.unwrap_or(total_nodes);
320 let bundle_root = assets_bundle_root()?;
321 let protocol_version = parse_protocol_version(&opts.protocol_version)?;
322 let protocol_version_chain = protocol_version.to_string();
323 let protocol_version_fs = protocol_version_chain.replace('.', "_");
324 let bundle_dir = bundle_dir_for_version(&bundle_root, &protocol_version).await?;
325
326 let chainspec_path = bundle_dir.join("chainspec.toml");
327 let config_path = bundle_dir.join("node-config.toml");
328 let sidecar_config_path = bundle_dir.join("sidecar-config.toml");
329
330 let net_dir = layout.net_dir();
331 tokio_fs::create_dir_all(&net_dir).await?;
332
333 setup_directories(layout, total_nodes, &protocol_version_fs).await?;
334 preflight_bundle(&bundle_dir, &chainspec_path, &config_path).await?;
335 setup_binaries(layout, total_nodes, &bundle_dir, &protocol_version_fs).await?;
336
337 let derived_accounts =
338 setup_seeded_keys(layout, total_nodes, users, Arc::clone(&opts.seed)).await?;
339
340 setup_chainspec(
341 layout,
342 total_nodes,
343 &chainspec_path,
344 opts.delay_seconds,
345 &protocol_version_chain,
346 &opts.network_name,
347 )
348 .await?;
349
350 setup_accounts(layout, total_nodes, genesis_nodes, users, &derived_accounts).await?;
351
352 setup_node_configs(
353 layout,
354 total_nodes,
355 &protocol_version_fs,
356 &config_path,
357 &sidecar_config_path,
358 &opts.node_log_format,
359 )
360 .await?;
361
362 Ok(())
363}
364
365pub async fn teardown(layout: &AssetsLayout) -> Result<()> {
367 let net_dir = layout.net_dir();
368 if is_dir(&net_dir).await {
369 tokio_fs::remove_dir_all(&net_dir).await?;
370 }
371 let dumps = layout.net_dir().join("dumps");
372 if is_dir(&dumps).await {
373 tokio_fs::remove_dir_all(dumps).await?;
374 }
375 Ok(())
376}
377
378pub async fn remove_consensus_keys(layout: &AssetsLayout, node_ids: &[u32]) -> Result<usize> {
380 let mut removed = 0;
381 for node_id in node_ids {
382 let path = layout.node_dir(*node_id).join("keys").join(SECRET_KEY_PEM);
383 match tokio_fs::remove_file(&path).await {
384 Ok(()) => removed += 1,
385 Err(err) if err.kind() == std::io::ErrorKind::NotFound => {}
386 Err(err) => return Err(err.into()),
387 }
388 }
389 Ok(removed)
390}
391
392pub async fn ensure_consensus_keys(layout: &AssetsLayout, seed: Arc<str>) -> Result<usize> {
394 let total_nodes = layout.count_nodes().await?;
395 if total_nodes == 0 {
396 return Ok(0);
397 }
398 let seed_for_root = seed.to_string();
399 let root = spawn_blocking_result(move || unsafe_root_from_seed(&seed_for_root)).await?;
400 let mut restored = 0;
401
402 for node_id in 1..=total_nodes {
403 let key_path = layout.node_dir(node_id).join("keys").join(SECRET_KEY_PEM);
404 if is_file(&key_path).await {
405 continue;
406 }
407 if let Some(parent) = key_path.parent() {
408 tokio_fs::create_dir_all(parent).await?;
409 }
410 let path =
411 DerivationPath::from_str(&format!("{}/{}", DERIVATION_PATH_PREFIX, node_id - 1))?;
412 let secret_key_pem = spawn_blocking_result({
413 let root = root.clone();
414 move || {
415 let child = derive_xprv_from_path(&root, &path)?;
416 let secret_key = SecretKey::secp256k1_from_bytes(child.to_bytes())?;
417 Ok(secret_key.to_pem()?)
418 }
419 })
420 .await?;
421 tokio_fs::write(&key_path, secret_key_pem).await?;
422 restored += 1;
423 }
424
425 Ok(restored)
426}
427
428pub fn parse_protocol_version(raw: &str) -> Result<Version> {
429 let trimmed = raw.trim();
430 let normalized = trimmed.strip_prefix('v').unwrap_or(trimmed);
431 Version::parse(normalized).map_err(|err| anyhow!("invalid protocol version {}: {}", raw, err))
432}
433
434fn bundle_version_dir(bundle_root: &Path, protocol_version: &Version) -> PathBuf {
435 bundle_root.join(format!("v{}", protocol_version))
436}
437
438async fn bundle_dir_for_version(bundle_root: &Path, protocol_version: &Version) -> Result<PathBuf> {
439 let version_dir = bundle_version_dir(bundle_root, protocol_version);
440 if is_dir(&version_dir).await {
441 return Ok(version_dir);
442 }
443 Err(anyhow!("assets bundle missing {}", version_dir.display()))
444}
445
446pub async fn has_bundle_version(protocol_version: &Version) -> Result<bool> {
447 let bundle_root = assets_bundle_root()?;
448 Ok(is_dir(&bundle_version_dir(&bundle_root, protocol_version)).await)
449}
450
451async fn extract_assets_bundle(bundle_path: &Path, bundle_root: &Path) -> Result<()> {
452 if !is_file(bundle_path).await {
453 return Err(anyhow!("missing assets bundle {}", bundle_path.display()));
454 }
455
456 let bundle_path = bundle_path.to_path_buf();
457 let bundle_root = bundle_root.to_path_buf();
458 spawn_blocking_result(move || {
459 std::fs::create_dir_all(&bundle_root)?;
460 let file = File::open(&bundle_path)?;
461 let decoder = GzDecoder::new(file);
462 let mut archive = Archive::new(decoder);
463 println!("unpacking assets into {}", bundle_root.display());
464 archive.unpack(&bundle_root)?;
465 Ok(())
466 })
467 .await
468}
469
470pub async fn install_assets_bundle(bundle_path: &Path) -> Result<()> {
471 let bundle_root = assets_bundle_root()?;
472 println!(
473 "unpacking local assets bundle {} into {}",
474 bundle_path.display(),
475 bundle_root.display()
476 );
477 extract_assets_bundle(bundle_path, &bundle_root).await
478}
479
480pub async fn pull_assets_bundles(target_override: Option<&str>, force: bool) -> Result<()> {
481 let bundle_root = assets_bundle_root()?;
482 let target = target_override
483 .map(str::to_string)
484 .unwrap_or_else(default_target);
485 println!("assets pull target: {}", target);
486 let release = fetch_latest_release().await?;
487 println!("release tag: {}", release.tag_name);
488
489 let mut assets = Vec::new();
490 for asset in release.assets {
491 if let Some(version) = parse_release_asset_version(&asset.name, &target) {
492 assets.push(ReleaseAsset {
493 url: asset.browser_download_url,
494 version,
495 });
496 }
497 }
498
499 if assets.is_empty() {
500 return Err(anyhow!(
501 "no assets found for target {} in release {}",
502 target,
503 release.tag_name
504 ));
505 }
506
507 for asset in assets {
508 let bytes = download_asset(&asset.url, &asset.version).await?;
509 let expected_hash = download_asset_sha512(&asset.url).await?;
510 let actual_hash = sha512_hex(&bytes);
511 if expected_hash != actual_hash {
512 return Err(anyhow!(
513 "sha512 mismatch for {} (expected {}, got {})",
514 asset.url,
515 expected_hash,
516 actual_hash
517 ));
518 }
519 let remote_manifest = extract_manifest_from_bytes(&bytes).await?;
520 let version_dir = bundle_version_dir(&bundle_root, &asset.version);
521 let local_manifest = read_local_manifest(&version_dir).await?;
522
523 if !force
524 && let (Some(remote), Some(local)) = (&remote_manifest, &local_manifest)
525 && remote == local
526 {
527 println!("already have this file v{}", asset.version);
528 continue;
529 }
530
531 if is_dir(&version_dir).await {
532 tokio_fs::remove_dir_all(&version_dir).await?;
533 }
534
535 println!("saving assets bundle v{}", asset.version);
536 unpack_assets_with_progress(&bytes, &bundle_root, &asset.version).await?;
537 }
538
539 tokio_fs::write(bundle_root.join("latest"), release.tag_name).await?;
540 Ok(())
541}
542
543pub async fn most_recent_bundle_version() -> Result<Option<Version>> {
544 let mut versions = list_bundle_versions().await?;
545 versions.sort();
546 Ok(versions.pop())
547}
548
549pub async fn list_bundle_versions() -> Result<Vec<Version>> {
550 let bundle_root = assets_bundle_root()?;
551 if !is_dir(&bundle_root).await {
552 return Ok(Vec::new());
553 }
554 let mut versions: Vec<Version> = Vec::new();
555 let mut entries = tokio_fs::read_dir(&bundle_root).await?;
556 while let Some(entry) = entries.next_entry().await? {
557 if !entry.file_type().await?.is_dir() {
558 continue;
559 }
560 let name = entry.file_name().to_string_lossy().to_string();
561 if !name.starts_with('v') {
562 continue;
563 }
564 let dir_version = match parse_protocol_version(&name) {
565 Ok(version) => version,
566 Err(err) => {
567 eprintln!("warning: skipping assets bundle {}: {}", name, err);
568 continue;
569 }
570 };
571 let dir_path = entry.path();
572 let chainspec_path = dir_path.join("chainspec.toml");
573 if !is_file(&chainspec_path).await {
574 continue;
575 }
576 let contents = tokio_fs::read_to_string(&chainspec_path).await?;
577 let chainspec_version =
578 spawn_blocking_result(move || parse_chainspec_version(&contents)).await?;
579 if chainspec_version != dir_version {
580 eprintln!(
581 "warning: bundle directory {} does not match chainspec protocol version {}; using directory version {}",
582 name, chainspec_version, dir_version
583 );
584 }
585 versions.push(dir_version);
586 }
587 Ok(versions)
588}
589
590fn parse_chainspec_version(contents: &str) -> Result<Version> {
591 let value: toml::Value = toml::from_str(contents)?;
592 let protocol = value
593 .get("protocol")
594 .and_then(|v| v.as_table())
595 .ok_or_else(|| anyhow!("chainspec missing [protocol] section"))?;
596 let version = protocol
597 .get("version")
598 .and_then(|v| v.as_str())
599 .ok_or_else(|| anyhow!("chainspec missing protocol.version"))?;
600 parse_protocol_version(version)
601}
602
603#[derive(Deserialize)]
604struct GithubRelease {
605 tag_name: String,
606 assets: Vec<GithubAsset>,
607}
608
609#[derive(Deserialize)]
610struct GithubAsset {
611 name: String,
612 browser_download_url: String,
613}
614
615struct ReleaseAsset {
616 url: String,
617 version: Version,
618}
619
620async fn fetch_latest_release() -> Result<GithubRelease> {
621 let client = reqwest::Client::builder()
622 .user_agent("casper-devnet")
623 .build()?;
624 let url = "https://api.github.com/repos/veles-labs/devnet-launcher-assets/releases/latest";
625 println!("GET {}", url);
626 let response = client.get(url).send().await?.error_for_status()?;
627 Ok(response.json::<GithubRelease>().await?)
628}
629
630fn parse_release_asset_version(name: &str, target: &str) -> Option<Version> {
631 let trimmed = name.strip_prefix("casper-v")?;
632 let trimmed = trimmed.strip_suffix(".tar.gz")?;
633 let (version, asset_target) = trimmed.split_once('-')?;
634 if asset_target != target {
635 return None;
636 }
637 parse_protocol_version(version).ok()
638}
639
640fn download_progress_style() -> ProgressStyle {
641 ProgressStyle::with_template("{msg} {bar:40.cyan/blue} {bytes:>7}/{total_bytes:7} ({eta})")
642 .expect("valid download progress template")
643 .progress_chars("█▉▊▋▌▍▎▏ ")
644}
645
646fn download_spinner_style() -> ProgressStyle {
647 ProgressStyle::with_template("{msg} {spinner:.cyan} {bytes:>7}")
648 .expect("valid download spinner template")
649 .tick_chars("⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏")
650}
651
652fn unpack_spinner_style() -> ProgressStyle {
653 ProgressStyle::with_template("{msg} {spinner:.magenta} {elapsed_precise}")
654 .expect("valid unpack spinner template")
655 .tick_chars("⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏")
656}
657
658async fn download_asset(url: &str, version: &Version) -> Result<Vec<u8>> {
659 let client = reqwest::Client::builder()
660 .user_agent("casper-devnet")
661 .build()?;
662 println!("GET {}", url);
663 let response = client.get(url).send().await?.error_for_status()?;
664 let total = response.content_length();
665 let pb = match total {
666 Some(total) if total > 0 => {
667 let pb = ProgressBar::new(total);
668 pb.set_style(download_progress_style());
669 pb
670 }
671 _ => {
672 let pb = ProgressBar::new_spinner();
673 pb.set_style(download_spinner_style());
674 pb.enable_steady_tick(StdDuration::from_millis(PROGRESS_TICK_MS));
675 pb
676 }
677 };
678 pb.set_message(format!("⬇️ v{} download", version));
679
680 let mut bytes = Vec::new();
681 if let Some(total) = total
682 && total <= usize::MAX as u64
683 {
684 bytes.reserve(total as usize);
685 }
686
687 let mut stream = response.bytes_stream();
688 while let Some(chunk) = stream.next().await {
689 match chunk {
690 Ok(chunk) => {
691 pb.inc(chunk.len() as u64);
692 bytes.extend_from_slice(&chunk);
693 }
694 Err(err) => {
695 pb.finish_with_message(format!("❌ v{} download failed", version));
696 return Err(err.into());
697 }
698 }
699 }
700
701 pb.finish_with_message(format!("✅ v{} downloaded", version));
702 Ok(bytes)
703}
704
705async fn download_asset_sha512(url: &str) -> Result<String> {
706 let sha_url = format!("{url}.sha512");
707 let client = reqwest::Client::builder()
708 .user_agent("casper-devnet")
709 .build()?;
710 println!("GET {}", sha_url);
711 let response = client.get(sha_url).send().await?.error_for_status()?;
712 let text = response.text().await?;
713 parse_sha512(&text)
714}
715
716fn parse_sha512(text: &str) -> Result<String> {
717 let token = text
718 .split_whitespace()
719 .next()
720 .ok_or_else(|| anyhow!("invalid sha512 file contents"))?;
721 let token = token.trim();
722 if token.len() != 128 || !token.chars().all(|c| c.is_ascii_hexdigit()) {
723 return Err(anyhow!("invalid sha512 hash {}", token));
724 }
725 Ok(token.to_lowercase())
726}
727
728fn sha512_hex(bytes: &[u8]) -> String {
729 let digest = Sha512::digest(bytes);
730 let mut out = String::with_capacity(digest.len() * 2);
731 for byte in digest {
732 use std::fmt::Write;
733 let _ = write!(&mut out, "{:02x}", byte);
734 }
735 out
736}
737
738async fn extract_manifest_from_bytes(bytes: &[u8]) -> Result<Option<serde_json::Value>> {
739 let bytes = bytes.to_vec();
740 spawn_blocking_result(move || {
741 let cursor = Cursor::new(bytes);
742 let decoder = GzDecoder::new(cursor);
743 let mut archive = Archive::new(decoder);
744 let entries = archive.entries()?;
745 for entry in entries {
746 let mut entry = entry?;
747 let path = entry.path()?;
748 if path.file_name() == Some(OsStr::new("manifest.json")) {
749 let mut contents = String::new();
750 entry.read_to_string(&mut contents)?;
751 let value = serde_json::from_str(&contents)?;
752 return Ok(Some(value));
753 }
754 }
755 Ok(None)
756 })
757 .await
758}
759
760async fn read_local_manifest(version_dir: &Path) -> Result<Option<serde_json::Value>> {
761 let path = version_dir.join("manifest.json");
762 if !is_file(&path).await {
763 return Ok(None);
764 }
765 let contents = tokio_fs::read_to_string(&path).await?;
766 let value = serde_json::from_str(&contents)?;
767 Ok(Some(value))
768}
769
770async fn unpack_assets_with_progress(
771 bytes: &[u8],
772 bundle_root: &Path,
773 version: &Version,
774) -> Result<()> {
775 let pb = ProgressBar::new_spinner();
776 pb.set_style(unpack_spinner_style());
777 pb.set_message(format!("📦 v{} unpack", version));
778 pb.enable_steady_tick(StdDuration::from_millis(PROGRESS_TICK_MS));
779
780 let result = extract_assets_from_bytes(bytes, bundle_root).await;
781 match result {
782 Ok(()) => pb.finish_with_message(format!("✅ v{} unpacked", version)),
783 Err(_) => pb.finish_with_message(format!("❌ v{} unpack failed", version)),
784 }
785 result
786}
787
788async fn extract_assets_from_bytes(bytes: &[u8], bundle_root: &Path) -> Result<()> {
789 let bytes = bytes.to_vec();
790 let bundle_root = bundle_root.to_path_buf();
791 spawn_blocking_result(move || {
792 std::fs::create_dir_all(&bundle_root)?;
793 let cursor = Cursor::new(bytes);
794 let decoder = GzDecoder::new(cursor);
795 let mut archive = Archive::new(decoder);
796 archive.unpack(&bundle_root)?;
797 Ok(())
798 })
799 .await
800}
801
802fn default_target() -> String {
803 env!("BUILD_TARGET").to_string()
804}
805
806async fn setup_directories(
807 layout: &AssetsLayout,
808 total_nodes: u32,
809 protocol_version_fs: &str,
810) -> Result<()> {
811 let net_dir = layout.net_dir();
812 let bin_dir = net_dir.join("bin");
813 let chainspec_dir = net_dir.join("chainspec");
814 let daemon_dir = net_dir.join("daemon");
815 let nodes_dir = net_dir.join("nodes");
816
817 tokio_fs::create_dir_all(bin_dir).await?;
818 tokio_fs::create_dir_all(chainspec_dir).await?;
819 tokio_fs::create_dir_all(daemon_dir.join("config")).await?;
820 tokio_fs::create_dir_all(daemon_dir.join("logs")).await?;
821 tokio_fs::create_dir_all(daemon_dir.join("socket")).await?;
822 tokio_fs::create_dir_all(&nodes_dir).await?;
823
824 for node_id in 1..=total_nodes {
825 let node_dir = layout.node_dir(node_id);
826 tokio_fs::create_dir_all(node_dir.join("bin").join(protocol_version_fs)).await?;
827 tokio_fs::create_dir_all(node_dir.join("config").join(protocol_version_fs)).await?;
828 tokio_fs::create_dir_all(node_dir.join("keys")).await?;
829 tokio_fs::create_dir_all(node_dir.join("logs")).await?;
830 tokio_fs::create_dir_all(node_dir.join("storage")).await?;
831 }
832
833 Ok(())
834}
835
836async fn setup_binaries(
837 layout: &AssetsLayout,
838 total_nodes: u32,
839 bundle_dir: &Path,
840 protocol_version_fs: &str,
841) -> Result<()> {
842 let node_bin_src = bundle_dir.join("bin").join("casper-node");
843 let sidecar_src = bundle_dir.join("bin").join("casper-sidecar");
844
845 for node_id in 1..=total_nodes {
846 let node_bin_dir = layout.node_bin_dir(node_id);
847 let version_dir = node_bin_dir.join(protocol_version_fs);
848
849 let node_dest = version_dir.join("casper-node");
850 hardlink_file(&node_bin_src, &node_dest).await?;
851
852 let sidecar_dest = version_dir.join("casper-sidecar");
853 hardlink_file(&sidecar_src, &sidecar_dest).await?;
854 }
855
856 Ok(())
857}
858
859async fn preflight_bundle(
860 bundle_dir: &Path,
861 chainspec_path: &Path,
862 config_path: &Path,
863) -> Result<()> {
864 let mut missing = Vec::new();
865
866 let node_bin = bundle_dir.join("bin").join("casper-node");
867 let sidecar_bin = bundle_dir.join("bin").join("casper-sidecar");
868 if !is_file(&node_bin).await {
869 missing.push(node_bin.clone());
870 }
871 if !is_file(&sidecar_bin).await {
872 missing.push(sidecar_bin.clone());
873 }
874 if !is_file(chainspec_path).await {
875 missing.push(chainspec_path.to_path_buf());
876 }
877 if !is_file(config_path).await {
878 missing.push(config_path.to_path_buf());
879 }
880
881 if !missing.is_empty() {
882 let message = missing
883 .into_iter()
884 .map(|path| format!("missing source file {}", path.display()))
885 .collect::<Vec<_>>()
886 .join("\n");
887 return Err(anyhow!(message));
888 }
889
890 verify_binary_version(&node_bin, "casper-node").await?;
891 verify_binary_version(&sidecar_bin, "casper-sidecar").await?;
892 Ok(())
893}
894
895async fn verify_binary_version(path: &Path, label: &str) -> Result<()> {
896 let output = Command::new(path).arg("--version").output().await?;
897 if output.status.success() {
898 return Ok(());
899 }
900 let stderr = String::from_utf8_lossy(&output.stderr);
901 let stdout = String::from_utf8_lossy(&output.stdout);
902 Err(anyhow!(
903 "{} --version failed (status={}): {}{}",
904 label,
905 output.status,
906 stdout,
907 stderr
908 ))
909}
910
911fn unsafe_root_from_seed(seed: &str) -> Result<XPrv> {
917 if seed.is_empty() {
918 return Err(anyhow!("seed must not be empty"));
919 }
920 let mut hasher = Blake2bVar::new(32).map_err(|_| anyhow!("invalid blake2b output size"))?;
921 hasher.update(DEVNET_SEED_DOMAIN);
922 hasher.update(seed.as_bytes());
923
924 let mut entropy = [0u8; 32];
925 hasher
926 .finalize_variable(&mut entropy)
927 .map_err(|_| anyhow!("failed to finalize blake2b"))?;
928
929 Ok(XPrv::new(entropy)?)
930}
931
932fn derive_xprv_from_path(root: &XPrv, path: &DerivationPath) -> Result<XPrv> {
933 let mut key = root.clone();
934 for child in path.iter() {
935 key = key.derive_child(child)?;
936 }
937 Ok(key)
938}
939
940fn derive_account_material(
944 root: &XPrv,
945 path: &DerivationPath,
946 write_secret: bool,
947) -> Result<DerivedAccountMaterial> {
948 let child = derive_xprv_from_path(root, path)?;
949 let secret_key = SecretKey::secp256k1_from_bytes(child.to_bytes())?;
950 let public_key = PublicKey::from(&secret_key);
951 let public_key_hex = public_key.to_hex();
952 let account_hash = AccountHash::from(&public_key).to_hex_string();
953 let secret_key_pem = if write_secret {
954 Some(secret_key.to_pem()?)
955 } else {
956 None
957 };
958
959 Ok(DerivedAccountMaterial {
960 path: path.clone(),
961 public_key_hex,
962 account_hash,
963 secret_key_pem,
964 })
965}
966
967async fn write_node_keys(dir: &Path, account: &DerivedAccountMaterial) -> Result<()> {
968 tokio_fs::create_dir_all(dir).await?;
969 if let Some(secret_key_pem) = &account.secret_key_pem {
970 tokio_fs::write(dir.join(SECRET_KEY_PEM), secret_key_pem).await?;
971 }
972 Ok(())
973}
974
975async fn setup_seeded_keys(
976 layout: &AssetsLayout,
977 total_nodes: u32,
978 users: u32,
979 seed: Arc<str>,
980) -> Result<DerivedAccounts> {
981 let seed_for_root = seed.to_string();
982 let root = spawn_blocking_result(move || unsafe_root_from_seed(&seed_for_root)).await?;
983 let mut summary = Vec::new();
984 let mut derived = DerivedAccounts {
985 nodes: Vec::new(),
986 users: Vec::new(),
987 };
988
989 for node_id in 1..=total_nodes {
990 let path =
991 DerivationPath::from_str(&format!("{}/{}", DERIVATION_PATH_PREFIX, node_id - 1))?;
992 let account = spawn_blocking_result({
993 let root = root.clone();
994 let path = path.clone();
995 move || derive_account_material(&root, &path, true)
996 })
997 .await?;
998 write_node_keys(&layout.node_dir(node_id).join("keys"), &account).await?;
999
1000 let info = DerivedAccountInfo {
1001 kind: "validator",
1002 name: format!("node-{}", node_id),
1003 id: node_id,
1004 path: account.path.clone(),
1005 public_key_hex: account.public_key_hex.clone(),
1006 account_hash: account.account_hash.clone(),
1007 balance_motes: DEVNET_INITIAL_BALANCE_VALIDATOR,
1008 };
1009 summary.push(info.clone());
1010 derived.nodes.push(info);
1011 }
1012
1013 for user_id in 1..=users {
1014 let path = DerivationPath::from_str(&format!(
1015 "{}/{}",
1016 DERIVATION_PATH_PREFIX,
1017 USER_DERIVATION_START + user_id - 1
1018 ))?;
1019 let account = spawn_blocking_result({
1020 let root = root.clone();
1021 let path = path.clone();
1022 move || derive_account_material(&root, &path, false)
1023 })
1024 .await?;
1025 let info = DerivedAccountInfo {
1026 kind: "user",
1027 name: format!("user-{}", user_id),
1028 id: user_id,
1029 path: account.path.clone(),
1030 public_key_hex: account.public_key_hex.clone(),
1031 account_hash: account.account_hash.clone(),
1032 balance_motes: DEVNET_INITIAL_BALANCE_USER,
1033 };
1034 summary.push(info.clone());
1035 derived.users.push(info);
1036 }
1037
1038 write_derived_accounts_summary(layout, &summary).await?;
1039
1040 Ok(derived)
1041}
1042
1043async fn setup_chainspec(
1044 layout: &AssetsLayout,
1045 total_nodes: u32,
1046 chainspec_template: &Path,
1047 delay_seconds: u64,
1048 protocol_version_chain: &str,
1049 network_name: &str,
1050) -> Result<()> {
1051 let chainspec_dest = layout.net_dir().join("chainspec/chainspec.toml");
1052 copy_file(chainspec_template, &chainspec_dest).await?;
1053
1054 let activation_point = genesis_timestamp(delay_seconds)?;
1055 let chainspec_contents = tokio_fs::read_to_string(&chainspec_dest).await?;
1056 let protocol_version_chain = protocol_version_chain.to_string();
1057 let network_name = network_name.to_string();
1058 let updated = spawn_blocking_result(move || {
1059 let mut value: toml::Value = toml::from_str(&chainspec_contents)?;
1060 set_string(
1061 &mut value,
1062 &["protocol", "activation_point"],
1063 activation_point,
1064 )?;
1065 set_string(&mut value, &["protocol", "version"], protocol_version_chain)?;
1066 set_string(&mut value, &["network", "name"], network_name)?;
1067
1068 set_integer(&mut value, &["core", "validator_slots"], total_nodes as i64)?;
1069
1070 Ok(toml::to_string(&value)?)
1071 })
1072 .await?;
1073
1074 tokio_fs::write(&chainspec_dest, updated).await?;
1075
1076 Ok(())
1077}
1078
1079async fn setup_accounts(
1080 layout: &AssetsLayout,
1081 total_nodes: u32,
1082 genesis_nodes: u32,
1083 users: u32,
1084 derived_accounts: &DerivedAccounts,
1085) -> Result<()> {
1086 let accounts_path = layout.net_dir().join("chainspec/accounts.toml");
1087 struct NodeAccount {
1088 node_id: u32,
1089 public_key: String,
1090 is_genesis: bool,
1091 }
1092
1093 struct UserAccount {
1094 user_id: u32,
1095 public_key: String,
1096 validator_key: Option<String>,
1097 }
1098
1099 if derived_accounts.nodes.len() != total_nodes as usize {
1100 return Err(anyhow!(
1101 "expected {} validator accounts, got {}",
1102 total_nodes,
1103 derived_accounts.nodes.len()
1104 ));
1105 }
1106 if derived_accounts.users.len() != users as usize {
1107 return Err(anyhow!(
1108 "expected {} user accounts, got {}",
1109 users,
1110 derived_accounts.users.len()
1111 ));
1112 }
1113
1114 let mut node_accounts = Vec::new();
1115 let mut user_accounts = Vec::new();
1116
1117 for node in &derived_accounts.nodes {
1118 node_accounts.push(NodeAccount {
1119 node_id: node.id,
1120 public_key: node.public_key_hex.clone(),
1121 is_genesis: node.id <= genesis_nodes,
1122 });
1123 }
1124
1125 for user in &derived_accounts.users {
1126 let validator_key = if user.id <= genesis_nodes {
1127 Some(
1128 derived_accounts
1129 .nodes
1130 .get((user.id - 1) as usize)
1131 .map(|node| node.public_key_hex.clone())
1132 .ok_or_else(|| anyhow!("missing validator key for node {}", user.id))?,
1133 )
1134 } else {
1135 None
1136 };
1137 user_accounts.push(UserAccount {
1138 user_id: user.id,
1139 public_key: user.public_key_hex.clone(),
1140 validator_key,
1141 });
1142 }
1143
1144 let contents = spawn_blocking_result(move || {
1145 let mut lines = Vec::new();
1146 for node in node_accounts {
1147 if node.node_id > 1 {
1148 lines.push(String::new());
1149 }
1150 lines.push(format!("# VALIDATOR {}.", node.node_id));
1151 lines.push("[[accounts]]".to_string());
1152 lines.push(format!("public_key = \"{}\"", node.public_key));
1153 lines.push(format!(
1154 "balance = \"{}\"",
1155 DEVNET_INITIAL_BALANCE_VALIDATOR
1156 ));
1157 if node.is_genesis {
1158 lines.push(String::new());
1159 lines.push("[accounts.validator]".to_string());
1160 lines.push(format!(
1161 "bonded_amount = \"{}\"",
1162 validator_weight(node.node_id)
1163 ));
1164 lines.push(format!("delegation_rate = {}", node.node_id));
1165 }
1166 }
1167
1168 for user in user_accounts {
1169 lines.push(String::new());
1170 lines.push(format!("# USER {}.", user.user_id));
1171 if let Some(validator_key) = user.validator_key {
1172 lines.push("[[delegators]]".to_string());
1173 lines.push(format!("validator_public_key = \"{}\"", validator_key));
1174 lines.push(format!("delegator_public_key = \"{}\"", user.public_key));
1175 lines.push(format!("balance = \"{}\"", DEVNET_INITIAL_BALANCE_USER));
1176 lines.push(format!(
1177 "delegated_amount = \"{}\"",
1178 DEVNET_INITIAL_DELEGATION_AMOUNT + user.user_id as u128
1179 ));
1180 } else {
1181 lines.push("[[accounts]]".to_string());
1182 lines.push(format!("public_key = \"{}\"", user.public_key));
1183 lines.push(format!("balance = \"{}\"", DEVNET_INITIAL_BALANCE_USER));
1184 }
1185 }
1186
1187 Ok(format!("{}\n", lines.join("\n")))
1188 })
1189 .await?;
1190 tokio_fs::write(&accounts_path, contents).await?;
1191
1192 Ok(())
1193}
1194
1195async fn setup_node_configs(
1196 layout: &AssetsLayout,
1197 total_nodes: u32,
1198 protocol_version_fs: &str,
1199 config_template: &Path,
1200 sidecar_template: &Path,
1201 log_format: &str,
1202) -> Result<()> {
1203 let chainspec_path = layout.net_dir().join("chainspec/chainspec.toml");
1204 let accounts_path = layout.net_dir().join("chainspec/accounts.toml");
1205 let log_format = log_format.to_string();
1206
1207 for node_id in 1..=total_nodes {
1208 let config_root = layout.node_config_root(node_id).join(protocol_version_fs);
1209 tokio_fs::create_dir_all(&config_root).await?;
1210
1211 copy_file(&chainspec_path, &config_root.join("chainspec.toml")).await?;
1212 copy_file(&accounts_path, &config_root.join("accounts.toml")).await?;
1213 copy_file(config_template, &config_root.join("config.toml")).await?;
1214
1215 let config_contents = tokio_fs::read_to_string(config_root.join("config.toml")).await?;
1216 let log_format = log_format.clone();
1217 let bind_address = format!("0.0.0.0:{}", node_port(DEVNET_BASE_PORT_NETWORK, node_id));
1218 let known = known_addresses(node_id, total_nodes);
1219 let rest_address = format!("0.0.0.0:{}", node_port(DEVNET_BASE_PORT_REST, node_id));
1220 let sse_address = format!("0.0.0.0:{}", node_port(DEVNET_BASE_PORT_SSE, node_id));
1221 let binary_address = format!("0.0.0.0:{}", node_port(DEVNET_BASE_PORT_BINARY, node_id));
1222
1223 let diagnostics_socket = diagnostics_socket_path(layout.network_name(), node_id);
1224
1225 let updated_config = spawn_blocking_result(move || {
1226 let mut config_value: toml::Value = toml::from_str(&config_contents)?;
1227
1228 set_string(
1229 &mut config_value,
1230 &["consensus", "secret_key_path"],
1231 "../../keys/secret_key.pem".to_string(),
1232 )?;
1233 set_string(&mut config_value, &["logging", "format"], log_format)?;
1234 set_string(
1235 &mut config_value,
1236 &["network", "bind_address"],
1237 bind_address,
1238 )?;
1239 set_array(&mut config_value, &["network", "known_addresses"], known)?;
1240 set_string(
1241 &mut config_value,
1242 &["storage", "path"],
1243 "../../storage".to_string(),
1244 )?;
1245 set_string(&mut config_value, &["rest_server", "address"], rest_address)?;
1246 set_string(
1247 &mut config_value,
1248 &["event_stream_server", "address"],
1249 sse_address,
1250 )?;
1251
1252 set_string(
1253 &mut config_value,
1254 &["diagnostics_port", "socket_path"],
1255 diagnostics_socket,
1256 )?;
1257
1258 set_string(
1259 &mut config_value,
1260 &["binary_port_server", "address"],
1261 binary_address,
1262 )?;
1263
1264 set_bool(
1266 &mut config_value,
1267 &["binary_port_server", "allow_request_get_trie"],
1268 true,
1269 )?;
1270
1271 set_bool(
1273 &mut config_value,
1274 &["binary_port_server", "allow_request_speculative_exec"],
1275 true,
1276 )?;
1277
1278 Ok(toml::to_string(&config_value)?)
1279 })
1280 .await?;
1281
1282 tokio_fs::write(config_root.join("config.toml"), updated_config).await?;
1283
1284 if is_file(sidecar_template).await {
1285 let sidecar_path = config_root.join("sidecar.toml");
1286 copy_file(sidecar_template, &sidecar_path).await?;
1287
1288 let sidecar_contents = tokio_fs::read_to_string(&sidecar_path).await?;
1289 let rpc_port = node_port(DEVNET_BASE_PORT_RPC, node_id) as i64;
1290 let binary_port = node_port(DEVNET_BASE_PORT_BINARY, node_id) as i64;
1291
1292 let updated_sidecar = spawn_blocking_result(move || {
1293 let mut sidecar_value: toml::Value = toml::from_str(&sidecar_contents)?;
1294 set_string(
1295 &mut sidecar_value,
1296 &["rpc_server", "main_server", "ip_address"],
1297 "0.0.0.0".to_string(),
1298 )?;
1299 set_integer(
1300 &mut sidecar_value,
1301 &["rpc_server", "main_server", "port"],
1302 rpc_port,
1303 )?;
1304 set_string(
1305 &mut sidecar_value,
1306 &["rpc_server", "node_client", "ip_address"],
1307 "0.0.0.0".to_string(),
1308 )?;
1309 set_integer(
1310 &mut sidecar_value,
1311 &["rpc_server", "node_client", "port"],
1312 binary_port,
1313 )?;
1314
1315 Ok(toml::to_string(&sidecar_value)?)
1316 })
1317 .await?;
1318
1319 tokio_fs::write(&sidecar_path, updated_sidecar).await?;
1320 }
1321 }
1322
1323 Ok(())
1324}
1325
1326fn node_port(base: u32, node_id: u32) -> u32 {
1327 base + DEVNET_NET_PORT_OFFSET + node_id
1328}
1329
1330fn bootstrap_address(node_id: u32) -> String {
1331 format!("127.0.0.1:{}", node_port(DEVNET_BASE_PORT_NETWORK, node_id))
1332}
1333
1334fn known_addresses(node_id: u32, total_nodes: u32) -> Vec<String> {
1335 let bootstrap_nodes = BOOTSTRAP_NODES.min(total_nodes);
1336 let mut addresses = Vec::new();
1337 addresses.push(bootstrap_address(1));
1338
1339 if node_id < bootstrap_nodes {
1340 for id in 2..=bootstrap_nodes {
1341 addresses.push(bootstrap_address(id));
1342 }
1343 } else {
1344 let limit = node_id.min(total_nodes);
1345 for id in 2..=limit {
1346 addresses.push(bootstrap_address(id));
1347 }
1348 }
1349
1350 addresses
1351}
1352
1353fn validator_weight(node_id: u32) -> u128 {
1354 DEVNET_VALIDATOR_BASE_WEIGHT + node_id as u128
1355}
1356
1357fn genesis_timestamp(delay_seconds: u64) -> Result<String> {
1358 let ts = OffsetDateTime::now_utc() + Duration::seconds(delay_seconds as i64);
1359 Ok(ts.format(&Rfc3339)?)
1360}
1361
1362fn format_cspr(motes: u128) -> String {
1363 let whole = motes / MOTE_PER_CSPR;
1364 let rem = motes % MOTE_PER_CSPR;
1365 if rem == 0 {
1366 return whole.to_string();
1367 }
1368 let frac = format!("{:09}", rem);
1369 let frac = frac.trim_end_matches('0');
1370 format!("{}.{}", whole, frac)
1371}
1372
1373fn derived_accounts_path(layout: &AssetsLayout) -> PathBuf {
1374 layout.net_dir().join(DERIVED_ACCOUNTS_FILE)
1375}
1376
1377async fn write_derived_accounts_summary(
1378 layout: &AssetsLayout,
1379 accounts: &[DerivedAccountInfo],
1380) -> Result<()> {
1381 let mut lines = Vec::new();
1382 lines.push("kind,name,key_type,derivation,path,account_hash,balance".to_string());
1383 for account in accounts {
1384 lines.push(account.line());
1385 }
1386 tokio_fs::write(derived_accounts_path(layout), lines.join("\n")).await?;
1387 Ok(())
1388}
1389
1390pub async fn derived_accounts_summary(layout: &AssetsLayout) -> Option<String> {
1391 tokio_fs::read_to_string(derived_accounts_path(layout))
1392 .await
1393 .ok()
1394}
1395
1396pub(crate) async fn derive_account_from_seed_path(
1397 seed: Arc<str>,
1398 path: &str,
1399) -> Result<DerivedPathMaterial> {
1400 let seed_for_root = seed.to_string();
1401 let path_for_parse = path.to_string();
1402 spawn_blocking_result(move || {
1403 let root = unsafe_root_from_seed(&seed_for_root)?;
1404 let path = DerivationPath::from_str(&path_for_parse)?;
1405 let material = derive_account_material(&root, &path, true)?;
1406 let secret_key_pem = material
1407 .secret_key_pem
1408 .ok_or_else(|| anyhow!("missing secret key material for {}", path_for_parse))?;
1409
1410 Ok(DerivedPathMaterial {
1411 public_key_hex: material.public_key_hex,
1412 account_hash: material.account_hash,
1413 secret_key_pem,
1414 })
1415 })
1416 .await
1417}
1418
1419async fn copy_file(src: &Path, dest: &Path) -> Result<()> {
1420 if !is_file(src).await {
1421 return Err(anyhow!("missing source file {}", src.display()));
1422 }
1423 if let Some(parent) = dest.parent() {
1424 tokio_fs::create_dir_all(parent).await?;
1425 }
1426 tokio_fs::copy(src, dest).await?;
1427 Ok(())
1428}
1429
1430async fn hardlink_file(src: &Path, dest: &Path) -> Result<()> {
1431 if !is_file(src).await {
1432 return Err(anyhow!("missing source file {}", src.display()));
1433 }
1434 if let Some(parent) = dest.parent() {
1435 tokio_fs::create_dir_all(parent).await?;
1436 }
1437 if let Ok(metadata) = tokio_fs::symlink_metadata(dest).await {
1438 if metadata.is_dir() {
1439 return Err(anyhow!("destination {} is a directory", dest.display()));
1440 }
1441 tokio_fs::remove_file(dest).await?;
1442 }
1443 tokio_fs::hard_link(src, dest).await?;
1444 Ok(())
1445}
1446
1447async fn is_dir(path: &Path) -> bool {
1448 tokio_fs::metadata(path)
1449 .await
1450 .map(|meta| meta.is_dir())
1451 .unwrap_or(false)
1452}
1453
1454async fn is_file(path: &Path) -> bool {
1455 tokio_fs::metadata(path)
1456 .await
1457 .map(|meta| meta.is_file())
1458 .unwrap_or(false)
1459}
1460
1461async fn spawn_blocking_result<F, T>(f: F) -> Result<T>
1462where
1463 F: FnOnce() -> Result<T> + Send + 'static,
1464 T: Send + 'static,
1465{
1466 match task::spawn_blocking(f).await {
1467 Ok(result) => result,
1468 Err(err) => Err(anyhow!("blocking task failed: {}", err)),
1469 }
1470}
1471
1472fn set_string(root: &mut toml::Value, path: &[&str], value: String) -> Result<()> {
1473 set_value(root, path, toml::Value::String(value))
1474}
1475
1476fn set_integer(root: &mut toml::Value, path: &[&str], value: i64) -> Result<()> {
1477 set_value(root, path, toml::Value::Integer(value))
1478}
1479
1480fn set_array(root: &mut toml::Value, path: &[&str], values: Vec<String>) -> Result<()> {
1481 let array = values.into_iter().map(toml::Value::String).collect();
1482 set_value(root, path, toml::Value::Array(array))
1483}
1484
1485fn set_bool(root: &mut toml::Value, path: &[&str], value: bool) -> Result<()> {
1486 set_value(root, path, toml::Value::Boolean(value))
1487}
1488
1489fn set_value(root: &mut toml::Value, path: &[&str], value: toml::Value) -> Result<()> {
1490 let table = root
1491 .as_table_mut()
1492 .ok_or_else(|| anyhow!("TOML root is not a table"))?;
1493
1494 let mut current = table;
1495 for key in &path[..path.len() - 1] {
1496 current = ensure_table(current, key);
1497 }
1498 current.insert(path[path.len() - 1].to_string(), value);
1499 Ok(())
1500}
1501
1502fn ensure_table<'a>(table: &'a mut toml::value::Table, key: &str) -> &'a mut toml::value::Table {
1503 if !table.contains_key(key) {
1504 table.insert(
1505 key.to_string(),
1506 toml::Value::Table(toml::value::Table::new()),
1507 );
1508 }
1509 table
1510 .get_mut(key)
1511 .and_then(|v| v.as_table_mut())
1512 .expect("table entry is not a table")
1513}
1514
1515#[cfg(test)]
1516mod tests {
1517 use super::format_cspr;
1518
1519 #[test]
1520 fn format_cspr_handles_whole_and_fractional() {
1521 assert_eq!(format_cspr(0), "0");
1522 assert_eq!(format_cspr(1), "0.000000001");
1523 assert_eq!(format_cspr(1_000_000_000), "1");
1524 assert_eq!(format_cspr(1_000_000_001), "1.000000001");
1525 assert_eq!(format_cspr(123_000_000_000), "123");
1526 assert_eq!(format_cspr(123_000_000_456), "123.000000456");
1527 }
1528}