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