casper_devnet/
assets.rs

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