Skip to main content

casper_devnet/
assets.rs

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/// Layout of generated assets for a given network.
98#[derive(Clone, Debug)]
99pub struct AssetsLayout {
100    assets_root: PathBuf,
101    network_name: String,
102}
103
104impl AssetsLayout {
105    /// Create a new layout rooted at `assets_root/<network_name>`.
106    pub fn new(assets_root: PathBuf, network_name: String) -> Self {
107        Self {
108            assets_root,
109            network_name,
110        }
111    }
112
113    /// Root folder for assets (contains all networks).
114    pub fn assets_root(&self) -> &Path {
115        &self.assets_root
116    }
117
118    /// Network name used in paths and configs.
119    pub fn network_name(&self) -> &str {
120        &self.network_name
121    }
122
123    /// Base directory for this network's assets.
124    pub fn net_dir(&self) -> PathBuf {
125        self.assets_root.join(&self.network_name)
126    }
127
128    /// Directory that contains all node folders.
129    pub fn nodes_dir(&self) -> PathBuf {
130        self.net_dir().join("nodes")
131    }
132
133    /// Directory for a single node.
134    pub fn node_dir(&self, node_id: u32) -> PathBuf {
135        self.nodes_dir().join(format!("node-{}", node_id))
136    }
137
138    /// Directory for a node's binaries.
139    pub fn node_bin_dir(&self, node_id: u32) -> PathBuf {
140        self.node_dir(node_id).join("bin")
141    }
142
143    /// Directory for a node's configs.
144    pub fn node_config_root(&self, node_id: u32) -> PathBuf {
145        self.node_dir(node_id).join("config")
146    }
147
148    /// Directory for a node's logs.
149    pub fn node_logs_dir(&self, node_id: u32) -> PathBuf {
150        self.node_dir(node_id).join("logs")
151    }
152
153    /// Directory for daemon-related artifacts.
154    pub fn daemon_dir(&self) -> PathBuf {
155        self.net_dir().join("daemon")
156    }
157
158    /// Returns true if the network's nodes directory exists.
159    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    /// Count node directories under `nodes/`.
167    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    /// Find the newest protocol version directory for a node.
188    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    /// Return all config.toml paths for a node.
215    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
301/// Parameters for building a local devnet asset tree.
302pub 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
312/// Create or refresh local assets for a devnet.
313pub 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
365/// Remove assets for the given network (and local dumps).
366pub 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
378/// Remove consensus secret keys for the provided node IDs.
379pub 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
392/// Ensure consensus secret keys are present for all nodes.
393pub 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
911///
912/// Derive an unsafe deterministic root key from an arbitrary seed string.
913///
914/// DEVNET ONLY, NOT BIP-39, NOT WALLET-COMPATIBLE.
915///
916fn 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
940///
941/// Derive a single Casper account from a given root and derivation path.
942///
943fn 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            // Enable requests that are disabled by default for security reasons.
1265            set_bool(
1266                &mut config_value,
1267                &["binary_port_server", "allow_request_get_trie"],
1268                true,
1269            )?;
1270
1271            // Enable speculative execution requests.
1272            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}