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<Option<Version>> {
517    let mut versions = list_bundle_versions().await?;
518    versions.sort();
519    Ok(versions.pop())
520}
521
522pub async fn list_bundle_versions() -> Result<Vec<Version>> {
523    let bundle_root = assets_bundle_root()?;
524    if !is_dir(&bundle_root).await {
525        return Ok(Vec::new());
526    }
527    let mut versions: Vec<Version> = Vec::new();
528    let mut entries = tokio_fs::read_dir(&bundle_root).await?;
529    while let Some(entry) = entries.next_entry().await? {
530        if !entry.file_type().await?.is_dir() {
531            continue;
532        }
533        let name = entry.file_name().to_string_lossy().to_string();
534        if !name.starts_with('v') {
535            continue;
536        }
537        let dir_path = entry.path();
538        let chainspec_path = dir_path.join("chainspec.toml");
539        if !is_file(&chainspec_path).await {
540            continue;
541        }
542        let contents = tokio_fs::read_to_string(&chainspec_path).await?;
543        let version = spawn_blocking_result(move || parse_chainspec_version(&contents)).await?;
544        let expected_dir = format!("v{}", version);
545        if name != expected_dir {
546            return Err(anyhow!(
547                "bundle directory {} does not match chainspec protocol version {}",
548                name,
549                version
550            ));
551        }
552        versions.push(version);
553    }
554    Ok(versions)
555}
556
557fn parse_chainspec_version(contents: &str) -> Result<Version> {
558    let value: toml::Value = toml::from_str(contents)?;
559    let protocol = value
560        .get("protocol")
561        .and_then(|v| v.as_table())
562        .ok_or_else(|| anyhow!("chainspec missing [protocol] section"))?;
563    let version = protocol
564        .get("version")
565        .and_then(|v| v.as_str())
566        .ok_or_else(|| anyhow!("chainspec missing protocol.version"))?;
567    parse_protocol_version(version)
568}
569
570#[derive(Deserialize)]
571struct GithubRelease {
572    tag_name: String,
573    assets: Vec<GithubAsset>,
574}
575
576#[derive(Deserialize)]
577struct GithubAsset {
578    name: String,
579    browser_download_url: String,
580}
581
582struct ReleaseAsset {
583    url: String,
584    version: Version,
585}
586
587async fn fetch_latest_release() -> Result<GithubRelease> {
588    let client = reqwest::Client::builder()
589        .user_agent("casper-devnet")
590        .build()?;
591    let url = "https://api.github.com/repos/veles-labs/devnet-launcher-assets/releases/latest";
592    println!("GET {}", url);
593    let response = client.get(url).send().await?.error_for_status()?;
594    Ok(response.json::<GithubRelease>().await?)
595}
596
597fn parse_release_asset_version(name: &str, target: &str) -> Option<Version> {
598    let trimmed = name.strip_prefix("casper-v")?;
599    let trimmed = trimmed.strip_suffix(".tar.gz")?;
600    let (version, asset_target) = trimmed.split_once('-')?;
601    if asset_target != target {
602        return None;
603    }
604    parse_protocol_version(version).ok()
605}
606
607fn download_progress_style() -> ProgressStyle {
608    ProgressStyle::with_template("{msg} {bar:40.cyan/blue} {bytes:>7}/{total_bytes:7} ({eta})")
609        .expect("valid download progress template")
610        .progress_chars("█▉▊▋▌▍▎▏ ")
611}
612
613fn download_spinner_style() -> ProgressStyle {
614    ProgressStyle::with_template("{msg} {spinner:.cyan} {bytes:>7}")
615        .expect("valid download spinner template")
616        .tick_chars("⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏")
617}
618
619fn unpack_spinner_style() -> ProgressStyle {
620    ProgressStyle::with_template("{msg} {spinner:.magenta} {elapsed_precise}")
621        .expect("valid unpack spinner template")
622        .tick_chars("⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏")
623}
624
625async fn download_asset(url: &str, version: &Version) -> Result<Vec<u8>> {
626    let client = reqwest::Client::builder()
627        .user_agent("casper-devnet")
628        .build()?;
629    println!("GET {}", url);
630    let response = client.get(url).send().await?.error_for_status()?;
631    let total = response.content_length();
632    let pb = match total {
633        Some(total) if total > 0 => {
634            let pb = ProgressBar::new(total);
635            pb.set_style(download_progress_style());
636            pb
637        }
638        _ => {
639            let pb = ProgressBar::new_spinner();
640            pb.set_style(download_spinner_style());
641            pb.enable_steady_tick(StdDuration::from_millis(PROGRESS_TICK_MS));
642            pb
643        }
644    };
645    pb.set_message(format!("⬇️  v{} download", version));
646
647    let mut bytes = Vec::new();
648    if let Some(total) = total
649        && total <= usize::MAX as u64
650    {
651        bytes.reserve(total as usize);
652    }
653
654    let mut stream = response.bytes_stream();
655    while let Some(chunk) = stream.next().await {
656        match chunk {
657            Ok(chunk) => {
658                pb.inc(chunk.len() as u64);
659                bytes.extend_from_slice(&chunk);
660            }
661            Err(err) => {
662                pb.finish_with_message(format!("❌  v{} download failed", version));
663                return Err(err.into());
664            }
665        }
666    }
667
668    pb.finish_with_message(format!("✅  v{} downloaded", version));
669    Ok(bytes)
670}
671
672async fn download_asset_sha512(url: &str) -> Result<String> {
673    let sha_url = format!("{url}.sha512");
674    let client = reqwest::Client::builder()
675        .user_agent("casper-devnet")
676        .build()?;
677    println!("GET {}", sha_url);
678    let response = client.get(sha_url).send().await?.error_for_status()?;
679    let text = response.text().await?;
680    parse_sha512(&text)
681}
682
683fn parse_sha512(text: &str) -> Result<String> {
684    let token = text
685        .split_whitespace()
686        .next()
687        .ok_or_else(|| anyhow!("invalid sha512 file contents"))?;
688    let token = token.trim();
689    if token.len() != 128 || !token.chars().all(|c| c.is_ascii_hexdigit()) {
690        return Err(anyhow!("invalid sha512 hash {}", token));
691    }
692    Ok(token.to_lowercase())
693}
694
695fn sha512_hex(bytes: &[u8]) -> String {
696    let digest = Sha512::digest(bytes);
697    let mut out = String::with_capacity(digest.len() * 2);
698    for byte in digest {
699        use std::fmt::Write;
700        let _ = write!(&mut out, "{:02x}", byte);
701    }
702    out
703}
704
705async fn extract_manifest_from_bytes(bytes: &[u8]) -> Result<Option<serde_json::Value>> {
706    let bytes = bytes.to_vec();
707    spawn_blocking_result(move || {
708        let cursor = Cursor::new(bytes);
709        let decoder = GzDecoder::new(cursor);
710        let mut archive = Archive::new(decoder);
711        let entries = archive.entries()?;
712        for entry in entries {
713            let mut entry = entry?;
714            let path = entry.path()?;
715            if path.file_name() == Some(OsStr::new("manifest.json")) {
716                let mut contents = String::new();
717                entry.read_to_string(&mut contents)?;
718                let value = serde_json::from_str(&contents)?;
719                return Ok(Some(value));
720            }
721        }
722        Ok(None)
723    })
724    .await
725}
726
727async fn read_local_manifest(version_dir: &Path) -> Result<Option<serde_json::Value>> {
728    let path = version_dir.join("manifest.json");
729    if !is_file(&path).await {
730        return Ok(None);
731    }
732    let contents = tokio_fs::read_to_string(&path).await?;
733    let value = serde_json::from_str(&contents)?;
734    Ok(Some(value))
735}
736
737async fn unpack_assets_with_progress(
738    bytes: &[u8],
739    bundle_root: &Path,
740    version: &Version,
741) -> Result<()> {
742    let pb = ProgressBar::new_spinner();
743    pb.set_style(unpack_spinner_style());
744    pb.set_message(format!("📦  v{} unpack", version));
745    pb.enable_steady_tick(StdDuration::from_millis(PROGRESS_TICK_MS));
746
747    let result = extract_assets_from_bytes(bytes, bundle_root).await;
748    match result {
749        Ok(()) => pb.finish_with_message(format!("✅  v{} unpacked", version)),
750        Err(_) => pb.finish_with_message(format!("❌  v{} unpack failed", version)),
751    }
752    result
753}
754
755async fn extract_assets_from_bytes(bytes: &[u8], bundle_root: &Path) -> Result<()> {
756    let bytes = bytes.to_vec();
757    let bundle_root = bundle_root.to_path_buf();
758    spawn_blocking_result(move || {
759        std::fs::create_dir_all(&bundle_root)?;
760        let cursor = Cursor::new(bytes);
761        let decoder = GzDecoder::new(cursor);
762        let mut archive = Archive::new(decoder);
763        archive.unpack(&bundle_root)?;
764        Ok(())
765    })
766    .await
767}
768
769fn default_target() -> String {
770    env!("BUILD_TARGET").to_string()
771}
772
773async fn setup_directories(
774    layout: &AssetsLayout,
775    total_nodes: u32,
776    protocol_version_fs: &str,
777) -> Result<()> {
778    let net_dir = layout.net_dir();
779    let bin_dir = net_dir.join("bin");
780    let chainspec_dir = net_dir.join("chainspec");
781    let daemon_dir = net_dir.join("daemon");
782    let nodes_dir = net_dir.join("nodes");
783
784    tokio_fs::create_dir_all(bin_dir).await?;
785    tokio_fs::create_dir_all(chainspec_dir).await?;
786    tokio_fs::create_dir_all(daemon_dir.join("config")).await?;
787    tokio_fs::create_dir_all(daemon_dir.join("logs")).await?;
788    tokio_fs::create_dir_all(daemon_dir.join("socket")).await?;
789    tokio_fs::create_dir_all(&nodes_dir).await?;
790
791    for node_id in 1..=total_nodes {
792        let node_dir = layout.node_dir(node_id);
793        tokio_fs::create_dir_all(node_dir.join("bin").join(protocol_version_fs)).await?;
794        tokio_fs::create_dir_all(node_dir.join("config").join(protocol_version_fs)).await?;
795        tokio_fs::create_dir_all(node_dir.join("keys")).await?;
796        tokio_fs::create_dir_all(node_dir.join("logs")).await?;
797        tokio_fs::create_dir_all(node_dir.join("storage")).await?;
798    }
799
800    Ok(())
801}
802
803async fn setup_binaries(
804    layout: &AssetsLayout,
805    total_nodes: u32,
806    bundle_dir: &Path,
807    protocol_version_fs: &str,
808) -> Result<()> {
809    let node_bin_src = bundle_dir.join("bin").join("casper-node");
810    let sidecar_src = bundle_dir.join("bin").join("casper-sidecar");
811
812    for node_id in 1..=total_nodes {
813        let node_bin_dir = layout.node_bin_dir(node_id);
814        let version_dir = node_bin_dir.join(protocol_version_fs);
815
816        let node_dest = version_dir.join("casper-node");
817        hardlink_file(&node_bin_src, &node_dest).await?;
818
819        let sidecar_dest = version_dir.join("casper-sidecar");
820        hardlink_file(&sidecar_src, &sidecar_dest).await?;
821    }
822
823    Ok(())
824}
825
826async fn preflight_bundle(
827    bundle_dir: &Path,
828    chainspec_path: &Path,
829    config_path: &Path,
830) -> Result<()> {
831    let mut missing = Vec::new();
832
833    let node_bin = bundle_dir.join("bin").join("casper-node");
834    let sidecar_bin = bundle_dir.join("bin").join("casper-sidecar");
835    if !is_file(&node_bin).await {
836        missing.push(node_bin.clone());
837    }
838    if !is_file(&sidecar_bin).await {
839        missing.push(sidecar_bin.clone());
840    }
841    if !is_file(chainspec_path).await {
842        missing.push(chainspec_path.to_path_buf());
843    }
844    if !is_file(config_path).await {
845        missing.push(config_path.to_path_buf());
846    }
847
848    if !missing.is_empty() {
849        let message = missing
850            .into_iter()
851            .map(|path| format!("missing source file {}", path.display()))
852            .collect::<Vec<_>>()
853            .join("\n");
854        return Err(anyhow!(message));
855    }
856
857    verify_binary_version(&node_bin, "casper-node").await?;
858    verify_binary_version(&sidecar_bin, "casper-sidecar").await?;
859    Ok(())
860}
861
862async fn verify_binary_version(path: &Path, label: &str) -> Result<()> {
863    let output = Command::new(path).arg("--version").output().await?;
864    if output.status.success() {
865        return Ok(());
866    }
867    let stderr = String::from_utf8_lossy(&output.stderr);
868    let stdout = String::from_utf8_lossy(&output.stdout);
869    Err(anyhow!(
870        "{} --version failed (status={}): {}{}",
871        label,
872        output.status,
873        stdout,
874        stderr
875    ))
876}
877
878///
879/// Derive an unsafe deterministic root key from an arbitrary seed string.
880///
881/// DEVNET ONLY, NOT BIP-39, NOT WALLET-COMPATIBLE.
882///
883fn unsafe_root_from_seed(seed: &str) -> Result<XPrv> {
884    if seed.is_empty() {
885        return Err(anyhow!("seed must not be empty"));
886    }
887    let mut hasher = Blake2bVar::new(32).map_err(|_| anyhow!("invalid blake2b output size"))?;
888    hasher.update(DEVNET_SEED_DOMAIN);
889    hasher.update(seed.as_bytes());
890
891    let mut entropy = [0u8; 32];
892    hasher
893        .finalize_variable(&mut entropy)
894        .map_err(|_| anyhow!("failed to finalize blake2b"))?;
895
896    Ok(XPrv::new(entropy)?)
897}
898
899fn derive_xprv_from_path(root: &XPrv, path: &DerivationPath) -> Result<XPrv> {
900    let mut key = root.clone();
901    for child in path.iter() {
902        key = key.derive_child(child)?;
903    }
904    Ok(key)
905}
906
907///
908/// Derive a single Casper account from a given root and derivation path.
909///
910fn derive_account_material(
911    root: &XPrv,
912    path: &DerivationPath,
913    write_secret: bool,
914) -> Result<DerivedAccountMaterial> {
915    let child = derive_xprv_from_path(root, path)?;
916    let secret_key = SecretKey::secp256k1_from_bytes(child.to_bytes())?;
917    let public_key = PublicKey::from(&secret_key);
918    let public_key_hex = public_key.to_hex();
919    let account_hash = AccountHash::from(&public_key).to_hex_string();
920    let secret_key_pem = if write_secret {
921        Some(secret_key.to_pem()?)
922    } else {
923        None
924    };
925
926    Ok(DerivedAccountMaterial {
927        path: path.clone(),
928        public_key_hex,
929        account_hash,
930        secret_key_pem,
931    })
932}
933
934async fn write_node_keys(dir: &Path, account: &DerivedAccountMaterial) -> Result<()> {
935    tokio_fs::create_dir_all(dir).await?;
936    if let Some(secret_key_pem) = &account.secret_key_pem {
937        tokio_fs::write(dir.join(SECRET_KEY_PEM), secret_key_pem).await?;
938    }
939    Ok(())
940}
941
942async fn setup_seeded_keys(
943    layout: &AssetsLayout,
944    total_nodes: u32,
945    users: u32,
946    seed: Arc<str>,
947) -> Result<DerivedAccounts> {
948    let seed_for_root = seed.to_string();
949    let root = spawn_blocking_result(move || unsafe_root_from_seed(&seed_for_root)).await?;
950    let mut summary = Vec::new();
951    let mut derived = DerivedAccounts {
952        nodes: Vec::new(),
953        users: Vec::new(),
954    };
955
956    for node_id in 1..=total_nodes {
957        let path =
958            DerivationPath::from_str(&format!("{}/{}", DERIVATION_PATH_PREFIX, node_id - 1))?;
959        let account = spawn_blocking_result({
960            let root = root.clone();
961            let path = path.clone();
962            move || derive_account_material(&root, &path, true)
963        })
964        .await?;
965        write_node_keys(&layout.node_dir(node_id).join("keys"), &account).await?;
966
967        let info = DerivedAccountInfo {
968            kind: "validator",
969            name: format!("node-{}", node_id),
970            id: node_id,
971            path: account.path.clone(),
972            public_key_hex: account.public_key_hex.clone(),
973            account_hash: account.account_hash.clone(),
974            balance_motes: DEVNET_INITIAL_BALANCE_VALIDATOR,
975        };
976        summary.push(info.clone());
977        derived.nodes.push(info);
978    }
979
980    for user_id in 1..=users {
981        let path = DerivationPath::from_str(&format!(
982            "{}/{}",
983            DERIVATION_PATH_PREFIX,
984            USER_DERIVATION_START + user_id - 1
985        ))?;
986        let account = spawn_blocking_result({
987            let root = root.clone();
988            let path = path.clone();
989            move || derive_account_material(&root, &path, false)
990        })
991        .await?;
992        let info = DerivedAccountInfo {
993            kind: "user",
994            name: format!("user-{}", user_id),
995            id: user_id,
996            path: account.path.clone(),
997            public_key_hex: account.public_key_hex.clone(),
998            account_hash: account.account_hash.clone(),
999            balance_motes: DEVNET_INITIAL_BALANCE_USER,
1000        };
1001        summary.push(info.clone());
1002        derived.users.push(info);
1003    }
1004
1005    write_derived_accounts_summary(layout, &summary).await?;
1006
1007    Ok(derived)
1008}
1009
1010async fn setup_chainspec(
1011    layout: &AssetsLayout,
1012    total_nodes: u32,
1013    chainspec_template: &Path,
1014    delay_seconds: u64,
1015    protocol_version_chain: &str,
1016    network_name: &str,
1017) -> Result<()> {
1018    let chainspec_dest = layout.net_dir().join("chainspec/chainspec.toml");
1019    copy_file(chainspec_template, &chainspec_dest).await?;
1020
1021    let activation_point = genesis_timestamp(delay_seconds)?;
1022    let chainspec_contents = tokio_fs::read_to_string(&chainspec_dest).await?;
1023    let protocol_version_chain = protocol_version_chain.to_string();
1024    let network_name = network_name.to_string();
1025    let updated = spawn_blocking_result(move || {
1026        let mut value: toml::Value = toml::from_str(&chainspec_contents)?;
1027        set_string(
1028            &mut value,
1029            &["protocol", "activation_point"],
1030            activation_point,
1031        )?;
1032        set_string(&mut value, &["protocol", "version"], protocol_version_chain)?;
1033        set_string(&mut value, &["network", "name"], network_name)?;
1034
1035        set_integer(&mut value, &["core", "validator_slots"], total_nodes as i64)?;
1036
1037        Ok(toml::to_string(&value)?)
1038    })
1039    .await?;
1040
1041    tokio_fs::write(&chainspec_dest, updated).await?;
1042
1043    Ok(())
1044}
1045
1046async fn setup_accounts(
1047    layout: &AssetsLayout,
1048    total_nodes: u32,
1049    genesis_nodes: u32,
1050    users: u32,
1051    derived_accounts: &DerivedAccounts,
1052) -> Result<()> {
1053    let accounts_path = layout.net_dir().join("chainspec/accounts.toml");
1054    struct NodeAccount {
1055        node_id: u32,
1056        public_key: String,
1057        is_genesis: bool,
1058    }
1059
1060    struct UserAccount {
1061        user_id: u32,
1062        public_key: String,
1063        validator_key: Option<String>,
1064    }
1065
1066    if derived_accounts.nodes.len() != total_nodes as usize {
1067        return Err(anyhow!(
1068            "expected {} validator accounts, got {}",
1069            total_nodes,
1070            derived_accounts.nodes.len()
1071        ));
1072    }
1073    if derived_accounts.users.len() != users as usize {
1074        return Err(anyhow!(
1075            "expected {} user accounts, got {}",
1076            users,
1077            derived_accounts.users.len()
1078        ));
1079    }
1080
1081    let mut node_accounts = Vec::new();
1082    let mut user_accounts = Vec::new();
1083
1084    for node in &derived_accounts.nodes {
1085        node_accounts.push(NodeAccount {
1086            node_id: node.id,
1087            public_key: node.public_key_hex.clone(),
1088            is_genesis: node.id <= genesis_nodes,
1089        });
1090    }
1091
1092    for user in &derived_accounts.users {
1093        let validator_key = if user.id <= genesis_nodes {
1094            Some(
1095                derived_accounts
1096                    .nodes
1097                    .get((user.id - 1) as usize)
1098                    .map(|node| node.public_key_hex.clone())
1099                    .ok_or_else(|| anyhow!("missing validator key for node {}", user.id))?,
1100            )
1101        } else {
1102            None
1103        };
1104        user_accounts.push(UserAccount {
1105            user_id: user.id,
1106            public_key: user.public_key_hex.clone(),
1107            validator_key,
1108        });
1109    }
1110
1111    let contents = spawn_blocking_result(move || {
1112        let mut lines = Vec::new();
1113        for node in node_accounts {
1114            if node.node_id > 1 {
1115                lines.push(String::new());
1116            }
1117            lines.push(format!("# VALIDATOR {}.", node.node_id));
1118            lines.push("[[accounts]]".to_string());
1119            lines.push(format!("public_key = \"{}\"", node.public_key));
1120            lines.push(format!(
1121                "balance = \"{}\"",
1122                DEVNET_INITIAL_BALANCE_VALIDATOR
1123            ));
1124            if node.is_genesis {
1125                lines.push(String::new());
1126                lines.push("[accounts.validator]".to_string());
1127                lines.push(format!(
1128                    "bonded_amount = \"{}\"",
1129                    validator_weight(node.node_id)
1130                ));
1131                lines.push(format!("delegation_rate = {}", node.node_id));
1132            }
1133        }
1134
1135        for user in user_accounts {
1136            lines.push(String::new());
1137            lines.push(format!("# USER {}.", user.user_id));
1138            if let Some(validator_key) = user.validator_key {
1139                lines.push("[[delegators]]".to_string());
1140                lines.push(format!("validator_public_key = \"{}\"", validator_key));
1141                lines.push(format!("delegator_public_key = \"{}\"", user.public_key));
1142                lines.push(format!("balance = \"{}\"", DEVNET_INITIAL_BALANCE_USER));
1143                lines.push(format!(
1144                    "delegated_amount = \"{}\"",
1145                    DEVNET_INITIAL_DELEGATION_AMOUNT + user.user_id as u128
1146                ));
1147            } else {
1148                lines.push("[[accounts]]".to_string());
1149                lines.push(format!("public_key = \"{}\"", user.public_key));
1150                lines.push(format!("balance = \"{}\"", DEVNET_INITIAL_BALANCE_USER));
1151            }
1152        }
1153
1154        Ok(format!("{}\n", lines.join("\n")))
1155    })
1156    .await?;
1157    tokio_fs::write(&accounts_path, contents).await?;
1158
1159    Ok(())
1160}
1161
1162async fn setup_node_configs(
1163    layout: &AssetsLayout,
1164    total_nodes: u32,
1165    protocol_version_fs: &str,
1166    config_template: &Path,
1167    sidecar_template: &Path,
1168    log_format: &str,
1169) -> Result<()> {
1170    let chainspec_path = layout.net_dir().join("chainspec/chainspec.toml");
1171    let accounts_path = layout.net_dir().join("chainspec/accounts.toml");
1172    let log_format = log_format.to_string();
1173
1174    for node_id in 1..=total_nodes {
1175        let config_root = layout.node_config_root(node_id).join(protocol_version_fs);
1176        tokio_fs::create_dir_all(&config_root).await?;
1177
1178        copy_file(&chainspec_path, &config_root.join("chainspec.toml")).await?;
1179        copy_file(&accounts_path, &config_root.join("accounts.toml")).await?;
1180        copy_file(config_template, &config_root.join("config.toml")).await?;
1181
1182        let config_contents = tokio_fs::read_to_string(config_root.join("config.toml")).await?;
1183        let log_format = log_format.clone();
1184        let bind_address = format!("0.0.0.0:{}", node_port(DEVNET_BASE_PORT_NETWORK, node_id));
1185        let known = known_addresses(node_id, total_nodes);
1186        let rest_address = format!("0.0.0.0:{}", node_port(DEVNET_BASE_PORT_REST, node_id));
1187        let sse_address = format!("0.0.0.0:{}", node_port(DEVNET_BASE_PORT_SSE, node_id));
1188        let binary_address = format!("0.0.0.0:{}", node_port(DEVNET_BASE_PORT_BINARY, node_id));
1189
1190        #[cfg(not(target_os = "macos"))]
1191        let diagnostics_socket = Some(
1192            layout
1193                .node_dir(node_id)
1194                .join("diagnostics_port.socket")
1195                .to_string_lossy()
1196                .to_string(),
1197        );
1198        #[cfg(target_os = "macos")]
1199        let diagnostics_socket: Option<String> = None;
1200
1201        let updated_config = spawn_blocking_result(move || {
1202            let mut config_value: toml::Value = toml::from_str(&config_contents)?;
1203
1204            set_string(
1205                &mut config_value,
1206                &["consensus", "secret_key_path"],
1207                "../../keys/secret_key.pem".to_string(),
1208            )?;
1209            set_string(&mut config_value, &["logging", "format"], log_format)?;
1210            set_string(
1211                &mut config_value,
1212                &["network", "bind_address"],
1213                bind_address,
1214            )?;
1215            set_array(&mut config_value, &["network", "known_addresses"], known)?;
1216            set_string(
1217                &mut config_value,
1218                &["storage", "path"],
1219                "../../storage".to_string(),
1220            )?;
1221            set_string(&mut config_value, &["rest_server", "address"], rest_address)?;
1222            set_string(
1223                &mut config_value,
1224                &["event_stream_server", "address"],
1225                sse_address,
1226            )?;
1227
1228            if let Some(diagnostics_socket) = diagnostics_socket {
1229                set_string(
1230                    &mut config_value,
1231                    &["diagnostics_port", "socket_path"],
1232                    diagnostics_socket,
1233                )?;
1234            } else {
1235                // Even though macOS supports unix sockets, the paths we're using based tend to be too long
1236                // and that causes network startup issues.
1237                // Let's just disable the diagnostics port on macOS for now, it's mostly useful for core protocol development debugging anyway.
1238                // We may want to revisit this in the future if we find a better way to handle it (i.e. with cmd line argument).
1239                set_bool(&mut config_value, &["diagnostics_port", "enabled"], false)?;
1240            }
1241
1242            set_string(
1243                &mut config_value,
1244                &["binary_port_server", "address"],
1245                binary_address,
1246            )?;
1247
1248            // Enable requests that are disabled by default for security reasons.
1249            set_bool(
1250                &mut config_value,
1251                &["binary_port_server", "allow_request_get_trie"],
1252                true,
1253            )?;
1254
1255            // Enable speculative execution requests.
1256            set_bool(
1257                &mut config_value,
1258                &["binary_port_server", "allow_request_speculative_exec"],
1259                true,
1260            )?;
1261
1262            Ok(toml::to_string(&config_value)?)
1263        })
1264        .await?;
1265
1266        tokio_fs::write(config_root.join("config.toml"), updated_config).await?;
1267
1268        if is_file(sidecar_template).await {
1269            let sidecar_path = config_root.join("sidecar.toml");
1270            copy_file(sidecar_template, &sidecar_path).await?;
1271
1272            let sidecar_contents = tokio_fs::read_to_string(&sidecar_path).await?;
1273            let rpc_port = node_port(DEVNET_BASE_PORT_RPC, node_id) as i64;
1274            let binary_port = node_port(DEVNET_BASE_PORT_BINARY, node_id) as i64;
1275
1276            let updated_sidecar = spawn_blocking_result(move || {
1277                let mut sidecar_value: toml::Value = toml::from_str(&sidecar_contents)?;
1278                set_string(
1279                    &mut sidecar_value,
1280                    &["rpc_server", "main_server", "ip_address"],
1281                    "0.0.0.0".to_string(),
1282                )?;
1283                set_integer(
1284                    &mut sidecar_value,
1285                    &["rpc_server", "main_server", "port"],
1286                    rpc_port,
1287                )?;
1288                set_string(
1289                    &mut sidecar_value,
1290                    &["rpc_server", "node_client", "ip_address"],
1291                    "0.0.0.0".to_string(),
1292                )?;
1293                set_integer(
1294                    &mut sidecar_value,
1295                    &["rpc_server", "node_client", "port"],
1296                    binary_port,
1297                )?;
1298
1299                Ok(toml::to_string(&sidecar_value)?)
1300            })
1301            .await?;
1302
1303            tokio_fs::write(&sidecar_path, updated_sidecar).await?;
1304        }
1305    }
1306
1307    Ok(())
1308}
1309
1310fn node_port(base: u32, node_id: u32) -> u32 {
1311    base + DEVNET_NET_PORT_OFFSET + node_id
1312}
1313
1314fn bootstrap_address(node_id: u32) -> String {
1315    format!("127.0.0.1:{}", node_port(DEVNET_BASE_PORT_NETWORK, node_id))
1316}
1317
1318fn known_addresses(node_id: u32, total_nodes: u32) -> Vec<String> {
1319    let bootstrap_nodes = BOOTSTRAP_NODES.min(total_nodes);
1320    let mut addresses = Vec::new();
1321    addresses.push(bootstrap_address(1));
1322
1323    if node_id < bootstrap_nodes {
1324        for id in 2..=bootstrap_nodes {
1325            addresses.push(bootstrap_address(id));
1326        }
1327    } else {
1328        let limit = node_id.min(total_nodes);
1329        for id in 2..=limit {
1330            addresses.push(bootstrap_address(id));
1331        }
1332    }
1333
1334    addresses
1335}
1336
1337fn validator_weight(node_id: u32) -> u128 {
1338    DEVNET_VALIDATOR_BASE_WEIGHT + node_id as u128
1339}
1340
1341fn genesis_timestamp(delay_seconds: u64) -> Result<String> {
1342    let ts = OffsetDateTime::now_utc() + Duration::seconds(delay_seconds as i64);
1343    Ok(ts.format(&Rfc3339)?)
1344}
1345
1346fn format_cspr(motes: u128) -> String {
1347    let whole = motes / MOTE_PER_CSPR;
1348    let rem = motes % MOTE_PER_CSPR;
1349    if rem == 0 {
1350        return whole.to_string();
1351    }
1352    let frac = format!("{:09}", rem);
1353    let frac = frac.trim_end_matches('0');
1354    format!("{}.{}", whole, frac)
1355}
1356
1357fn derived_accounts_path(layout: &AssetsLayout) -> PathBuf {
1358    layout.net_dir().join(DERIVED_ACCOUNTS_FILE)
1359}
1360
1361async fn write_derived_accounts_summary(
1362    layout: &AssetsLayout,
1363    accounts: &[DerivedAccountInfo],
1364) -> Result<()> {
1365    let mut lines = Vec::new();
1366    lines.push("kind,name,key_type,derivation,path,account_hash,balance".to_string());
1367    for account in accounts {
1368        lines.push(account.line());
1369    }
1370    tokio_fs::write(derived_accounts_path(layout), lines.join("\n")).await?;
1371    Ok(())
1372}
1373
1374pub async fn derived_accounts_summary(layout: &AssetsLayout) -> Option<String> {
1375    tokio_fs::read_to_string(derived_accounts_path(layout))
1376        .await
1377        .ok()
1378}
1379
1380async fn copy_file(src: &Path, dest: &Path) -> Result<()> {
1381    if !is_file(src).await {
1382        return Err(anyhow!("missing source file {}", src.display()));
1383    }
1384    if let Some(parent) = dest.parent() {
1385        tokio_fs::create_dir_all(parent).await?;
1386    }
1387    tokio_fs::copy(src, dest).await?;
1388    Ok(())
1389}
1390
1391async fn hardlink_file(src: &Path, dest: &Path) -> Result<()> {
1392    if !is_file(src).await {
1393        return Err(anyhow!("missing source file {}", src.display()));
1394    }
1395    if let Some(parent) = dest.parent() {
1396        tokio_fs::create_dir_all(parent).await?;
1397    }
1398    if let Ok(metadata) = tokio_fs::symlink_metadata(dest).await {
1399        if metadata.is_dir() {
1400            return Err(anyhow!("destination {} is a directory", dest.display()));
1401        }
1402        tokio_fs::remove_file(dest).await?;
1403    }
1404    tokio_fs::hard_link(src, dest).await?;
1405    Ok(())
1406}
1407
1408async fn is_dir(path: &Path) -> bool {
1409    tokio_fs::metadata(path)
1410        .await
1411        .map(|meta| meta.is_dir())
1412        .unwrap_or(false)
1413}
1414
1415async fn is_file(path: &Path) -> bool {
1416    tokio_fs::metadata(path)
1417        .await
1418        .map(|meta| meta.is_file())
1419        .unwrap_or(false)
1420}
1421
1422async fn spawn_blocking_result<F, T>(f: F) -> Result<T>
1423where
1424    F: FnOnce() -> Result<T> + Send + 'static,
1425    T: Send + 'static,
1426{
1427    match task::spawn_blocking(f).await {
1428        Ok(result) => result,
1429        Err(err) => Err(anyhow!("blocking task failed: {}", err)),
1430    }
1431}
1432
1433fn set_string(root: &mut toml::Value, path: &[&str], value: String) -> Result<()> {
1434    set_value(root, path, toml::Value::String(value))
1435}
1436
1437fn set_integer(root: &mut toml::Value, path: &[&str], value: i64) -> Result<()> {
1438    set_value(root, path, toml::Value::Integer(value))
1439}
1440
1441fn set_array(root: &mut toml::Value, path: &[&str], values: Vec<String>) -> Result<()> {
1442    let array = values.into_iter().map(toml::Value::String).collect();
1443    set_value(root, path, toml::Value::Array(array))
1444}
1445
1446fn set_bool(root: &mut toml::Value, path: &[&str], value: bool) -> Result<()> {
1447    set_value(root, path, toml::Value::Boolean(value))
1448}
1449
1450fn set_value(root: &mut toml::Value, path: &[&str], value: toml::Value) -> Result<()> {
1451    let table = root
1452        .as_table_mut()
1453        .ok_or_else(|| anyhow!("TOML root is not a table"))?;
1454
1455    let mut current = table;
1456    for key in &path[..path.len() - 1] {
1457        current = ensure_table(current, key);
1458    }
1459    current.insert(path[path.len() - 1].to_string(), value);
1460    Ok(())
1461}
1462
1463fn ensure_table<'a>(table: &'a mut toml::value::Table, key: &str) -> &'a mut toml::value::Table {
1464    if !table.contains_key(key) {
1465        table.insert(
1466            key.to_string(),
1467            toml::Value::Table(toml::value::Table::new()),
1468        );
1469    }
1470    table
1471        .get_mut(key)
1472        .and_then(|v| v.as_table_mut())
1473        .expect("table entry is not a table")
1474}
1475
1476#[cfg(test)]
1477mod tests {
1478    use super::format_cspr;
1479
1480    #[test]
1481    fn format_cspr_handles_whole_and_fractional() {
1482        assert_eq!(format_cspr(0), "0");
1483        assert_eq!(format_cspr(1), "0.000000001");
1484        assert_eq!(format_cspr(1_000_000_000), "1");
1485        assert_eq!(format_cspr(1_000_000_001), "1.000000001");
1486        assert_eq!(format_cspr(123_000_000_000), "123");
1487        assert_eq!(format_cspr(123_000_000_456), "123.000000456");
1488    }
1489}