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