Skip to main content

casper_devnet/
assets.rs

1mod chainspec_override;
2
3use anyhow::{Result, anyhow};
4use bip32::{DerivationPath, XPrv};
5use blake2::Blake2bVar;
6use blake2::digest::{Update, VariableOutput};
7use casper_types::account::AccountHash;
8use casper_types::{AsymmetricType, PublicKey, SecretKey};
9use directories::ProjectDirs;
10use flate2::read::GzDecoder;
11use futures::StreamExt;
12use indicatif::{ProgressBar, ProgressStyle};
13use semver::Version;
14use serde::{Deserialize, Serialize};
15use sha2::{Digest, Sha512};
16use std::collections::BTreeSet;
17use std::ffi::OsStr;
18use std::fs::File;
19use std::io::{Cursor, Read};
20use std::os::unix::fs::PermissionsExt;
21use std::path::{Path, PathBuf};
22use std::process::Stdio;
23use std::str::FromStr;
24use std::sync::Arc;
25use std::time::Duration as StdDuration;
26use tar::Archive;
27use time::format_description::well_known::Rfc3339;
28use time::{Duration, OffsetDateTime};
29use tokio::fs as tokio_fs;
30use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
31use tokio::process::Command;
32use tokio::task;
33
34use crate::node_launcher::NODE_LAUNCHER_STATE_FILE;
35
36pub const BOOTSTRAP_NODES: u32 = 3;
37
38const DEVNET_BASE_PORT_RPC: u32 = 11000;
39const DEVNET_BASE_PORT_REST: u32 = 14000;
40const DEVNET_BASE_PORT_SSE: u32 = 18000;
41const DEVNET_BASE_PORT_NETWORK: u32 = 22000;
42const DEVNET_BASE_PORT_BINARY: u32 = 28000;
43const DEVNET_DIAGNOSTICS_PROXY_PORT: u32 = 32000;
44const DEVNET_NET_PORT_OFFSET: u32 = 100;
45
46const DEVNET_INITIAL_BALANCE_USER: u128 = 1_000_000_000_000_000_000_000_000_000_000_000_000;
47const DEVNET_INITIAL_BALANCE_VALIDATOR: u128 = 1_000_000_000_000_000_000_000_000_000_000_000_000;
48const DEVNET_INITIAL_DELEGATION_AMOUNT: u128 = 1_000_000_000_000_000_000;
49const DEVNET_VALIDATOR_BASE_WEIGHT: u128 = 1_000_000_000_000_000_000;
50const DEVNET_SEED_DOMAIN: &[u8] = b"casper-unsafe-devnet-v1";
51const DERIVATION_PATH_PREFIX: &str = "m/44'/506'/0'/0";
52const USER_DERIVATION_START: u32 = 100;
53const DERIVED_ACCOUNTS_FILE: &str = "derived-accounts.csv";
54const SECRET_KEY_PEM: &str = "secret_key.pem";
55const MOTE_PER_CSPR: u128 = 1_000_000_000;
56const PROGRESS_TICK_MS: u64 = 120;
57const CONTROL_SOCKET_NAME_MAX: usize = 80;
58const HOOKS_DIR_NAME: &str = "hooks";
59const HOOKS_PENDING_DIR_NAME: &str = "pending";
60const HOOKS_STATUS_DIR_NAME: &str = "status";
61const HOOKS_LOGS_DIR_NAME: &str = "logs";
62const HOOKS_WORK_DIR_NAME: &str = "work";
63const PRE_GENESIS_HOOK: &str = "pre-genesis";
64const POST_GENESIS_HOOK: &str = "post-genesis";
65const BLOCK_ADDED_HOOK: &str = "block-added";
66const PRE_STAGE_PROTOCOL_HOOK: &str = "pre-stage-protocol";
67const POST_STAGE_PROTOCOL_HOOK: &str = "post-stage-protocol";
68const PRE_GENESIS_SAMPLE: &str = "pre-genesis.sample";
69const POST_GENESIS_SAMPLE: &str = "post-genesis.sample";
70const BLOCK_ADDED_SAMPLE: &str = "block-added.sample";
71const PRE_STAGE_PROTOCOL_SAMPLE: &str = "pre-stage-protocol.sample";
72const POST_STAGE_PROTOCOL_SAMPLE: &str = "post-stage-protocol.sample";
73const HOOK_FILE_MODE: u32 = 0o755;
74const PRE_GENESIS_SAMPLE_SCRIPT: &[u8] = include_bytes!("../examples/hooks/pre-genesis.sample");
75const POST_GENESIS_SAMPLE_SCRIPT: &[u8] = include_bytes!("../examples/hooks/post-genesis.sample");
76const BLOCK_ADDED_SAMPLE_SCRIPT: &[u8] = include_bytes!("../examples/hooks/block-added.sample");
77const PRE_STAGE_PROTOCOL_SAMPLE_SCRIPT: &[u8] =
78    include_bytes!("../examples/hooks/pre-stage-protocol.sample");
79const POST_STAGE_PROTOCOL_SAMPLE_SCRIPT: &[u8] =
80    include_bytes!("../examples/hooks/post-stage-protocol.sample");
81
82#[derive(Debug)]
83struct DerivedAccountMaterial {
84    path: DerivationPath,
85    public_key_hex: String,
86    account_hash: String,
87    secret_key_pem: Option<String>,
88}
89
90#[derive(Debug, Clone)]
91pub(crate) struct DerivedPathMaterial {
92    pub public_key_hex: String,
93    pub account_hash: String,
94    pub secret_key_pem: String,
95}
96
97#[derive(Debug, Clone)]
98struct DerivedAccountInfo {
99    kind: &'static str,
100    name: String,
101    id: u32,
102    path: DerivationPath,
103    public_key_hex: String,
104    account_hash: String,
105    balance_motes: u128,
106}
107
108#[derive(Debug)]
109struct DerivedAccounts {
110    nodes: Vec<DerivedAccountInfo>,
111    users: Vec<DerivedAccountInfo>,
112}
113
114impl DerivedAccountInfo {
115    fn line(&self) -> String {
116        format!(
117            "{},{},{},{},{},{},{}",
118            self.kind,
119            self.name,
120            "secp256k1",
121            "bip32",
122            self.path,
123            self.account_hash,
124            format_cspr(self.balance_motes)
125        )
126    }
127}
128
129/// Layout of generated assets for a given network.
130#[derive(Clone, Debug)]
131pub struct AssetsLayout {
132    assets_root: PathBuf,
133    network_name: String,
134}
135
136impl AssetsLayout {
137    /// Create a new layout rooted at `assets_root/<network_name>`.
138    pub fn new(assets_root: PathBuf, network_name: String) -> Self {
139        Self {
140            assets_root,
141            network_name,
142        }
143    }
144
145    /// Root folder for assets (contains all networks).
146    pub fn assets_root(&self) -> &Path {
147        &self.assets_root
148    }
149
150    /// Network name used in paths and configs.
151    pub fn network_name(&self) -> &str {
152        &self.network_name
153    }
154
155    /// Base directory for this network's assets.
156    pub fn net_dir(&self) -> PathBuf {
157        self.assets_root.join(&self.network_name)
158    }
159
160    /// Directory that contains all node folders.
161    pub fn nodes_dir(&self) -> PathBuf {
162        self.net_dir().join("nodes")
163    }
164
165    /// Directory for a single node.
166    pub fn node_dir(&self, node_id: u32) -> PathBuf {
167        self.nodes_dir().join(format!("node-{}", node_id))
168    }
169
170    /// Directory for a node's binaries.
171    pub fn node_bin_dir(&self, node_id: u32) -> PathBuf {
172        self.node_dir(node_id).join("bin")
173    }
174
175    /// Directory for a node's configs.
176    pub fn node_config_root(&self, node_id: u32) -> PathBuf {
177        self.node_dir(node_id).join("config")
178    }
179
180    /// Directory for a node's logs.
181    pub fn node_logs_dir(&self, node_id: u32) -> PathBuf {
182        self.node_dir(node_id).join("logs")
183    }
184
185    /// Directory for network-scoped hooks.
186    pub fn hooks_dir(&self) -> PathBuf {
187        network_hooks_dir(&self.net_dir())
188    }
189
190    /// Directory for pending hook metadata.
191    pub fn hooks_pending_dir(&self) -> PathBuf {
192        network_hooks_pending_dir(&self.net_dir())
193    }
194
195    /// Directory for hook completion state.
196    pub fn hooks_status_dir(&self) -> PathBuf {
197        network_hooks_status_dir(&self.net_dir())
198    }
199
200    /// Directory for hook stdout/stderr logs.
201    pub fn hook_logs_dir(&self) -> PathBuf {
202        network_hook_logs_dir(&self.net_dir())
203    }
204
205    /// Root directory for per-hook working directories.
206    pub fn hook_work_root(&self) -> PathBuf {
207        network_hook_work_root(&self.net_dir())
208    }
209
210    /// Dedicated working directory for a specific hook.
211    pub fn hook_work_dir(&self, hook_name: &str) -> PathBuf {
212        network_hook_work_dir(&self.net_dir(), hook_name)
213    }
214
215    /// Path to the control socket for runtime operations.
216    pub fn control_socket_path(&self) -> PathBuf {
217        control_socket_path_for_network(&self.network_name)
218    }
219
220    /// Returns true if the network's nodes directory exists.
221    pub async fn exists(&self) -> bool {
222        tokio_fs::metadata(self.nodes_dir())
223            .await
224            .map(|meta| meta.is_dir())
225            .unwrap_or(false)
226    }
227
228    /// List node IDs under `nodes/`.
229    pub async fn node_ids(&self) -> Result<Vec<u32>> {
230        let nodes_dir = self.nodes_dir();
231        if !is_dir(&nodes_dir).await {
232            return Ok(Vec::new());
233        }
234        let mut node_ids = Vec::new();
235        let mut entries = tokio_fs::read_dir(&nodes_dir).await?;
236        while let Some(entry) = entries.next_entry().await? {
237            if !entry.file_type().await?.is_dir() {
238                continue;
239            }
240            let name = entry.file_name();
241            let name = name.to_string_lossy();
242            if let Some(node_id) = name
243                .strip_prefix("node-")
244                .and_then(|suffix| suffix.parse::<u32>().ok())
245            {
246                node_ids.push(node_id);
247            }
248        }
249        node_ids.sort_unstable();
250        Ok(node_ids)
251    }
252
253    /// Count node directories under `nodes/`.
254    pub async fn count_nodes(&self) -> Result<u32> {
255        Ok(self.node_ids().await?.len() as u32)
256    }
257
258    /// Find the newest protocol version directory for a node.
259    pub async fn latest_protocol_version_dir(&self, node_id: u32) -> Result<String> {
260        let bin_dir = self.node_bin_dir(node_id);
261        let mut versions: Vec<(Version, String)> = Vec::new();
262        let mut entries = tokio_fs::read_dir(&bin_dir).await?;
263        while let Some(entry) = entries.next_entry().await? {
264            if !entry.file_type().await?.is_dir() {
265                continue;
266            }
267            let name = entry.file_name().to_string_lossy().to_string();
268            let version_str = name.replace('_', ".");
269            if let Ok(version) = Version::parse(&version_str) {
270                versions.push((version, name));
271            }
272        }
273        versions.sort_by(|a, b| a.0.cmp(&b.0));
274        versions
275            .last()
276            .map(|(_, name)| name.clone())
277            .ok_or_else(|| {
278                anyhow!(
279                    "no protocol version directories found in {}",
280                    bin_dir.display()
281                )
282            })
283    }
284
285    /// Return all config.toml paths for a node.
286    pub async fn node_config_paths(&self, node_id: u32) -> Result<Vec<PathBuf>> {
287        let config_root = self.node_config_root(node_id);
288        let mut paths = Vec::new();
289        if !is_dir(&config_root).await {
290            return Ok(paths);
291        }
292        let mut entries = tokio_fs::read_dir(&config_root).await?;
293        while let Some(entry) = entries.next_entry().await? {
294            if !entry.file_type().await?.is_dir() {
295                continue;
296            }
297            let path = entry.path().join("config.toml");
298            if is_file(&path).await {
299                paths.push(path);
300            }
301        }
302        Ok(paths)
303    }
304}
305
306fn control_socket_path_for_network(network_name: &str) -> PathBuf {
307    let socket_name = format!("{}.socket", normalize_control_socket_name(network_name));
308    std::env::temp_dir().join(socket_name)
309}
310
311fn normalize_control_socket_name(network_name: &str) -> String {
312    let mut normalized = network_name
313        .chars()
314        .map(|ch| {
315            if ch.is_ascii_alphanumeric() || matches!(ch, '-' | '_' | '.') {
316                ch
317            } else {
318                '_'
319            }
320        })
321        .collect::<String>();
322    if normalized.is_empty() {
323        normalized.push_str("casper-dev");
324    }
325    if normalized.len() > CONTROL_SOCKET_NAME_MAX {
326        normalized.truncate(CONTROL_SOCKET_NAME_MAX);
327    }
328    normalized
329}
330
331pub fn data_dir() -> Result<PathBuf> {
332    let project_dirs = ProjectDirs::from("xyz", "veleslabs", "casper-devnet")
333        .ok_or_else(|| anyhow!("unable to resolve data directory"))?;
334    Ok(project_dirs.data_dir().to_path_buf())
335}
336
337pub fn default_assets_root() -> Result<PathBuf> {
338    Ok(data_dir()?.join("networks"))
339}
340
341pub fn assets_bundle_root() -> Result<PathBuf> {
342    Ok(data_dir()?.join("assets"))
343}
344
345pub fn custom_assets_root() -> Result<PathBuf> {
346    Ok(assets_bundle_root()?.join("custom"))
347}
348
349pub async fn custom_asset_path(name: &str) -> Result<PathBuf> {
350    validate_custom_asset_name(name)?;
351    let asset_dir = custom_assets_root()?.join(name);
352    if !is_dir(&asset_dir).await {
353        return Err(anyhow!(
354            "custom asset '{}' not found at {}",
355            name,
356            asset_dir.display()
357        ));
358    }
359    Ok(asset_dir)
360}
361
362pub fn optional_asset_selector(
363    asset: Option<&str>,
364    custom_asset: Option<&str>,
365) -> Result<AssetSelector> {
366    match (asset, custom_asset) {
367        (Some(_), Some(_)) => Err(anyhow!("--asset and --custom-asset are mutually exclusive")),
368        (Some(asset), None) => Ok(AssetSelector::Versioned(asset.to_string())),
369        (None, Some(custom_asset)) => Ok(AssetSelector::Custom(custom_asset.to_string())),
370        (None, None) => Ok(AssetSelector::LatestVersioned),
371    }
372}
373
374pub fn required_asset_selector(
375    asset: Option<&str>,
376    custom_asset: Option<&str>,
377) -> Result<AssetSelector> {
378    match optional_asset_selector(asset, custom_asset)? {
379        AssetSelector::LatestVersioned => {
380            Err(anyhow!("one of --asset or --custom-asset is required"))
381        }
382        selector => Ok(selector),
383    }
384}
385
386pub fn file_name(path: &Path) -> Option<&OsStr> {
387    path.file_name()
388}
389
390pub fn sse_endpoint(node_id: u32) -> String {
391    format!(
392        "http://127.0.0.1:{}/events",
393        node_port(DEVNET_BASE_PORT_SSE, node_id)
394    )
395}
396
397pub fn rest_endpoint(node_id: u32) -> String {
398    format!(
399        "http://127.0.0.1:{}",
400        node_port(DEVNET_BASE_PORT_REST, node_id)
401    )
402}
403
404pub fn rpc_endpoint(node_id: u32) -> String {
405    format!(
406        "http://127.0.0.1:{}/rpc",
407        node_port(DEVNET_BASE_PORT_RPC, node_id)
408    )
409}
410
411pub fn binary_address(node_id: u32) -> String {
412    format!("127.0.0.1:{}", node_port(DEVNET_BASE_PORT_BINARY, node_id))
413}
414
415pub fn network_address(node_id: u32) -> String {
416    format!("127.0.0.1:{}", node_port(DEVNET_BASE_PORT_NETWORK, node_id))
417}
418
419pub fn diagnostics_proxy_port() -> u32 {
420    DEVNET_DIAGNOSTICS_PROXY_PORT
421}
422
423pub fn diagnostics_ws_endpoint(node_id: u32) -> String {
424    format!(
425        "ws://127.0.0.1:{}/diagnostics/node-{}/",
426        DEVNET_DIAGNOSTICS_PROXY_PORT, node_id
427    )
428}
429
430pub fn diagnostics_socket_path(network_name: &str, node_id: u32) -> String {
431    let socket_name = format!("{}-{}.sock", network_name, node_id);
432    tempfile::env::temp_dir()
433        .join(socket_name)
434        .to_string_lossy()
435        .to_string()
436}
437
438pub(crate) fn network_hooks_dir(network_dir: &Path) -> PathBuf {
439    network_dir.join(HOOKS_DIR_NAME)
440}
441
442pub(crate) fn network_hooks_pending_dir(network_dir: &Path) -> PathBuf {
443    network_hooks_dir(network_dir).join(format!(".{HOOKS_PENDING_DIR_NAME}"))
444}
445
446pub(crate) fn network_hooks_status_dir(network_dir: &Path) -> PathBuf {
447    network_hooks_dir(network_dir).join(format!(".{HOOKS_STATUS_DIR_NAME}"))
448}
449
450pub(crate) fn network_hook_logs_dir(network_dir: &Path) -> PathBuf {
451    network_hooks_dir(network_dir).join(HOOKS_LOGS_DIR_NAME)
452}
453
454pub(crate) fn network_hook_work_root(network_dir: &Path) -> PathBuf {
455    network_hooks_dir(network_dir).join(HOOKS_WORK_DIR_NAME)
456}
457
458pub(crate) fn network_hook_work_dir(network_dir: &Path, hook_name: &str) -> PathBuf {
459    network_hook_work_root(network_dir).join(hook_name)
460}
461
462#[derive(Clone, Debug, PartialEq, Eq)]
463pub enum AssetSelector {
464    LatestVersioned,
465    Versioned(String),
466    Custom(String),
467}
468
469#[derive(Clone, Copy, Debug, PartialEq, Eq)]
470enum AssetSourceKind {
471    Versioned,
472    Custom,
473}
474
475#[derive(Clone, Debug)]
476struct ResolvedAsset {
477    display_name: String,
478    source_kind: AssetSourceKind,
479    casper_node: PathBuf,
480    casper_sidecar: PathBuf,
481    chainspec: PathBuf,
482    node_config: PathBuf,
483    sidecar_config: PathBuf,
484    chainspec_protocol_version: Version,
485}
486
487#[derive(Debug)]
488pub struct SetupLocalResult {
489    pub protocol_version: String,
490}
491
492/// Parameters for building a local devnet asset tree.
493pub struct SetupOptions {
494    pub nodes: u32,
495    pub users: Option<u32>,
496    pub delay_seconds: u64,
497    pub network_name: String,
498    pub asset: AssetSelector,
499    pub protocol_version: Option<String>,
500    pub chainspec_overrides: Vec<String>,
501    pub node_log_format: String,
502    pub seed: Arc<str>,
503}
504
505pub struct CustomAssetInstallOptions {
506    pub name: String,
507    pub casper_node: PathBuf,
508    pub casper_sidecar: PathBuf,
509    pub chainspec: PathBuf,
510    pub node_config: PathBuf,
511    pub sidecar_config: PathBuf,
512}
513
514pub struct StageProtocolOptions {
515    pub asset: AssetSelector,
516    pub protocol_version: String,
517    pub activation_point: u64,
518    pub chainspec_overrides: Vec<String>,
519}
520
521#[derive(Debug)]
522pub struct StageProtocolResult {
523    pub staged_nodes: u32,
524}
525
526pub struct AddNodesOptions {
527    pub count: u32,
528    pub seed: Arc<str>,
529}
530
531#[derive(Debug)]
532pub struct AddNodesResult {
533    pub added_node_ids: Vec<u32>,
534    pub total_nodes: u32,
535}
536
537#[derive(Clone, Debug)]
538struct HookLogPaths {
539    stdout: PathBuf,
540    stderr: PathBuf,
541}
542
543#[derive(Clone, Debug, Serialize, Deserialize)]
544struct PendingPostStageProtocolHook {
545    asset_name: String,
546    network_name: String,
547    protocol_version: String,
548    activation_point: u64,
549    command_path: PathBuf,
550    stdout_path: PathBuf,
551    stderr_path: PathBuf,
552}
553
554#[derive(Clone, Debug, Serialize, Deserialize)]
555struct PendingPostGenesisHook {
556    network_name: String,
557    protocol_version: String,
558    command_path: PathBuf,
559    stdout_path: PathBuf,
560    stderr_path: PathBuf,
561}
562
563#[derive(Clone, Debug, Serialize, Deserialize)]
564struct CompletedPostStageProtocolHook {
565    asset_name: String,
566    network_name: String,
567    protocol_version: String,
568    activation_point: u64,
569    command_path: PathBuf,
570    stdout_path: PathBuf,
571    stderr_path: PathBuf,
572    status: HookRunStatus,
573    exit_code: Option<i32>,
574    error: Option<String>,
575    #[serde(with = "time::serde::rfc3339")]
576    completed_at: OffsetDateTime,
577}
578
579#[derive(Clone, Debug, Serialize, Deserialize)]
580struct CompletedPostGenesisHook {
581    network_name: String,
582    protocol_version: String,
583    command_path: PathBuf,
584    stdout_path: PathBuf,
585    stderr_path: PathBuf,
586    status: HookRunStatus,
587    exit_code: Option<i32>,
588    error: Option<String>,
589    #[serde(with = "time::serde::rfc3339")]
590    completed_at: OffsetDateTime,
591}
592
593#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
594#[serde(rename_all = "snake_case")]
595enum HookRunStatus {
596    Success,
597    Failure,
598}
599
600pub(crate) enum PendingPostStageProtocolHookResult {
601    NotRun,
602    Succeeded,
603    Failed(String),
604}
605
606pub(crate) enum PendingPostGenesisHookResult {
607    NotRun,
608    Succeeded,
609    Failed(String),
610}
611
612/// Create or refresh local assets for a devnet.
613pub async fn setup_local(layout: &AssetsLayout, opts: &SetupOptions) -> Result<SetupLocalResult> {
614    let genesis_nodes = opts.nodes;
615    if genesis_nodes == 0 {
616        return Err(anyhow!("nodes must be greater than 0"));
617    }
618    let total_nodes = genesis_nodes;
619    let users = opts.users.unwrap_or(total_nodes);
620    let asset = resolve_asset(&opts.asset).await?;
621    preflight_resolved_asset(&asset).await?;
622    let protocol_version = match &opts.protocol_version {
623        Some(protocol_version) => parse_protocol_version(protocol_version)?,
624        None => asset.chainspec_protocol_version.clone(),
625    };
626    let protocol_version_chain = protocol_version.to_string();
627    let protocol_version_fs = protocol_version_chain.replace('.', "_");
628
629    let net_dir = layout.net_dir();
630    tokio_fs::create_dir_all(&net_dir).await?;
631
632    setup_directories(layout, total_nodes, &protocol_version_fs).await?;
633    setup_binaries(layout, total_nodes, &asset, &protocol_version_fs).await?;
634
635    let derived_accounts =
636        setup_seeded_keys(layout, total_nodes, users, Arc::clone(&opts.seed)).await?;
637
638    setup_chainspec(
639        layout,
640        total_nodes,
641        &asset.chainspec,
642        opts.delay_seconds,
643        &protocol_version_chain,
644        &opts.network_name,
645        &opts.chainspec_overrides,
646    )
647    .await?;
648
649    setup_accounts(layout, total_nodes, genesis_nodes, users, &derived_accounts).await?;
650
651    setup_node_configs(
652        layout,
653        total_nodes,
654        &protocol_version_fs,
655        &asset.node_config,
656        &asset.sidecar_config,
657        &opts.node_log_format,
658    )
659    .await?;
660    ensure_network_hook_samples(layout).await?;
661
662    Ok(SetupLocalResult {
663        protocol_version: protocol_version_chain,
664    })
665}
666
667pub async fn install_custom_asset(opts: &CustomAssetInstallOptions) -> Result<()> {
668    validate_custom_asset_name(&opts.name)?;
669
670    let node_src = canonicalize_required_file(&opts.casper_node, "casper-node").await?;
671    let sidecar_src = canonicalize_required_file(&opts.casper_sidecar, "casper-sidecar").await?;
672    let chainspec_src = canonicalize_required_file(&opts.chainspec, "chainspec").await?;
673    let node_config_src = canonicalize_required_file(&opts.node_config, "node-config").await?;
674    let sidecar_config_src =
675        canonicalize_required_file(&opts.sidecar_config, "sidecar-config").await?;
676
677    verify_binary_version(&node_src, "casper-node").await?;
678    verify_binary_version(&sidecar_src, "casper-sidecar").await?;
679
680    let root = custom_assets_root()?;
681    let asset_dir = root.join(&opts.name);
682    if tokio_fs::symlink_metadata(&asset_dir).await.is_ok() {
683        return Err(anyhow!(
684            "custom asset '{}' already exists at {}",
685            opts.name,
686            asset_dir.display()
687        ));
688    }
689
690    tokio_fs::create_dir_all(asset_dir.join("bin")).await?;
691    symlink_file(&node_src, &asset_dir.join("bin").join("casper-node")).await?;
692    symlink_file(&sidecar_src, &asset_dir.join("bin").join("casper-sidecar")).await?;
693    symlink_file(&chainspec_src, &asset_dir.join("chainspec.toml")).await?;
694    symlink_file(&node_config_src, &asset_dir.join("node-config.toml")).await?;
695    symlink_file(&sidecar_config_src, &asset_dir.join("sidecar-config.toml")).await?;
696
697    Ok(())
698}
699
700pub async fn stage_protocol(
701    layout: &AssetsLayout,
702    opts: &StageProtocolOptions,
703) -> Result<StageProtocolResult> {
704    let asset = resolve_asset(&opts.asset).await?;
705    preflight_resolved_asset(&asset).await?;
706    let protocol_version = parse_protocol_version(&opts.protocol_version)?;
707    let protocol_version_chain = protocol_version.to_string();
708    let protocol_version_fs = protocol_version_chain.replace('.', "_");
709
710    let total_nodes = layout.count_nodes().await?;
711    if total_nodes == 0 {
712        return Err(anyhow!(
713            "no nodes found under {}; run start --setup-only first",
714            layout.nodes_dir().display()
715        ));
716    }
717    ensure_network_hook_samples(layout).await?;
718
719    let node_log_format = detect_current_node_log_format(layout).await?;
720    let accounts_path = layout.net_dir().join("chainspec/accounts.toml");
721    let mut overwritten_builtin_paths = Vec::new();
722
723    for node_id in 1..=total_nodes {
724        let node_bin_version_dir = layout.node_bin_dir(node_id).join(&protocol_version_fs);
725        let node_config_version_dir = layout.node_config_root(node_id).join(&protocol_version_fs);
726
727        if is_dir(&node_bin_version_dir).await {
728            tokio_fs::remove_dir_all(&node_bin_version_dir).await?;
729        }
730        if is_dir(&node_config_version_dir).await {
731            tokio_fs::remove_dir_all(&node_config_version_dir).await?;
732        }
733        tokio_fs::create_dir_all(&node_bin_version_dir).await?;
734        tokio_fs::create_dir_all(&node_config_version_dir).await?;
735
736        install_binary_file(
737            &asset,
738            &asset.casper_node,
739            &node_bin_version_dir.join("casper-node"),
740        )
741        .await?;
742        install_binary_file(
743            &asset,
744            &asset.casper_sidecar,
745            &node_bin_version_dir.join("casper-sidecar"),
746        )
747        .await?;
748
749        let chainspec_dest = node_config_version_dir.join("chainspec.toml");
750        copy_file(&asset.chainspec, &chainspec_dest).await?;
751        let staged_chainspec = tokio_fs::read_to_string(&chainspec_dest).await?;
752        let network_name = layout.network_name().to_string();
753        let activation_point = opts.activation_point as i64;
754        let protocol_version_chain = protocol_version_chain.clone();
755        let chainspec_overrides = opts.chainspec_overrides.clone();
756        let (updated_chainspec, node_overwritten_builtin_paths) =
757            spawn_blocking_result(move || {
758                let applied_overrides =
759                    chainspec_override::apply(&staged_chainspec, &chainspec_overrides)?;
760                update_chainspec_contents(
761                    &applied_overrides.contents,
762                    &protocol_version_chain,
763                    &activation_point.to_string(),
764                    false,
765                    &network_name,
766                    total_nodes,
767                )
768                .map(|updated| (updated, applied_overrides.overwritten_builtin_paths))
769            })
770            .await?;
771        for path in node_overwritten_builtin_paths {
772            if !overwritten_builtin_paths.contains(&path) {
773                overwritten_builtin_paths.push(path);
774            }
775        }
776        tokio_fs::write(&chainspec_dest, updated_chainspec).await?;
777
778        if is_file(&accounts_path).await {
779            copy_file(
780                &accounts_path,
781                &node_config_version_dir.join("accounts.toml"),
782            )
783            .await?;
784        }
785
786        let node_config_dest = node_config_version_dir.join("config.toml");
787        copy_file(&asset.node_config, &node_config_dest).await?;
788        let config_contents = tokio_fs::read_to_string(&node_config_dest).await?;
789        let bind_address = format!("0.0.0.0:{}", node_port(DEVNET_BASE_PORT_NETWORK, node_id));
790        let known = known_addresses(node_id, total_nodes);
791        let rest_address = format!("0.0.0.0:{}", node_port(DEVNET_BASE_PORT_REST, node_id));
792        let sse_address = format!("0.0.0.0:{}", node_port(DEVNET_BASE_PORT_SSE, node_id));
793        let binary_address = format!("0.0.0.0:{}", node_port(DEVNET_BASE_PORT_BINARY, node_id));
794        let diagnostics_socket = diagnostics_socket_path(layout.network_name(), node_id);
795        let node_log_format = node_log_format.clone();
796        let updated_config = spawn_blocking_result(move || {
797            let mut config_value: toml::Value = toml::from_str(&config_contents)?;
798            set_string(
799                &mut config_value,
800                &["consensus", "secret_key_path"],
801                "../../keys/secret_key.pem".to_string(),
802            )?;
803            set_string(&mut config_value, &["logging", "format"], node_log_format)?;
804            set_string(
805                &mut config_value,
806                &["network", "bind_address"],
807                bind_address,
808            )?;
809            set_array(&mut config_value, &["network", "known_addresses"], known)?;
810            set_string(
811                &mut config_value,
812                &["storage", "path"],
813                "../../storage".to_string(),
814            )?;
815            set_string(&mut config_value, &["rest_server", "address"], rest_address)?;
816            set_string(
817                &mut config_value,
818                &["event_stream_server", "address"],
819                sse_address,
820            )?;
821            set_string(
822                &mut config_value,
823                &["diagnostics_port", "socket_path"],
824                diagnostics_socket,
825            )?;
826            set_string(
827                &mut config_value,
828                &["binary_port_server", "address"],
829                binary_address,
830            )?;
831            set_bool(
832                &mut config_value,
833                &["binary_port_server", "allow_request_get_trie"],
834                true,
835            )?;
836            set_bool(
837                &mut config_value,
838                &["binary_port_server", "allow_request_speculative_exec"],
839                true,
840            )?;
841            Ok(toml::to_string(&config_value)?)
842        })
843        .await?;
844        tokio_fs::write(&node_config_dest, updated_config).await?;
845
846        let sidecar_dest = node_config_version_dir.join("sidecar.toml");
847        copy_file(&asset.sidecar_config, &sidecar_dest).await?;
848        let sidecar_contents = tokio_fs::read_to_string(&sidecar_dest).await?;
849        let rpc_port = node_port(DEVNET_BASE_PORT_RPC, node_id) as i64;
850        let binary_port = node_port(DEVNET_BASE_PORT_BINARY, node_id) as i64;
851        let updated_sidecar = spawn_blocking_result(move || {
852            let mut sidecar_value: toml::Value = toml::from_str(&sidecar_contents)?;
853            set_string(
854                &mut sidecar_value,
855                &["rpc_server", "main_server", "ip_address"],
856                "0.0.0.0".to_string(),
857            )?;
858            set_integer(
859                &mut sidecar_value,
860                &["rpc_server", "main_server", "port"],
861                rpc_port,
862            )?;
863            set_string(
864                &mut sidecar_value,
865                &["rpc_server", "node_client", "ip_address"],
866                "0.0.0.0".to_string(),
867            )?;
868            set_integer(
869                &mut sidecar_value,
870                &["rpc_server", "node_client", "port"],
871                binary_port,
872            )?;
873            Ok(toml::to_string(&sidecar_value)?)
874        })
875        .await?;
876        tokio_fs::write(&sidecar_dest, updated_sidecar).await?;
877    }
878
879    for path in overwritten_builtin_paths {
880        eprintln!(
881            "warning: chainspec override {path} is overwritten by stage-protocol command defaults"
882        );
883    }
884
885    clear_post_stage_protocol_hook_state(layout, &protocol_version_chain).await?;
886    if let Err(err) =
887        run_pre_stage_protocol_hook(layout, &protocol_version_chain, opts.activation_point).await
888    {
889        return Err(rollback_staged_protocol_dirs_after_error(
890            layout,
891            total_nodes,
892            &protocol_version_fs,
893            err,
894        )
895        .await);
896    }
897
898    refresh_post_stage_protocol_hook(layout, &asset.display_name, opts, &protocol_version_chain)
899        .await?;
900
901    Ok(StageProtocolResult {
902        staged_nodes: total_nodes,
903    })
904}
905
906async fn rollback_staged_protocol_dirs_after_error(
907    layout: &AssetsLayout,
908    total_nodes: u32,
909    protocol_version_fs: &str,
910    err: anyhow::Error,
911) -> anyhow::Error {
912    match rollback_staged_protocol_dirs(layout, total_nodes, protocol_version_fs).await {
913        Ok(()) => err,
914        Err(rollback_err) => {
915            anyhow!("{err}; additionally failed to remove staged protocol assets: {rollback_err}")
916        }
917    }
918}
919
920async fn rollback_staged_protocol_dirs(
921    layout: &AssetsLayout,
922    total_nodes: u32,
923    protocol_version_fs: &str,
924) -> Result<()> {
925    let mut errors = Vec::new();
926    for node_id in 1..=total_nodes {
927        for path in [
928            layout.node_bin_dir(node_id).join(protocol_version_fs),
929            layout.node_config_root(node_id).join(protocol_version_fs),
930        ] {
931            if !is_dir(&path).await {
932                continue;
933            }
934            let display = path.display().to_string();
935            if let Err(err) = tokio_fs::remove_dir_all(&path).await {
936                errors.push(format!("{display}: {err}"));
937            }
938        }
939    }
940
941    if errors.is_empty() {
942        Ok(())
943    } else {
944        Err(anyhow!("{}", errors.join("; ")))
945    }
946}
947
948/// Remove assets for the given network while preserving network hook state.
949pub async fn teardown(layout: &AssetsLayout) -> Result<()> {
950    let net_dir = layout.net_dir();
951    if !is_dir(&net_dir).await {
952        return Ok(());
953    }
954
955    let hooks_dir = layout.hooks_dir();
956    if !is_dir(&hooks_dir).await {
957        tokio_fs::remove_dir_all(&net_dir).await?;
958        return Ok(());
959    }
960
961    let mut entries = tokio_fs::read_dir(&net_dir).await?;
962    while let Some(entry) = entries.next_entry().await? {
963        let path = entry.path();
964        if path == hooks_dir {
965            continue;
966        }
967        let file_type = entry.file_type().await?;
968        if file_type.is_dir() {
969            tokio_fs::remove_dir_all(path).await?;
970        } else {
971            tokio_fs::remove_file(path).await?;
972        }
973    }
974    Ok(())
975}
976
977/// Remove consensus secret keys for the provided node IDs.
978pub async fn remove_consensus_keys(layout: &AssetsLayout, node_ids: &[u32]) -> Result<usize> {
979    let mut removed = 0;
980    for node_id in node_ids {
981        let path = layout.node_dir(*node_id).join("keys").join(SECRET_KEY_PEM);
982        match tokio_fs::remove_file(&path).await {
983            Ok(()) => removed += 1,
984            Err(err) if err.kind() == std::io::ErrorKind::NotFound => {}
985            Err(err) => return Err(err.into()),
986        }
987    }
988    Ok(removed)
989}
990
991/// Ensure consensus secret keys are present for all nodes.
992pub async fn ensure_consensus_keys(layout: &AssetsLayout, seed: Arc<str>) -> Result<usize> {
993    let total_nodes = layout.count_nodes().await?;
994    if total_nodes == 0 {
995        return Ok(0);
996    }
997    let seed_for_root = seed.to_string();
998    let root = spawn_blocking_result(move || unsafe_root_from_seed(&seed_for_root)).await?;
999    let mut restored = 0;
1000
1001    for node_id in 1..=total_nodes {
1002        let key_path = layout.node_dir(node_id).join("keys").join(SECRET_KEY_PEM);
1003        if is_file(&key_path).await {
1004            continue;
1005        }
1006        if let Some(parent) = key_path.parent() {
1007            tokio_fs::create_dir_all(parent).await?;
1008        }
1009        let path =
1010            DerivationPath::from_str(&format!("{}/{}", DERIVATION_PATH_PREFIX, node_id - 1))?;
1011        let secret_key_pem = spawn_blocking_result({
1012            let root = root.clone();
1013            move || {
1014                let child = derive_xprv_from_path(&root, &path)?;
1015                let secret_key = SecretKey::secp256k1_from_bytes(child.to_bytes())?;
1016                Ok(secret_key.to_pem()?)
1017            }
1018        })
1019        .await?;
1020        tokio_fs::write(&key_path, secret_key_pem).await?;
1021        restored += 1;
1022    }
1023
1024    Ok(restored)
1025}
1026
1027/// Prepare filesystem assets for appending managed non-genesis nodes to a live network.
1028pub async fn add_nodes(layout: &AssetsLayout, opts: &AddNodesOptions) -> Result<AddNodesResult> {
1029    add_nodes_with_trusted_hash(layout, opts, None).await
1030}
1031
1032async fn add_nodes_with_trusted_hash(
1033    layout: &AssetsLayout,
1034    opts: &AddNodesOptions,
1035    trusted_hash_override: Option<String>,
1036) -> Result<AddNodesResult> {
1037    if opts.count == 0 {
1038        return Err(anyhow!("count must be greater than 0"));
1039    }
1040
1041    let existing_node_ids = layout.node_ids().await?;
1042    if existing_node_ids.is_empty() {
1043        return Err(anyhow!(
1044            "no nodes found under {}; run start first",
1045            layout.nodes_dir().display()
1046        ));
1047    }
1048    ensure_contiguous_node_ids(&existing_node_ids)?;
1049
1050    let active_version = uniform_active_protocol_version(layout, &existing_node_ids).await?;
1051    let active_version_fs = active_version.to_string().replace('.', "_");
1052    let first_new_node_id = existing_node_ids
1053        .last()
1054        .copied()
1055        .expect("checked non-empty")
1056        .checked_add(1)
1057        .ok_or_else(|| anyhow!("node id overflow"))?;
1058    let total_nodes = existing_node_ids
1059        .len()
1060        .try_into()
1061        .ok()
1062        .and_then(|current: u32| current.checked_add(opts.count))
1063        .ok_or_else(|| anyhow!("node count overflow"))?;
1064    let added_node_ids = (first_new_node_id..=total_nodes).collect::<Vec<_>>();
1065
1066    let source_node_id = existing_node_ids[0];
1067    let mut version_dirs = vec![active_version_fs];
1068    version_dirs.extend(
1069        future_protocol_version_dirs_for_node(layout, source_node_id, &active_version).await?,
1070    );
1071    for version_fs in &version_dirs {
1072        ensure_version_assets(layout, source_node_id, version_fs).await?;
1073    }
1074
1075    let trusted_hash = match trusted_hash_override {
1076        Some(trusted_hash) => trusted_hash,
1077        None => trusted_hash_for_joining_node(&existing_node_ids).await?,
1078    };
1079
1080    for node_id in &added_node_ids {
1081        if let Err(err) = prepare_added_node(
1082            layout,
1083            source_node_id,
1084            *node_id,
1085            total_nodes,
1086            &active_version,
1087            &version_dirs,
1088            &trusted_hash,
1089        )
1090        .await
1091        {
1092            return Err(rollback_added_nodes_after_error(layout, &added_node_ids, err).await);
1093        }
1094    }
1095
1096    if let Err(err) =
1097        append_added_nodes_to_derived_accounts(layout, &added_node_ids, Arc::clone(&opts.seed))
1098            .await
1099    {
1100        return Err(rollback_added_nodes_after_error(layout, &added_node_ids, err).await);
1101    }
1102
1103    Ok(AddNodesResult {
1104        added_node_ids,
1105        total_nodes,
1106    })
1107}
1108
1109pub async fn rollback_added_nodes(layout: &AssetsLayout, node_ids: &[u32]) -> Result<()> {
1110    let mut errors = Vec::new();
1111    for node_id in node_ids {
1112        let node_dir = layout.node_dir(*node_id);
1113        match tokio_fs::remove_dir_all(&node_dir).await {
1114            Ok(()) => {}
1115            Err(err) if err.kind() == std::io::ErrorKind::NotFound => {}
1116            Err(err) => errors.push(format!("failed to remove {}: {}", node_dir.display(), err)),
1117        }
1118    }
1119
1120    if let Err(err) = remove_added_nodes_from_derived_accounts(layout, node_ids).await {
1121        errors.push(err.to_string());
1122    }
1123
1124    if errors.is_empty() {
1125        Ok(())
1126    } else {
1127        Err(anyhow!(errors.join("; ")))
1128    }
1129}
1130
1131async fn rollback_added_nodes_after_error(
1132    layout: &AssetsLayout,
1133    node_ids: &[u32],
1134    err: anyhow::Error,
1135) -> anyhow::Error {
1136    match rollback_added_nodes(layout, node_ids).await {
1137        Ok(()) => err,
1138        Err(rollback_err) => anyhow!(
1139            "{}; failed to roll back added node assets: {}",
1140            err,
1141            rollback_err
1142        ),
1143    }
1144}
1145
1146async fn trusted_hash_for_joining_node(node_ids: &[u32]) -> Result<String> {
1147    let client = reqwest::Client::builder()
1148        .no_proxy()
1149        .timeout(StdDuration::from_secs(4))
1150        .build()?;
1151    let mut errors = Vec::new();
1152
1153    for node_id in node_ids {
1154        let url = format!("{}/status", rest_endpoint(*node_id));
1155        match fetch_trusted_hash(&client, &url).await {
1156            Ok(trusted_hash) => return Ok(trusted_hash),
1157            Err(err) => errors.push(format!("node-{node_id}: {err}")),
1158        }
1159    }
1160
1161    Err(anyhow!(
1162        "failed to get trusted hash from existing node status endpoints: {}",
1163        errors.join("; ")
1164    ))
1165}
1166
1167async fn fetch_trusted_hash(client: &reqwest::Client, url: &str) -> Result<String> {
1168    let status = client
1169        .get(url)
1170        .send()
1171        .await
1172        .map_err(|err| anyhow!("failed to query {url}: {err}"))?
1173        .error_for_status()
1174        .map_err(|err| anyhow!("failed to query {url}: {err}"))?
1175        .json::<serde_json::Value>()
1176        .await
1177        .map_err(|err| anyhow!("failed to parse {url} response: {err}"))?;
1178
1179    status
1180        .pointer("/last_added_block_info/hash")
1181        .and_then(serde_json::Value::as_str)
1182        .or_else(|| {
1183            status
1184                .pointer("/last_added_block_info/block_hash")
1185                .and_then(serde_json::Value::as_str)
1186        })
1187        .map(str::to_string)
1188        .ok_or_else(|| anyhow!("{url} response missing last_added_block_info.hash"))
1189}
1190
1191fn ensure_contiguous_node_ids(node_ids: &[u32]) -> Result<()> {
1192    for (index, node_id) in node_ids.iter().enumerate() {
1193        let expected = (index as u32) + 1;
1194        if *node_id != expected {
1195            return Err(anyhow!(
1196                "node directories must be contiguous from node-1; expected node-{}, found node-{}",
1197                expected,
1198                node_id
1199            ));
1200        }
1201    }
1202    Ok(())
1203}
1204
1205async fn uniform_active_protocol_version(
1206    layout: &AssetsLayout,
1207    node_ids: &[u32],
1208) -> Result<Version> {
1209    let mut active = None;
1210    for node_id in node_ids {
1211        let version = active_protocol_version_for_node(layout, *node_id).await?;
1212        match &active {
1213            Some(active) if active != &version => {
1214                return Err(anyhow!(
1215                    "active protocol versions are mixed: node-1 is {}, node-{} is {}",
1216                    active,
1217                    node_id,
1218                    version
1219                ));
1220            }
1221            Some(_) => {}
1222            None => active = Some(version),
1223        }
1224    }
1225    active.ok_or_else(|| anyhow!("no active protocol version found"))
1226}
1227
1228async fn active_protocol_version_for_node(layout: &AssetsLayout, node_id: u32) -> Result<Version> {
1229    let state_path = layout
1230        .node_config_root(node_id)
1231        .join(NODE_LAUNCHER_STATE_FILE);
1232    let contents = tokio_fs::read_to_string(&state_path)
1233        .await
1234        .map_err(|err| anyhow!("failed to read {}: {}", state_path.display(), err))?;
1235    let value: toml::Value = toml::from_str(&contents)
1236        .map_err(|err| anyhow!("failed to parse {}: {}", state_path.display(), err))?;
1237    let mode = value
1238        .get("mode")
1239        .and_then(toml::Value::as_str)
1240        .ok_or_else(|| anyhow!("{} missing launcher mode", state_path.display()))?;
1241    if mode != "RunNodeAsValidator" {
1242        return Err(anyhow!(
1243            "node-{} is not running as a validator (launcher mode: {}); try again after migration completes",
1244            node_id,
1245            mode
1246        ));
1247    }
1248    let version = value
1249        .get("version")
1250        .and_then(toml::Value::as_str)
1251        .ok_or_else(|| anyhow!("{} missing active version", state_path.display()))?;
1252    parse_protocol_version(version)
1253}
1254
1255async fn ensure_version_assets(
1256    layout: &AssetsLayout,
1257    source_node_id: u32,
1258    version_fs: &str,
1259) -> Result<()> {
1260    let source_bin_dir = layout.node_bin_dir(source_node_id).join(version_fs);
1261    let source_config_dir = layout.node_config_root(source_node_id).join(version_fs);
1262    for path in [
1263        source_bin_dir.join("casper-node"),
1264        source_bin_dir.join("casper-sidecar"),
1265        source_config_dir.join("chainspec.toml"),
1266        source_config_dir.join("accounts.toml"),
1267        source_config_dir.join("config.toml"),
1268        source_config_dir.join("sidecar.toml"),
1269    ] {
1270        if !is_file(&path).await {
1271            return Err(anyhow!("missing active version asset {}", path.display()));
1272        }
1273    }
1274    Ok(())
1275}
1276
1277async fn future_protocol_version_dirs_for_node(
1278    layout: &AssetsLayout,
1279    node_id: u32,
1280    active_version: &Version,
1281) -> Result<Vec<String>> {
1282    let binary_versions = protocol_versions_from_dir(&layout.node_bin_dir(node_id)).await?;
1283    let config_versions = protocol_versions_from_dir(&layout.node_config_root(node_id)).await?;
1284    if binary_versions != config_versions {
1285        return Err(anyhow!(
1286            "installed binary versions ({}) don't match installed configs ({}) for node-{}",
1287            format_protocol_versions(&binary_versions),
1288            format_protocol_versions(&config_versions),
1289            node_id
1290        ));
1291    }
1292
1293    Ok(binary_versions
1294        .into_iter()
1295        .filter(|version| version > active_version)
1296        .map(|version| version.to_string().replace('.', "_"))
1297        .collect())
1298}
1299
1300async fn protocol_versions_from_dir(dir: &Path) -> Result<BTreeSet<Version>> {
1301    let mut versions = BTreeSet::new();
1302    let mut entries = tokio_fs::read_dir(dir).await?;
1303    while let Some(entry) = entries.next_entry().await? {
1304        if !entry.file_type().await?.is_dir() {
1305            continue;
1306        }
1307        let name = entry.file_name().to_string_lossy().replace('_', ".");
1308        if let Ok(version) = Version::parse(&name) {
1309            versions.insert(version);
1310        }
1311    }
1312    Ok(versions)
1313}
1314
1315fn format_protocol_versions(versions: &BTreeSet<Version>) -> String {
1316    versions
1317        .iter()
1318        .map(Version::to_string)
1319        .collect::<Vec<_>>()
1320        .join(", ")
1321}
1322
1323async fn prepare_added_node(
1324    layout: &AssetsLayout,
1325    source_node_id: u32,
1326    node_id: u32,
1327    total_nodes: u32,
1328    active_version: &Version,
1329    version_dirs: &[String],
1330    trusted_hash: &str,
1331) -> Result<()> {
1332    let node_dir = layout.node_dir(node_id);
1333
1334    tokio_fs::create_dir_all(node_dir.join("logs")).await?;
1335    tokio_fs::create_dir_all(node_dir.join("storage")).await?;
1336
1337    for version_fs in version_dirs {
1338        prepare_added_node_version(
1339            layout,
1340            source_node_id,
1341            node_id,
1342            total_nodes,
1343            version_fs,
1344            trusted_hash,
1345        )
1346        .await?;
1347    }
1348
1349    write_added_node_launcher_state(layout, node_id, active_version).await?;
1350    Ok(())
1351}
1352
1353#[derive(Serialize)]
1354struct AddedNodeLauncherState {
1355    mode: &'static str,
1356    version: String,
1357    binary_path: PathBuf,
1358    config_path: PathBuf,
1359}
1360
1361async fn write_added_node_launcher_state(
1362    layout: &AssetsLayout,
1363    node_id: u32,
1364    active_version: &Version,
1365) -> Result<()> {
1366    let active_version_fs = active_version.to_string().replace('.', "_");
1367    let state = AddedNodeLauncherState {
1368        mode: "RunNodeAsValidator",
1369        version: active_version.to_string(),
1370        binary_path: layout
1371            .node_bin_dir(node_id)
1372            .join(&active_version_fs)
1373            .join("casper-node"),
1374        config_path: layout
1375            .node_config_root(node_id)
1376            .join(&active_version_fs)
1377            .join("config.toml"),
1378    };
1379    let contents = toml::to_string_pretty(&state)?;
1380    tokio_fs::write(
1381        layout
1382            .node_config_root(node_id)
1383            .join(NODE_LAUNCHER_STATE_FILE),
1384        contents,
1385    )
1386    .await?;
1387    Ok(())
1388}
1389
1390async fn prepare_added_node_version(
1391    layout: &AssetsLayout,
1392    source_node_id: u32,
1393    node_id: u32,
1394    total_nodes: u32,
1395    version_fs: &str,
1396    trusted_hash: &str,
1397) -> Result<()> {
1398    let source_bin_dir = layout.node_bin_dir(source_node_id).join(version_fs);
1399    let source_config_dir = layout.node_config_root(source_node_id).join(version_fs);
1400    let dest_bin_dir = layout.node_bin_dir(node_id).join(version_fs);
1401    let dest_config_dir = layout.node_config_root(node_id).join(version_fs);
1402
1403    tokio_fs::create_dir_all(&dest_bin_dir).await?;
1404    tokio_fs::create_dir_all(&dest_config_dir).await?;
1405
1406    copy_file(
1407        &source_bin_dir.join("casper-node"),
1408        &dest_bin_dir.join("casper-node"),
1409    )
1410    .await?;
1411    copy_file(
1412        &source_bin_dir.join("casper-sidecar"),
1413        &dest_bin_dir.join("casper-sidecar"),
1414    )
1415    .await?;
1416    copy_file(
1417        &source_config_dir.join("chainspec.toml"),
1418        &dest_config_dir.join("chainspec.toml"),
1419    )
1420    .await?;
1421    copy_file(
1422        &source_config_dir.join("accounts.toml"),
1423        &dest_config_dir.join("accounts.toml"),
1424    )
1425    .await?;
1426    copy_file(
1427        &source_config_dir.join("config.toml"),
1428        &dest_config_dir.join("config.toml"),
1429    )
1430    .await?;
1431    copy_file(
1432        &source_config_dir.join("sidecar.toml"),
1433        &dest_config_dir.join("sidecar.toml"),
1434    )
1435    .await?;
1436
1437    rewrite_added_node_config(
1438        layout,
1439        node_id,
1440        total_nodes,
1441        &dest_config_dir.join("config.toml"),
1442        trusted_hash,
1443    )
1444    .await?;
1445    rewrite_added_sidecar_config(node_id, &dest_config_dir.join("sidecar.toml")).await?;
1446    Ok(())
1447}
1448
1449async fn rewrite_added_node_config(
1450    layout: &AssetsLayout,
1451    node_id: u32,
1452    total_nodes: u32,
1453    config_path: &Path,
1454    trusted_hash: &str,
1455) -> Result<()> {
1456    let config_contents = tokio_fs::read_to_string(config_path).await?;
1457    let bind_address = format!("0.0.0.0:{}", node_port(DEVNET_BASE_PORT_NETWORK, node_id));
1458    let known = known_addresses(node_id, total_nodes);
1459    let rest_address = format!("0.0.0.0:{}", node_port(DEVNET_BASE_PORT_REST, node_id));
1460    let sse_address = format!("0.0.0.0:{}", node_port(DEVNET_BASE_PORT_SSE, node_id));
1461    let binary_address = format!("0.0.0.0:{}", node_port(DEVNET_BASE_PORT_BINARY, node_id));
1462    let diagnostics_socket = diagnostics_socket_path(layout.network_name(), node_id);
1463    let trusted_hash = trusted_hash.to_string();
1464    let updated_config = spawn_blocking_result(move || {
1465        let mut config_value: toml::Value = toml::from_str(&config_contents)?;
1466        set_string(&mut config_value, &["node", "trusted_hash"], trusted_hash)?;
1467        set_joining_sync_mode(&mut config_value)?;
1468        set_string(
1469            &mut config_value,
1470            &["consensus", "secret_key_path"],
1471            "../../keys/secret_key.pem".to_string(),
1472        )?;
1473        set_string(
1474            &mut config_value,
1475            &["network", "bind_address"],
1476            bind_address,
1477        )?;
1478        set_array(&mut config_value, &["network", "known_addresses"], known)?;
1479        set_string(
1480            &mut config_value,
1481            &["storage", "path"],
1482            "../../storage".to_string(),
1483        )?;
1484        set_string(&mut config_value, &["rest_server", "address"], rest_address)?;
1485        set_string(
1486            &mut config_value,
1487            &["event_stream_server", "address"],
1488            sse_address,
1489        )?;
1490        set_string(
1491            &mut config_value,
1492            &["diagnostics_port", "socket_path"],
1493            diagnostics_socket,
1494        )?;
1495        set_string(
1496            &mut config_value,
1497            &["binary_port_server", "address"],
1498            binary_address,
1499        )?;
1500        set_bool(
1501            &mut config_value,
1502            &["binary_port_server", "allow_request_get_trie"],
1503            true,
1504        )?;
1505        set_bool(
1506            &mut config_value,
1507            &["binary_port_server", "allow_request_speculative_exec"],
1508            true,
1509        )?;
1510        Ok(toml::to_string(&config_value)?)
1511    })
1512    .await?;
1513    tokio_fs::write(config_path, updated_config).await?;
1514    Ok(())
1515}
1516
1517async fn rewrite_added_sidecar_config(node_id: u32, sidecar_path: &Path) -> Result<()> {
1518    let sidecar_contents = tokio_fs::read_to_string(sidecar_path).await?;
1519    let rpc_port = node_port(DEVNET_BASE_PORT_RPC, node_id) as i64;
1520    let binary_port = node_port(DEVNET_BASE_PORT_BINARY, node_id) as i64;
1521    let updated_sidecar = spawn_blocking_result(move || {
1522        let mut sidecar_value: toml::Value = toml::from_str(&sidecar_contents)?;
1523        set_string(
1524            &mut sidecar_value,
1525            &["rpc_server", "main_server", "ip_address"],
1526            "0.0.0.0".to_string(),
1527        )?;
1528        set_integer(
1529            &mut sidecar_value,
1530            &["rpc_server", "main_server", "port"],
1531            rpc_port,
1532        )?;
1533        set_string(
1534            &mut sidecar_value,
1535            &["rpc_server", "node_client", "ip_address"],
1536            "0.0.0.0".to_string(),
1537        )?;
1538        set_integer(
1539            &mut sidecar_value,
1540            &["rpc_server", "node_client", "port"],
1541            binary_port,
1542        )?;
1543        Ok(toml::to_string(&sidecar_value)?)
1544    })
1545    .await?;
1546    tokio_fs::write(sidecar_path, updated_sidecar).await?;
1547    Ok(())
1548}
1549
1550async fn append_added_nodes_to_derived_accounts(
1551    layout: &AssetsLayout,
1552    node_ids: &[u32],
1553    seed: Arc<str>,
1554) -> Result<()> {
1555    let mut lines = Vec::new();
1556    for node_id in node_ids {
1557        let account = derive_node_account(Arc::clone(&seed), *node_id, false).await?;
1558        lines.push(
1559            DerivedAccountInfo {
1560                kind: "node",
1561                name: format!("node-{}", node_id),
1562                id: *node_id,
1563                path: account.path,
1564                public_key_hex: account.public_key_hex,
1565                account_hash: account.account_hash,
1566                balance_motes: 0,
1567            }
1568            .line(),
1569        );
1570    }
1571
1572    let path = derived_accounts_path(layout);
1573    let mut contents = match tokio_fs::read_to_string(&path).await {
1574        Ok(contents) => {
1575            let trimmed = contents.trim_end();
1576            if trimmed.is_empty() {
1577                "kind,name,key_type,derivation,path,account_hash,balance".to_string()
1578            } else {
1579                trimmed.to_string()
1580            }
1581        }
1582        Err(err) if err.kind() == std::io::ErrorKind::NotFound => {
1583            "kind,name,key_type,derivation,path,account_hash,balance".to_string()
1584        }
1585        Err(err) => return Err(err.into()),
1586    };
1587    for line in lines {
1588        contents.push('\n');
1589        contents.push_str(&line);
1590    }
1591    contents.push('\n');
1592    tokio_fs::write(path, contents).await?;
1593    Ok(())
1594}
1595
1596async fn remove_added_nodes_from_derived_accounts(
1597    layout: &AssetsLayout,
1598    node_ids: &[u32],
1599) -> Result<()> {
1600    let path = derived_accounts_path(layout);
1601    let contents = match tokio_fs::read_to_string(&path).await {
1602        Ok(contents) => contents,
1603        Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(()),
1604        Err(err) => return Err(err.into()),
1605    };
1606    let node_names = node_ids
1607        .iter()
1608        .map(|node_id| format!("node-{}", node_id))
1609        .collect::<Vec<_>>();
1610    let mut retained = Vec::new();
1611    for line in contents.lines() {
1612        if !is_added_node_account_line(line, &node_names) {
1613            retained.push(line);
1614        }
1615    }
1616
1617    let mut updated = retained.join("\n");
1618    if !updated.is_empty() {
1619        updated.push('\n');
1620    }
1621    tokio_fs::write(path, updated).await?;
1622    Ok(())
1623}
1624
1625fn is_added_node_account_line(line: &str, node_names: &[String]) -> bool {
1626    let mut parts = line.splitn(3, ',');
1627    matches!(
1628        (parts.next(), parts.next()),
1629        (Some("node"), Some(name)) if node_names.iter().any(|candidate| candidate == name)
1630    )
1631}
1632
1633pub fn parse_protocol_version(raw: &str) -> Result<Version> {
1634    let trimmed = raw.trim();
1635    let normalized = trimmed.strip_prefix('v').unwrap_or(trimmed);
1636    Version::parse(normalized).map_err(|err| anyhow!("invalid protocol version {}: {}", raw, err))
1637}
1638
1639fn bundle_version_dir(bundle_root: &Path, protocol_version: &Version) -> PathBuf {
1640    bundle_root.join(format!("v{}", protocol_version))
1641}
1642
1643async fn bundle_dir_for_version(bundle_root: &Path, protocol_version: &Version) -> Result<PathBuf> {
1644    let version_dir = bundle_version_dir(bundle_root, protocol_version);
1645    if is_dir(&version_dir).await {
1646        return Ok(version_dir);
1647    }
1648    Err(anyhow!("assets bundle missing {}", version_dir.display()))
1649}
1650
1651pub async fn has_bundle_version(protocol_version: &Version) -> Result<bool> {
1652    let bundle_root = assets_bundle_root()?;
1653    Ok(is_dir(&bundle_version_dir(&bundle_root, protocol_version)).await)
1654}
1655
1656async fn extract_assets_bundle(bundle_path: &Path, bundle_root: &Path) -> Result<()> {
1657    if !is_file(bundle_path).await {
1658        return Err(anyhow!("missing assets bundle {}", bundle_path.display()));
1659    }
1660
1661    let bundle_path = bundle_path.to_path_buf();
1662    let bundle_root = bundle_root.to_path_buf();
1663    spawn_blocking_result(move || {
1664        std::fs::create_dir_all(&bundle_root)?;
1665        let file = File::open(&bundle_path)?;
1666        let decoder = GzDecoder::new(file);
1667        let mut archive = Archive::new(decoder);
1668        println!("unpacking assets into {}", bundle_root.display());
1669        archive.unpack(&bundle_root)?;
1670        Ok(())
1671    })
1672    .await
1673}
1674
1675pub async fn install_assets_bundle(bundle_path: &Path) -> Result<()> {
1676    let bundle_root = assets_bundle_root()?;
1677    println!(
1678        "unpacking local assets bundle {} into {}",
1679        bundle_path.display(),
1680        bundle_root.display()
1681    );
1682    extract_assets_bundle(bundle_path, &bundle_root).await
1683}
1684
1685pub async fn pull_assets_bundles(target_override: Option<&str>, force: bool) -> Result<()> {
1686    let bundle_root = assets_bundle_root()?;
1687    let target = target_override
1688        .map(str::to_string)
1689        .unwrap_or_else(default_target);
1690    println!("assets pull target: {}", target);
1691    let release = fetch_latest_release().await?;
1692    println!("release tag: {}", release.tag_name);
1693
1694    let mut assets = Vec::new();
1695    for asset in release.assets {
1696        if let Some(version) = parse_release_asset_version(&asset.name, &target) {
1697            assets.push(ReleaseAsset {
1698                url: asset.browser_download_url,
1699                version,
1700            });
1701        }
1702    }
1703
1704    if assets.is_empty() {
1705        return Err(anyhow!(
1706            "no assets found for target {} in release {}",
1707            target,
1708            release.tag_name
1709        ));
1710    }
1711
1712    for asset in assets {
1713        let bytes = download_asset(&asset.url, &asset.version).await?;
1714        let expected_hash = download_asset_sha512(&asset.url).await?;
1715        let actual_hash = sha512_hex(&bytes);
1716        if expected_hash != actual_hash {
1717            return Err(anyhow!(
1718                "sha512 mismatch for {} (expected {}, got {})",
1719                asset.url,
1720                expected_hash,
1721                actual_hash
1722            ));
1723        }
1724        let remote_manifest = extract_manifest_from_bytes(&bytes).await?;
1725        let version_dir = bundle_version_dir(&bundle_root, &asset.version);
1726        let local_manifest = read_local_manifest(&version_dir).await?;
1727
1728        if !force
1729            && let (Some(remote), Some(local)) = (&remote_manifest, &local_manifest)
1730            && remote == local
1731        {
1732            println!("already have this file v{}", asset.version);
1733            continue;
1734        }
1735
1736        if is_dir(&version_dir).await {
1737            tokio_fs::remove_dir_all(&version_dir).await?;
1738        }
1739
1740        println!("saving assets bundle v{}", asset.version);
1741        unpack_assets_with_progress(&bytes, &bundle_root, &asset.version).await?;
1742    }
1743
1744    tokio_fs::write(bundle_root.join("latest"), release.tag_name).await?;
1745    Ok(())
1746}
1747
1748pub async fn most_recent_bundle_version() -> Result<Option<Version>> {
1749    let mut versions = list_bundle_versions().await?;
1750    versions.sort();
1751    Ok(versions.pop())
1752}
1753
1754pub async fn list_bundle_versions() -> Result<Vec<Version>> {
1755    let bundle_root = assets_bundle_root()?;
1756    if !is_dir(&bundle_root).await {
1757        return Ok(Vec::new());
1758    }
1759    let mut versions: Vec<Version> = Vec::new();
1760    let mut entries = tokio_fs::read_dir(&bundle_root).await?;
1761    while let Some(entry) = entries.next_entry().await? {
1762        if !entry.file_type().await?.is_dir() {
1763            continue;
1764        }
1765        let name = entry.file_name().to_string_lossy().to_string();
1766        if !name.starts_with('v') {
1767            continue;
1768        }
1769        let dir_version = match parse_protocol_version(&name) {
1770            Ok(version) => version,
1771            Err(err) => {
1772                eprintln!("warning: skipping assets bundle {}: {}", name, err);
1773                continue;
1774            }
1775        };
1776        let dir_path = entry.path();
1777        let chainspec_path = dir_path.join("chainspec.toml");
1778        if !is_file(&chainspec_path).await {
1779            continue;
1780        }
1781        let contents = tokio_fs::read_to_string(&chainspec_path).await?;
1782        let chainspec_version =
1783            spawn_blocking_result(move || parse_chainspec_version(&contents)).await?;
1784        if chainspec_version != dir_version {
1785            eprintln!(
1786                "warning: bundle directory {} does not match chainspec protocol version {}; using directory version {}",
1787                name, chainspec_version, dir_version
1788            );
1789        }
1790        versions.push(dir_version);
1791    }
1792    Ok(versions)
1793}
1794
1795pub async fn list_custom_asset_names() -> Result<Vec<String>> {
1796    let root = custom_assets_root()?;
1797    if !is_dir(&root).await {
1798        return Ok(Vec::new());
1799    }
1800
1801    let mut names = Vec::new();
1802    let mut entries = tokio_fs::read_dir(&root).await?;
1803    while let Some(entry) = entries.next_entry().await? {
1804        if !entry.file_type().await?.is_dir() {
1805            continue;
1806        }
1807        let name = entry.file_name().to_string_lossy().to_string();
1808        if validate_custom_asset_name(&name).is_ok() {
1809            names.push(name);
1810        }
1811    }
1812    names.sort();
1813    Ok(names)
1814}
1815
1816async fn resolve_asset(selector: &AssetSelector) -> Result<ResolvedAsset> {
1817    match selector {
1818        AssetSelector::LatestVersioned => {
1819            let version = most_recent_bundle_version()
1820                .await?
1821                .ok_or_else(|| anyhow!("no assets bundles found"))?;
1822            resolve_versioned_asset(&version).await
1823        }
1824        AssetSelector::Versioned(raw) => {
1825            let version = parse_protocol_version(raw)?;
1826            resolve_versioned_asset(&version).await
1827        }
1828        AssetSelector::Custom(name) => resolve_custom_asset(name).await,
1829    }
1830}
1831
1832async fn resolve_versioned_asset(version: &Version) -> Result<ResolvedAsset> {
1833    let bundle_root = assets_bundle_root()?;
1834    let bundle_dir = bundle_dir_for_version(&bundle_root, version).await?;
1835    let casper_node =
1836        canonicalize_required_file(&bundle_dir.join("bin").join("casper-node"), "casper-node")
1837            .await?;
1838    let casper_sidecar = canonicalize_required_file(
1839        &bundle_dir.join("bin").join("casper-sidecar"),
1840        "casper-sidecar",
1841    )
1842    .await?;
1843    let chainspec =
1844        canonicalize_required_file(&bundle_dir.join("chainspec.toml"), "chainspec").await?;
1845    let node_config =
1846        canonicalize_required_file(&bundle_dir.join("node-config.toml"), "node-config").await?;
1847    let sidecar_config =
1848        canonicalize_required_file(&bundle_dir.join("sidecar-config.toml"), "sidecar-config")
1849            .await?;
1850    let chainspec_protocol_version = parse_chainspec_file(&chainspec).await?;
1851
1852    Ok(ResolvedAsset {
1853        display_name: version.to_string(),
1854        source_kind: AssetSourceKind::Versioned,
1855        casper_node,
1856        casper_sidecar,
1857        chainspec,
1858        node_config,
1859        sidecar_config,
1860        chainspec_protocol_version,
1861    })
1862}
1863
1864async fn resolve_custom_asset(name: &str) -> Result<ResolvedAsset> {
1865    let custom_asset = load_custom_asset(name).await?;
1866    let chainspec_protocol_version = parse_chainspec_file(&custom_asset.chainspec).await?;
1867
1868    Ok(ResolvedAsset {
1869        display_name: format!("custom/{name}"),
1870        source_kind: AssetSourceKind::Custom,
1871        casper_node: custom_asset.casper_node,
1872        casper_sidecar: custom_asset.casper_sidecar,
1873        chainspec: custom_asset.chainspec,
1874        node_config: custom_asset.node_config,
1875        sidecar_config: custom_asset.sidecar_config,
1876        chainspec_protocol_version,
1877    })
1878}
1879
1880async fn parse_chainspec_file(path: &Path) -> Result<Version> {
1881    let contents = tokio_fs::read_to_string(path).await?;
1882    spawn_blocking_result(move || parse_chainspec_version(&contents)).await
1883}
1884
1885fn parse_chainspec_version(contents: &str) -> Result<Version> {
1886    let value: toml::Value = toml::from_str(contents)?;
1887    let protocol = value
1888        .get("protocol")
1889        .and_then(|v| v.as_table())
1890        .ok_or_else(|| anyhow!("chainspec missing [protocol] section"))?;
1891    let version = protocol
1892        .get("version")
1893        .and_then(|v| v.as_str())
1894        .ok_or_else(|| anyhow!("chainspec missing protocol.version"))?;
1895    parse_protocol_version(version)
1896}
1897
1898#[derive(Deserialize)]
1899struct GithubRelease {
1900    tag_name: String,
1901    assets: Vec<GithubAsset>,
1902}
1903
1904#[derive(Deserialize)]
1905struct GithubAsset {
1906    name: String,
1907    browser_download_url: String,
1908}
1909
1910struct ReleaseAsset {
1911    url: String,
1912    version: Version,
1913}
1914
1915async fn fetch_latest_release() -> Result<GithubRelease> {
1916    let client = reqwest::Client::builder()
1917        .user_agent("casper-devnet")
1918        .build()?;
1919    let url = "https://api.github.com/repos/veles-labs/devnet-launcher-assets/releases/latest";
1920    println!("GET {}", url);
1921    let response = client.get(url).send().await?.error_for_status()?;
1922    Ok(response.json::<GithubRelease>().await?)
1923}
1924
1925fn parse_release_asset_version(name: &str, target: &str) -> Option<Version> {
1926    let trimmed = name.strip_prefix("casper-v")?;
1927    let trimmed = trimmed.strip_suffix(".tar.gz")?;
1928    let (version, asset_target) = trimmed.split_once('-')?;
1929    if asset_target != target {
1930        return None;
1931    }
1932    parse_protocol_version(version).ok()
1933}
1934
1935fn download_progress_style() -> ProgressStyle {
1936    ProgressStyle::with_template("{msg} {bar:40.cyan/blue} {bytes:>7}/{total_bytes:7} ({eta})")
1937        .expect("valid download progress template")
1938        .progress_chars("█▉▊▋▌▍▎▏ ")
1939}
1940
1941fn download_spinner_style() -> ProgressStyle {
1942    ProgressStyle::with_template("{msg} {spinner:.cyan} {bytes:>7}")
1943        .expect("valid download spinner template")
1944        .tick_chars("⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏")
1945}
1946
1947fn unpack_spinner_style() -> ProgressStyle {
1948    ProgressStyle::with_template("{msg} {spinner:.magenta} {elapsed_precise}")
1949        .expect("valid unpack spinner template")
1950        .tick_chars("⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏")
1951}
1952
1953async fn download_asset(url: &str, version: &Version) -> Result<Vec<u8>> {
1954    let client = reqwest::Client::builder()
1955        .user_agent("casper-devnet")
1956        .build()?;
1957    println!("GET {}", url);
1958    let response = client.get(url).send().await?.error_for_status()?;
1959    let total = response.content_length();
1960    let pb = match total {
1961        Some(total) if total > 0 => {
1962            let pb = ProgressBar::new(total);
1963            pb.set_style(download_progress_style());
1964            pb
1965        }
1966        _ => {
1967            let pb = ProgressBar::new_spinner();
1968            pb.set_style(download_spinner_style());
1969            pb.enable_steady_tick(StdDuration::from_millis(PROGRESS_TICK_MS));
1970            pb
1971        }
1972    };
1973    pb.set_message(format!("⬇️  v{} download", version));
1974
1975    let mut bytes = Vec::new();
1976    if let Some(total) = total
1977        && total <= usize::MAX as u64
1978    {
1979        bytes.reserve(total as usize);
1980    }
1981
1982    let mut stream = response.bytes_stream();
1983    while let Some(chunk) = stream.next().await {
1984        match chunk {
1985            Ok(chunk) => {
1986                pb.inc(chunk.len() as u64);
1987                bytes.extend_from_slice(&chunk);
1988            }
1989            Err(err) => {
1990                pb.finish_with_message(format!("❌  v{} download failed", version));
1991                return Err(err.into());
1992            }
1993        }
1994    }
1995
1996    pb.finish_with_message(format!("✅  v{} downloaded", version));
1997    Ok(bytes)
1998}
1999
2000async fn download_asset_sha512(url: &str) -> Result<String> {
2001    let sha_url = format!("{url}.sha512");
2002    let client = reqwest::Client::builder()
2003        .user_agent("casper-devnet")
2004        .build()?;
2005    println!("GET {}", sha_url);
2006    let response = client.get(sha_url).send().await?.error_for_status()?;
2007    let text = response.text().await?;
2008    parse_sha512(&text)
2009}
2010
2011fn parse_sha512(text: &str) -> Result<String> {
2012    let token = text
2013        .split_whitespace()
2014        .next()
2015        .ok_or_else(|| anyhow!("invalid sha512 file contents"))?;
2016    let token = token.trim();
2017    if token.len() != 128 || !token.chars().all(|c| c.is_ascii_hexdigit()) {
2018        return Err(anyhow!("invalid sha512 hash {}", token));
2019    }
2020    Ok(token.to_lowercase())
2021}
2022
2023fn sha512_hex(bytes: &[u8]) -> String {
2024    let digest = Sha512::digest(bytes);
2025    let mut out = String::with_capacity(digest.len() * 2);
2026    for byte in digest {
2027        use std::fmt::Write;
2028        let _ = write!(&mut out, "{:02x}", byte);
2029    }
2030    out
2031}
2032
2033async fn extract_manifest_from_bytes(bytes: &[u8]) -> Result<Option<serde_json::Value>> {
2034    let bytes = bytes.to_vec();
2035    spawn_blocking_result(move || {
2036        let cursor = Cursor::new(bytes);
2037        let decoder = GzDecoder::new(cursor);
2038        let mut archive = Archive::new(decoder);
2039        let entries = archive.entries()?;
2040        for entry in entries {
2041            let mut entry = entry?;
2042            let path = entry.path()?;
2043            if path.file_name() == Some(OsStr::new("manifest.json")) {
2044                let mut contents = String::new();
2045                entry.read_to_string(&mut contents)?;
2046                let value = serde_json::from_str(&contents)?;
2047                return Ok(Some(value));
2048            }
2049        }
2050        Ok(None)
2051    })
2052    .await
2053}
2054
2055async fn read_local_manifest(version_dir: &Path) -> Result<Option<serde_json::Value>> {
2056    let path = version_dir.join("manifest.json");
2057    if !is_file(&path).await {
2058        return Ok(None);
2059    }
2060    let contents = tokio_fs::read_to_string(&path).await?;
2061    let value = serde_json::from_str(&contents)?;
2062    Ok(Some(value))
2063}
2064
2065async fn unpack_assets_with_progress(
2066    bytes: &[u8],
2067    bundle_root: &Path,
2068    version: &Version,
2069) -> Result<()> {
2070    let pb = ProgressBar::new_spinner();
2071    pb.set_style(unpack_spinner_style());
2072    pb.set_message(format!("📦  v{} unpack", version));
2073    pb.enable_steady_tick(StdDuration::from_millis(PROGRESS_TICK_MS));
2074
2075    let result = extract_assets_from_bytes(bytes, bundle_root).await;
2076    match result {
2077        Ok(()) => pb.finish_with_message(format!("✅  v{} unpacked", version)),
2078        Err(_) => pb.finish_with_message(format!("❌  v{} unpack failed", version)),
2079    }
2080    result
2081}
2082
2083async fn extract_assets_from_bytes(bytes: &[u8], bundle_root: &Path) -> Result<()> {
2084    let bytes = bytes.to_vec();
2085    let bundle_root = bundle_root.to_path_buf();
2086    spawn_blocking_result(move || {
2087        std::fs::create_dir_all(&bundle_root)?;
2088        let cursor = Cursor::new(bytes);
2089        let decoder = GzDecoder::new(cursor);
2090        let mut archive = Archive::new(decoder);
2091        archive.unpack(&bundle_root)?;
2092        Ok(())
2093    })
2094    .await
2095}
2096
2097fn default_target() -> String {
2098    env!("BUILD_TARGET").to_string()
2099}
2100
2101async fn setup_directories(
2102    layout: &AssetsLayout,
2103    total_nodes: u32,
2104    protocol_version_fs: &str,
2105) -> Result<()> {
2106    let net_dir = layout.net_dir();
2107    let chainspec_dir = net_dir.join("chainspec");
2108    let nodes_dir = net_dir.join("nodes");
2109
2110    tokio_fs::create_dir_all(chainspec_dir).await?;
2111    tokio_fs::create_dir_all(&nodes_dir).await?;
2112
2113    for node_id in 1..=total_nodes {
2114        let node_dir = layout.node_dir(node_id);
2115        tokio_fs::create_dir_all(node_dir.join("bin").join(protocol_version_fs)).await?;
2116        tokio_fs::create_dir_all(node_dir.join("config").join(protocol_version_fs)).await?;
2117        tokio_fs::create_dir_all(node_dir.join("logs")).await?;
2118        tokio_fs::create_dir_all(node_dir.join("storage")).await?;
2119    }
2120
2121    Ok(())
2122}
2123
2124async fn setup_binaries(
2125    layout: &AssetsLayout,
2126    total_nodes: u32,
2127    asset: &ResolvedAsset,
2128    protocol_version_fs: &str,
2129) -> Result<()> {
2130    for node_id in 1..=total_nodes {
2131        let node_bin_dir = layout.node_bin_dir(node_id);
2132        let version_dir = node_bin_dir.join(protocol_version_fs);
2133
2134        let node_dest = version_dir.join("casper-node");
2135        install_binary_file(asset, &asset.casper_node, &node_dest).await?;
2136
2137        let sidecar_dest = version_dir.join("casper-sidecar");
2138        install_binary_file(asset, &asset.casper_sidecar, &sidecar_dest).await?;
2139    }
2140
2141    Ok(())
2142}
2143
2144async fn install_binary_file(asset: &ResolvedAsset, src: &Path, dest: &Path) -> Result<()> {
2145    match asset.source_kind {
2146        AssetSourceKind::Versioned => hardlink_file(src, dest).await,
2147        AssetSourceKind::Custom => symlink_file(src, dest).await,
2148    }
2149}
2150
2151async fn preflight_resolved_asset(asset: &ResolvedAsset) -> Result<()> {
2152    verify_binary_version(&asset.casper_node, "casper-node").await?;
2153    verify_binary_version(&asset.casper_sidecar, "casper-sidecar").await?;
2154    Ok(())
2155}
2156
2157async fn verify_binary_version(path: &Path, label: &str) -> Result<()> {
2158    let output = Command::new(path).arg("--version").output().await?;
2159    if output.status.success() {
2160        return Ok(());
2161    }
2162    let stderr = String::from_utf8_lossy(&output.stderr);
2163    let stdout = String::from_utf8_lossy(&output.stdout);
2164    Err(anyhow!(
2165        "{} --version failed (status={}): {}{}",
2166        label,
2167        output.status,
2168        stdout,
2169        stderr
2170    ))
2171}
2172
2173///
2174/// Derive an unsafe deterministic root key from an arbitrary seed string.
2175///
2176/// DEVNET ONLY, NOT BIP-39, NOT WALLET-COMPATIBLE.
2177///
2178fn unsafe_root_from_seed(seed: &str) -> Result<XPrv> {
2179    if seed.is_empty() {
2180        return Err(anyhow!("seed must not be empty"));
2181    }
2182    let mut hasher = Blake2bVar::new(32).map_err(|_| anyhow!("invalid blake2b output size"))?;
2183    hasher.update(DEVNET_SEED_DOMAIN);
2184    hasher.update(seed.as_bytes());
2185
2186    let mut entropy = [0u8; 32];
2187    hasher
2188        .finalize_variable(&mut entropy)
2189        .map_err(|_| anyhow!("failed to finalize blake2b"))?;
2190
2191    Ok(XPrv::new(entropy)?)
2192}
2193
2194fn derive_xprv_from_path(root: &XPrv, path: &DerivationPath) -> Result<XPrv> {
2195    let mut key = root.clone();
2196    for child in path.iter() {
2197        key = key.derive_child(child)?;
2198    }
2199    Ok(key)
2200}
2201
2202///
2203/// Derive a single Casper account from a given root and derivation path.
2204///
2205fn derive_account_material(
2206    root: &XPrv,
2207    path: &DerivationPath,
2208    write_secret: bool,
2209) -> Result<DerivedAccountMaterial> {
2210    let child = derive_xprv_from_path(root, path)?;
2211    let secret_key = SecretKey::secp256k1_from_bytes(child.to_bytes())?;
2212    let public_key = PublicKey::from(&secret_key);
2213    let public_key_hex = public_key.to_hex();
2214    let account_hash = AccountHash::from(&public_key).to_hex_string();
2215    let secret_key_pem = if write_secret {
2216        Some(secret_key.to_pem()?)
2217    } else {
2218        None
2219    };
2220
2221    Ok(DerivedAccountMaterial {
2222        path: path.clone(),
2223        public_key_hex,
2224        account_hash,
2225        secret_key_pem,
2226    })
2227}
2228
2229async fn derive_node_account(
2230    seed: Arc<str>,
2231    node_id: u32,
2232    write_secret: bool,
2233) -> Result<DerivedAccountMaterial> {
2234    let seed_for_root = seed.to_string();
2235    spawn_blocking_result(move || {
2236        let root = unsafe_root_from_seed(&seed_for_root)?;
2237        let path =
2238            DerivationPath::from_str(&format!("{}/{}", DERIVATION_PATH_PREFIX, node_id - 1))?;
2239        derive_account_material(&root, &path, write_secret)
2240    })
2241    .await
2242}
2243
2244async fn setup_seeded_keys(
2245    layout: &AssetsLayout,
2246    total_nodes: u32,
2247    users: u32,
2248    seed: Arc<str>,
2249) -> Result<DerivedAccounts> {
2250    let seed_for_root = seed.to_string();
2251    let root = spawn_blocking_result(move || unsafe_root_from_seed(&seed_for_root)).await?;
2252    let mut summary = Vec::new();
2253    let mut derived = DerivedAccounts {
2254        nodes: Vec::new(),
2255        users: Vec::new(),
2256    };
2257
2258    for node_id in 1..=total_nodes {
2259        let path =
2260            DerivationPath::from_str(&format!("{}/{}", DERIVATION_PATH_PREFIX, node_id - 1))?;
2261        let account = spawn_blocking_result({
2262            let root = root.clone();
2263            let path = path.clone();
2264            move || derive_account_material(&root, &path, false)
2265        })
2266        .await?;
2267
2268        let info = DerivedAccountInfo {
2269            kind: "validator",
2270            name: format!("node-{}", node_id),
2271            id: node_id,
2272            path: account.path.clone(),
2273            public_key_hex: account.public_key_hex.clone(),
2274            account_hash: account.account_hash.clone(),
2275            balance_motes: DEVNET_INITIAL_BALANCE_VALIDATOR,
2276        };
2277        summary.push(info.clone());
2278        derived.nodes.push(info);
2279    }
2280
2281    for user_id in 1..=users {
2282        let path = DerivationPath::from_str(&format!(
2283            "{}/{}",
2284            DERIVATION_PATH_PREFIX,
2285            USER_DERIVATION_START + user_id - 1
2286        ))?;
2287        let account = spawn_blocking_result({
2288            let root = root.clone();
2289            let path = path.clone();
2290            move || derive_account_material(&root, &path, false)
2291        })
2292        .await?;
2293        let info = DerivedAccountInfo {
2294            kind: "user",
2295            name: format!("user-{}", user_id),
2296            id: user_id,
2297            path: account.path.clone(),
2298            public_key_hex: account.public_key_hex.clone(),
2299            account_hash: account.account_hash.clone(),
2300            balance_motes: DEVNET_INITIAL_BALANCE_USER,
2301        };
2302        summary.push(info.clone());
2303        derived.users.push(info);
2304    }
2305
2306    write_derived_accounts_summary(layout, &summary).await?;
2307
2308    Ok(derived)
2309}
2310
2311async fn setup_chainspec(
2312    layout: &AssetsLayout,
2313    total_nodes: u32,
2314    chainspec_template: &Path,
2315    delay_seconds: u64,
2316    protocol_version_chain: &str,
2317    network_name: &str,
2318    chainspec_overrides: &[String],
2319) -> Result<()> {
2320    let chainspec_dest = layout.net_dir().join("chainspec/chainspec.toml");
2321    copy_file(chainspec_template, &chainspec_dest).await?;
2322
2323    let activation_point = genesis_timestamp(delay_seconds)?;
2324    let chainspec_contents = tokio_fs::read_to_string(&chainspec_dest).await?;
2325    let chainspec_overrides = chainspec_overrides.to_vec();
2326    let protocol_version_chain = protocol_version_chain.to_string();
2327    let network_name = network_name.to_string();
2328    let (updated, overwritten_builtin_paths) = spawn_blocking_result(move || {
2329        let applied_overrides =
2330            chainspec_override::apply(&chainspec_contents, &chainspec_overrides)?;
2331        update_chainspec_contents(
2332            &applied_overrides.contents,
2333            &protocol_version_chain,
2334            &activation_point,
2335            true,
2336            &network_name,
2337            total_nodes,
2338        )
2339        .map(|updated| (updated, applied_overrides.overwritten_builtin_paths))
2340    })
2341    .await?;
2342
2343    for path in overwritten_builtin_paths {
2344        eprintln!("warning: chainspec override {path} is overwritten by start command defaults");
2345    }
2346
2347    tokio_fs::write(&chainspec_dest, updated).await?;
2348
2349    Ok(())
2350}
2351
2352async fn setup_accounts(
2353    layout: &AssetsLayout,
2354    total_nodes: u32,
2355    genesis_nodes: u32,
2356    users: u32,
2357    derived_accounts: &DerivedAccounts,
2358) -> Result<()> {
2359    let accounts_path = layout.net_dir().join("chainspec/accounts.toml");
2360    struct NodeAccount {
2361        node_id: u32,
2362        public_key: String,
2363        is_genesis: bool,
2364    }
2365
2366    struct UserAccount {
2367        user_id: u32,
2368        public_key: String,
2369        validator_key: Option<String>,
2370    }
2371
2372    if derived_accounts.nodes.len() != total_nodes as usize {
2373        return Err(anyhow!(
2374            "expected {} validator accounts, got {}",
2375            total_nodes,
2376            derived_accounts.nodes.len()
2377        ));
2378    }
2379    if derived_accounts.users.len() != users as usize {
2380        return Err(anyhow!(
2381            "expected {} user accounts, got {}",
2382            users,
2383            derived_accounts.users.len()
2384        ));
2385    }
2386
2387    let mut node_accounts = Vec::new();
2388    let mut user_accounts = Vec::new();
2389
2390    for node in &derived_accounts.nodes {
2391        node_accounts.push(NodeAccount {
2392            node_id: node.id,
2393            public_key: node.public_key_hex.clone(),
2394            is_genesis: node.id <= genesis_nodes,
2395        });
2396    }
2397
2398    for user in &derived_accounts.users {
2399        let validator_key = if user.id <= genesis_nodes {
2400            Some(
2401                derived_accounts
2402                    .nodes
2403                    .get((user.id - 1) as usize)
2404                    .map(|node| node.public_key_hex.clone())
2405                    .ok_or_else(|| anyhow!("missing validator key for node {}", user.id))?,
2406            )
2407        } else {
2408            None
2409        };
2410        user_accounts.push(UserAccount {
2411            user_id: user.id,
2412            public_key: user.public_key_hex.clone(),
2413            validator_key,
2414        });
2415    }
2416
2417    let contents = spawn_blocking_result(move || {
2418        let mut lines = Vec::new();
2419        for node in node_accounts {
2420            if node.node_id > 1 {
2421                lines.push(String::new());
2422            }
2423            lines.push(format!("# VALIDATOR {}.", node.node_id));
2424            lines.push("[[accounts]]".to_string());
2425            lines.push(format!("public_key = \"{}\"", node.public_key));
2426            lines.push(format!(
2427                "balance = \"{}\"",
2428                DEVNET_INITIAL_BALANCE_VALIDATOR
2429            ));
2430            if node.is_genesis {
2431                lines.push(String::new());
2432                lines.push("[accounts.validator]".to_string());
2433                lines.push(format!(
2434                    "bonded_amount = \"{}\"",
2435                    validator_weight(node.node_id)
2436                ));
2437                lines.push(format!("delegation_rate = {}", node.node_id));
2438            }
2439        }
2440
2441        for user in user_accounts {
2442            lines.push(String::new());
2443            lines.push(format!("# USER {}.", user.user_id));
2444            if let Some(validator_key) = user.validator_key {
2445                lines.push("[[delegators]]".to_string());
2446                lines.push(format!("validator_public_key = \"{}\"", validator_key));
2447                lines.push(format!("delegator_public_key = \"{}\"", user.public_key));
2448                lines.push(format!("balance = \"{}\"", DEVNET_INITIAL_BALANCE_USER));
2449                lines.push(format!(
2450                    "delegated_amount = \"{}\"",
2451                    DEVNET_INITIAL_DELEGATION_AMOUNT + user.user_id as u128
2452                ));
2453            } else {
2454                lines.push("[[accounts]]".to_string());
2455                lines.push(format!("public_key = \"{}\"", user.public_key));
2456                lines.push(format!("balance = \"{}\"", DEVNET_INITIAL_BALANCE_USER));
2457            }
2458        }
2459
2460        Ok(format!("{}\n", lines.join("\n")))
2461    })
2462    .await?;
2463    tokio_fs::write(&accounts_path, contents).await?;
2464
2465    Ok(())
2466}
2467
2468async fn setup_node_configs(
2469    layout: &AssetsLayout,
2470    total_nodes: u32,
2471    protocol_version_fs: &str,
2472    config_template: &Path,
2473    sidecar_template: &Path,
2474    log_format: &str,
2475) -> Result<()> {
2476    let chainspec_path = layout.net_dir().join("chainspec/chainspec.toml");
2477    let accounts_path = layout.net_dir().join("chainspec/accounts.toml");
2478    let log_format = log_format.to_string();
2479
2480    for node_id in 1..=total_nodes {
2481        let config_root = layout.node_config_root(node_id).join(protocol_version_fs);
2482        tokio_fs::create_dir_all(&config_root).await?;
2483
2484        copy_file(&chainspec_path, &config_root.join("chainspec.toml")).await?;
2485        copy_file(&accounts_path, &config_root.join("accounts.toml")).await?;
2486        copy_file(config_template, &config_root.join("config.toml")).await?;
2487
2488        let config_contents = tokio_fs::read_to_string(config_root.join("config.toml")).await?;
2489        let log_format = log_format.clone();
2490        let bind_address = format!("0.0.0.0:{}", node_port(DEVNET_BASE_PORT_NETWORK, node_id));
2491        let known = known_addresses(node_id, total_nodes);
2492        let rest_address = format!("0.0.0.0:{}", node_port(DEVNET_BASE_PORT_REST, node_id));
2493        let sse_address = format!("0.0.0.0:{}", node_port(DEVNET_BASE_PORT_SSE, node_id));
2494        let binary_address = format!("0.0.0.0:{}", node_port(DEVNET_BASE_PORT_BINARY, node_id));
2495
2496        let diagnostics_socket = diagnostics_socket_path(layout.network_name(), node_id);
2497
2498        let updated_config = spawn_blocking_result(move || {
2499            let mut config_value: toml::Value = toml::from_str(&config_contents)?;
2500
2501            set_string(
2502                &mut config_value,
2503                &["consensus", "secret_key_path"],
2504                "../../keys/secret_key.pem".to_string(),
2505            )?;
2506            set_string(&mut config_value, &["logging", "format"], log_format)?;
2507            set_string(
2508                &mut config_value,
2509                &["network", "bind_address"],
2510                bind_address,
2511            )?;
2512            set_array(&mut config_value, &["network", "known_addresses"], known)?;
2513            set_string(
2514                &mut config_value,
2515                &["storage", "path"],
2516                "../../storage".to_string(),
2517            )?;
2518            set_string(&mut config_value, &["rest_server", "address"], rest_address)?;
2519            set_string(
2520                &mut config_value,
2521                &["event_stream_server", "address"],
2522                sse_address,
2523            )?;
2524
2525            set_string(
2526                &mut config_value,
2527                &["diagnostics_port", "socket_path"],
2528                diagnostics_socket,
2529            )?;
2530
2531            set_string(
2532                &mut config_value,
2533                &["binary_port_server", "address"],
2534                binary_address,
2535            )?;
2536
2537            // Enable requests that are disabled by default for security reasons.
2538            set_bool(
2539                &mut config_value,
2540                &["binary_port_server", "allow_request_get_trie"],
2541                true,
2542            )?;
2543
2544            // Enable speculative execution requests.
2545            set_bool(
2546                &mut config_value,
2547                &["binary_port_server", "allow_request_speculative_exec"],
2548                true,
2549            )?;
2550
2551            Ok(toml::to_string(&config_value)?)
2552        })
2553        .await?;
2554
2555        tokio_fs::write(config_root.join("config.toml"), updated_config).await?;
2556
2557        if is_file(sidecar_template).await {
2558            let sidecar_path = config_root.join("sidecar.toml");
2559            copy_file(sidecar_template, &sidecar_path).await?;
2560
2561            let sidecar_contents = tokio_fs::read_to_string(&sidecar_path).await?;
2562            let rpc_port = node_port(DEVNET_BASE_PORT_RPC, node_id) as i64;
2563            let binary_port = node_port(DEVNET_BASE_PORT_BINARY, node_id) as i64;
2564
2565            let updated_sidecar = spawn_blocking_result(move || {
2566                let mut sidecar_value: toml::Value = toml::from_str(&sidecar_contents)?;
2567                set_string(
2568                    &mut sidecar_value,
2569                    &["rpc_server", "main_server", "ip_address"],
2570                    "0.0.0.0".to_string(),
2571                )?;
2572                set_integer(
2573                    &mut sidecar_value,
2574                    &["rpc_server", "main_server", "port"],
2575                    rpc_port,
2576                )?;
2577                set_string(
2578                    &mut sidecar_value,
2579                    &["rpc_server", "node_client", "ip_address"],
2580                    "0.0.0.0".to_string(),
2581                )?;
2582                set_integer(
2583                    &mut sidecar_value,
2584                    &["rpc_server", "node_client", "port"],
2585                    binary_port,
2586                )?;
2587
2588                Ok(toml::to_string(&sidecar_value)?)
2589            })
2590            .await?;
2591
2592            tokio_fs::write(&sidecar_path, updated_sidecar).await?;
2593        }
2594    }
2595
2596    Ok(())
2597}
2598
2599fn node_port(base: u32, node_id: u32) -> u32 {
2600    base + DEVNET_NET_PORT_OFFSET + node_id
2601}
2602
2603fn bootstrap_address(node_id: u32) -> String {
2604    format!("127.0.0.1:{}", node_port(DEVNET_BASE_PORT_NETWORK, node_id))
2605}
2606
2607fn known_addresses(node_id: u32, total_nodes: u32) -> Vec<String> {
2608    let bootstrap_nodes = BOOTSTRAP_NODES.min(total_nodes);
2609    let mut addresses = Vec::new();
2610    addresses.push(bootstrap_address(1));
2611
2612    if node_id < bootstrap_nodes {
2613        for id in 2..=bootstrap_nodes {
2614            addresses.push(bootstrap_address(id));
2615        }
2616    } else {
2617        let limit = node_id.min(total_nodes);
2618        for id in 2..=limit {
2619            addresses.push(bootstrap_address(id));
2620        }
2621    }
2622
2623    addresses
2624}
2625
2626fn validator_weight(node_id: u32) -> u128 {
2627    DEVNET_VALIDATOR_BASE_WEIGHT + node_id as u128
2628}
2629
2630fn genesis_timestamp(delay_seconds: u64) -> Result<String> {
2631    let ts = OffsetDateTime::now_utc() + Duration::seconds(delay_seconds as i64);
2632    Ok(ts.format(&Rfc3339)?)
2633}
2634
2635fn format_cspr(motes: u128) -> String {
2636    let whole = motes / MOTE_PER_CSPR;
2637    let rem = motes % MOTE_PER_CSPR;
2638    if rem == 0 {
2639        return whole.to_string();
2640    }
2641    let frac = format!("{:09}", rem);
2642    let frac = frac.trim_end_matches('0');
2643    format!("{}.{}", whole, frac)
2644}
2645
2646fn derived_accounts_path(layout: &AssetsLayout) -> PathBuf {
2647    layout.net_dir().join(DERIVED_ACCOUNTS_FILE)
2648}
2649
2650async fn write_derived_accounts_summary(
2651    layout: &AssetsLayout,
2652    accounts: &[DerivedAccountInfo],
2653) -> Result<()> {
2654    let mut lines = Vec::new();
2655    lines.push("kind,name,key_type,derivation,path,account_hash,balance".to_string());
2656    for account in accounts {
2657        lines.push(account.line());
2658    }
2659    tokio_fs::write(derived_accounts_path(layout), lines.join("\n")).await?;
2660    Ok(())
2661}
2662
2663pub async fn derived_accounts_summary(layout: &AssetsLayout) -> Option<String> {
2664    tokio_fs::read_to_string(derived_accounts_path(layout))
2665        .await
2666        .ok()
2667}
2668
2669pub(crate) async fn derive_account_from_seed_path(
2670    seed: Arc<str>,
2671    path: &str,
2672) -> Result<DerivedPathMaterial> {
2673    let seed_for_root = seed.to_string();
2674    let path_for_parse = path.to_string();
2675    spawn_blocking_result(move || {
2676        let root = unsafe_root_from_seed(&seed_for_root)?;
2677        let path = DerivationPath::from_str(&path_for_parse)?;
2678        let material = derive_account_material(&root, &path, true)?;
2679        let secret_key_pem = material
2680            .secret_key_pem
2681            .ok_or_else(|| anyhow!("missing secret key material for {}", path_for_parse))?;
2682
2683        Ok(DerivedPathMaterial {
2684            public_key_hex: material.public_key_hex,
2685            account_hash: material.account_hash,
2686            secret_key_pem,
2687        })
2688    })
2689    .await
2690}
2691
2692pub(crate) async fn derive_node_secret_key_pem(seed: Arc<str>, node_id: u32) -> Result<String> {
2693    let material = derive_node_account(seed, node_id, true).await?;
2694    material
2695        .secret_key_pem
2696        .ok_or_else(|| anyhow!("missing secret key material for node-{}", node_id))
2697}
2698
2699struct CustomAssetPaths {
2700    casper_node: PathBuf,
2701    casper_sidecar: PathBuf,
2702    chainspec: PathBuf,
2703    node_config: PathBuf,
2704    sidecar_config: PathBuf,
2705}
2706
2707pub async fn ensure_network_hook_samples(layout: &AssetsLayout) -> Result<()> {
2708    let hooks_dir = layout.hooks_dir();
2709    tokio_fs::create_dir_all(&hooks_dir).await?;
2710    write_executable_script_if_missing(
2711        &hooks_dir.join(PRE_GENESIS_SAMPLE),
2712        PRE_GENESIS_SAMPLE_SCRIPT,
2713    )
2714    .await?;
2715    write_executable_script_if_missing(
2716        &hooks_dir.join(POST_GENESIS_SAMPLE),
2717        POST_GENESIS_SAMPLE_SCRIPT,
2718    )
2719    .await?;
2720    write_executable_script_if_missing(
2721        &hooks_dir.join(BLOCK_ADDED_SAMPLE),
2722        BLOCK_ADDED_SAMPLE_SCRIPT,
2723    )
2724    .await?;
2725    write_executable_script_if_missing(
2726        &hooks_dir.join(PRE_STAGE_PROTOCOL_SAMPLE),
2727        PRE_STAGE_PROTOCOL_SAMPLE_SCRIPT,
2728    )
2729    .await?;
2730    write_executable_script_if_missing(
2731        &hooks_dir.join(POST_STAGE_PROTOCOL_SAMPLE),
2732        POST_STAGE_PROTOCOL_SAMPLE_SCRIPT,
2733    )
2734    .await?;
2735    Ok(())
2736}
2737
2738async fn write_executable_script(path: &Path, contents: impl AsRef<[u8]>) -> Result<()> {
2739    if let Some(parent) = path.parent() {
2740        tokio_fs::create_dir_all(parent).await?;
2741    }
2742    tokio_fs::write(path, contents.as_ref()).await?;
2743    tokio_fs::set_permissions(path, std::fs::Permissions::from_mode(HOOK_FILE_MODE)).await?;
2744    Ok(())
2745}
2746
2747async fn write_executable_script_if_missing(path: &Path, contents: &[u8]) -> Result<()> {
2748    if tokio_fs::symlink_metadata(path).await.is_ok() {
2749        return Ok(());
2750    }
2751    write_executable_script(path, contents).await
2752}
2753
2754fn protocol_version_fs(protocol_version: &str) -> String {
2755    protocol_version.replace('.', "_")
2756}
2757
2758fn hook_log_paths(logs_dir: &Path, hook_name: &str, protocol_version: &str) -> HookLogPaths {
2759    let base = format!("{hook_name}-{}", protocol_version_fs(protocol_version));
2760    HookLogPaths {
2761        stdout: logs_dir.join(format!("{base}.stdout.log")),
2762        stderr: logs_dir.join(format!("{base}.stderr.log")),
2763    }
2764}
2765
2766fn pending_post_stage_protocol_hook_path(hooks_dir: &Path, protocol_version: &str) -> PathBuf {
2767    hooks_dir
2768        .join(format!(".{HOOKS_PENDING_DIR_NAME}"))
2769        .join(format!(
2770            "{POST_STAGE_PROTOCOL_HOOK}-{}.json",
2771            protocol_version_fs(protocol_version)
2772        ))
2773}
2774
2775fn post_stage_protocol_claim_path(hooks_dir: &Path, protocol_version: &str) -> PathBuf {
2776    hooks_dir
2777        .join(format!(".{HOOKS_STATUS_DIR_NAME}"))
2778        .join(format!(
2779            "{POST_STAGE_PROTOCOL_HOOK}-{}.claimed",
2780            protocol_version_fs(protocol_version)
2781        ))
2782}
2783
2784fn post_stage_protocol_completion_path(hooks_dir: &Path, protocol_version: &str) -> PathBuf {
2785    hooks_dir
2786        .join(format!(".{HOOKS_STATUS_DIR_NAME}"))
2787        .join(format!(
2788            "{POST_STAGE_PROTOCOL_HOOK}-{}.json",
2789            protocol_version_fs(protocol_version)
2790        ))
2791}
2792
2793fn pending_post_genesis_hook_path(hooks_dir: &Path, protocol_version: &str) -> PathBuf {
2794    hooks_dir
2795        .join(format!(".{HOOKS_PENDING_DIR_NAME}"))
2796        .join(format!(
2797            "{POST_GENESIS_HOOK}-{}.json",
2798            protocol_version_fs(protocol_version)
2799        ))
2800}
2801
2802fn post_genesis_claim_path(hooks_dir: &Path, protocol_version: &str) -> PathBuf {
2803    hooks_dir
2804        .join(format!(".{HOOKS_STATUS_DIR_NAME}"))
2805        .join(format!(
2806            "{POST_GENESIS_HOOK}-{}.claimed",
2807            protocol_version_fs(protocol_version)
2808        ))
2809}
2810
2811fn post_genesis_completion_path(hooks_dir: &Path, protocol_version: &str) -> PathBuf {
2812    hooks_dir
2813        .join(format!(".{HOOKS_STATUS_DIR_NAME}"))
2814        .join(format!(
2815            "{POST_GENESIS_HOOK}-{}.json",
2816            protocol_version_fs(protocol_version)
2817        ))
2818}
2819
2820pub async fn prepare_genesis_hooks(layout: &AssetsLayout, protocol_version: &str) -> Result<()> {
2821    let protocol_version = parse_protocol_version(protocol_version)?.to_string();
2822    ensure_network_hook_samples(layout).await?;
2823    run_pre_genesis_hook(layout, &protocol_version).await?;
2824    refresh_post_genesis_hook(layout, &protocol_version).await?;
2825    Ok(())
2826}
2827
2828async fn run_pre_stage_protocol_hook(
2829    layout: &AssetsLayout,
2830    protocol_version: &str,
2831    activation_point: u64,
2832) -> Result<()> {
2833    let Some(command_path) = lookup_network_hook(layout, PRE_STAGE_PROTOCOL_HOOK).await? else {
2834        return Ok(());
2835    };
2836
2837    let log_paths = hook_log_paths(
2838        &layout.hook_logs_dir(),
2839        PRE_STAGE_PROTOCOL_HOOK,
2840        protocol_version,
2841    );
2842    let args = vec![
2843        layout.network_name().to_string(),
2844        protocol_version.to_string(),
2845        activation_point.to_string(),
2846    ];
2847    let exit_status = execute_hook_command(
2848        &command_path,
2849        &args,
2850        &layout.hook_work_dir(PRE_STAGE_PROTOCOL_HOOK),
2851        &log_paths,
2852        None,
2853        false,
2854    )
2855    .await?;
2856    if !exit_status.success() {
2857        return Err(anyhow!(
2858            "{} hook {} exited with status {}",
2859            PRE_STAGE_PROTOCOL_HOOK,
2860            command_path.display(),
2861            exit_status
2862        ));
2863    }
2864    Ok(())
2865}
2866
2867async fn lookup_network_hook(layout: &AssetsLayout, hook_name: &str) -> Result<Option<PathBuf>> {
2868    canonicalize_optional_hook(&layout.hooks_dir().join(hook_name)).await
2869}
2870
2871async fn run_pre_genesis_hook(layout: &AssetsLayout, protocol_version: &str) -> Result<()> {
2872    let Some(command_path) = lookup_network_hook(layout, PRE_GENESIS_HOOK).await? else {
2873        return Ok(());
2874    };
2875
2876    let log_paths = hook_log_paths(&layout.hook_logs_dir(), PRE_GENESIS_HOOK, protocol_version);
2877    let args = vec![
2878        layout.network_name().to_string(),
2879        protocol_version.to_string(),
2880    ];
2881    let exit_status = execute_hook_command(
2882        &command_path,
2883        &args,
2884        &layout.hook_work_dir(PRE_GENESIS_HOOK),
2885        &log_paths,
2886        None,
2887        false,
2888    )
2889    .await?;
2890    if !exit_status.success() {
2891        return Err(anyhow!(
2892            "{} hook {} exited with status {}",
2893            PRE_GENESIS_HOOK,
2894            command_path.display(),
2895            exit_status
2896        ));
2897    }
2898    Ok(())
2899}
2900
2901async fn refresh_post_genesis_hook(layout: &AssetsLayout, protocol_version: &str) -> Result<()> {
2902    clear_post_genesis_hook_state(layout, protocol_version).await?;
2903
2904    let Some(command_path) = lookup_network_hook(layout, POST_GENESIS_HOOK).await? else {
2905        return Ok(());
2906    };
2907
2908    tokio_fs::create_dir_all(layout.hooks_pending_dir()).await?;
2909    tokio_fs::create_dir_all(layout.hooks_status_dir()).await?;
2910    tokio_fs::create_dir_all(layout.hook_logs_dir()).await?;
2911
2912    let log_paths = hook_log_paths(&layout.hook_logs_dir(), POST_GENESIS_HOOK, protocol_version);
2913    let pending = PendingPostGenesisHook {
2914        network_name: layout.network_name().to_string(),
2915        protocol_version: protocol_version.to_string(),
2916        command_path,
2917        stdout_path: log_paths.stdout,
2918        stderr_path: log_paths.stderr,
2919    };
2920    write_json_atomic(
2921        &pending_post_genesis_hook_path(&layout.hooks_dir(), protocol_version),
2922        &pending,
2923    )
2924    .await
2925}
2926
2927async fn clear_post_genesis_hook_state(
2928    layout: &AssetsLayout,
2929    protocol_version: &str,
2930) -> Result<()> {
2931    let hooks_dir = layout.hooks_dir();
2932    remove_file_if_exists(&pending_post_genesis_hook_path(
2933        &hooks_dir,
2934        protocol_version,
2935    ))
2936    .await?;
2937    remove_file_if_exists(&post_genesis_claim_path(&hooks_dir, protocol_version)).await?;
2938    remove_file_if_exists(&post_genesis_completion_path(&hooks_dir, protocol_version)).await?;
2939    Ok(())
2940}
2941
2942async fn refresh_post_stage_protocol_hook(
2943    layout: &AssetsLayout,
2944    asset_name: &str,
2945    opts: &StageProtocolOptions,
2946    protocol_version: &str,
2947) -> Result<()> {
2948    clear_post_stage_protocol_hook_state(layout, protocol_version).await?;
2949
2950    let Some(command_path) = lookup_network_hook(layout, POST_STAGE_PROTOCOL_HOOK).await? else {
2951        return Ok(());
2952    };
2953
2954    tokio_fs::create_dir_all(layout.hooks_pending_dir()).await?;
2955    tokio_fs::create_dir_all(layout.hooks_status_dir()).await?;
2956    tokio_fs::create_dir_all(layout.hook_logs_dir()).await?;
2957
2958    let log_paths = hook_log_paths(
2959        &layout.hook_logs_dir(),
2960        POST_STAGE_PROTOCOL_HOOK,
2961        protocol_version,
2962    );
2963    let pending = PendingPostStageProtocolHook {
2964        asset_name: asset_name.to_string(),
2965        network_name: layout.network_name().to_string(),
2966        protocol_version: protocol_version.to_string(),
2967        activation_point: opts.activation_point,
2968        command_path,
2969        stdout_path: log_paths.stdout,
2970        stderr_path: log_paths.stderr,
2971    };
2972    write_json_atomic(
2973        &pending_post_stage_protocol_hook_path(&layout.hooks_dir(), protocol_version),
2974        &pending,
2975    )
2976    .await
2977}
2978
2979async fn clear_post_stage_protocol_hook_state(
2980    layout: &AssetsLayout,
2981    protocol_version: &str,
2982) -> Result<()> {
2983    let hooks_dir = layout.hooks_dir();
2984    remove_file_if_exists(&pending_post_stage_protocol_hook_path(
2985        &hooks_dir,
2986        protocol_version,
2987    ))
2988    .await?;
2989    remove_file_if_exists(&post_stage_protocol_claim_path(
2990        &hooks_dir,
2991        protocol_version,
2992    ))
2993    .await?;
2994    remove_file_if_exists(&post_stage_protocol_completion_path(
2995        &hooks_dir,
2996        protocol_version,
2997    ))
2998    .await?;
2999    Ok(())
3000}
3001
3002pub(crate) async fn run_pending_post_stage_protocol_hook(
3003    hooks_dir: &Path,
3004    network_dir: &Path,
3005    protocol_version: &Version,
3006) -> Result<PendingPostStageProtocolHookResult> {
3007    let protocol_version = protocol_version.to_string();
3008    let pending_path = pending_post_stage_protocol_hook_path(hooks_dir, &protocol_version);
3009    if !is_file(&pending_path).await {
3010        return Ok(PendingPostStageProtocolHookResult::NotRun);
3011    }
3012
3013    let claim_path = post_stage_protocol_claim_path(hooks_dir, &protocol_version);
3014    if !try_create_claim_marker(&claim_path).await? {
3015        return Ok(PendingPostStageProtocolHookResult::NotRun);
3016    }
3017
3018    let pending = read_json_file::<PendingPostStageProtocolHook>(&pending_path).await?;
3019    let args = vec![
3020        pending.network_name.clone(),
3021        pending.protocol_version.clone(),
3022    ];
3023    let completion = match execute_hook_command(
3024        &pending.command_path,
3025        &args,
3026        &network_hook_work_dir(network_dir, POST_STAGE_PROTOCOL_HOOK),
3027        &HookLogPaths {
3028            stdout: pending.stdout_path.clone(),
3029            stderr: pending.stderr_path.clone(),
3030        },
3031        None,
3032        false,
3033    )
3034    .await
3035    {
3036        Ok(exit_status) if exit_status.success() => CompletedPostStageProtocolHook {
3037            asset_name: pending.asset_name.clone(),
3038            network_name: pending.network_name.clone(),
3039            protocol_version: pending.protocol_version.clone(),
3040            activation_point: pending.activation_point,
3041            command_path: pending.command_path.clone(),
3042            stdout_path: pending.stdout_path.clone(),
3043            stderr_path: pending.stderr_path.clone(),
3044            status: HookRunStatus::Success,
3045            exit_code: exit_status.code(),
3046            error: None,
3047            completed_at: OffsetDateTime::now_utc(),
3048        },
3049        Ok(exit_status) => CompletedPostStageProtocolHook {
3050            asset_name: pending.asset_name.clone(),
3051            network_name: pending.network_name.clone(),
3052            protocol_version: pending.protocol_version.clone(),
3053            activation_point: pending.activation_point,
3054            command_path: pending.command_path.clone(),
3055            stdout_path: pending.stdout_path.clone(),
3056            stderr_path: pending.stderr_path.clone(),
3057            status: HookRunStatus::Failure,
3058            exit_code: exit_status.code(),
3059            error: Some(format!(
3060                "{} hook {} exited with status {}",
3061                POST_STAGE_PROTOCOL_HOOK,
3062                pending.command_path.display(),
3063                exit_status
3064            )),
3065            completed_at: OffsetDateTime::now_utc(),
3066        },
3067        Err(err) => CompletedPostStageProtocolHook {
3068            asset_name: pending.asset_name.clone(),
3069            network_name: pending.network_name.clone(),
3070            protocol_version: pending.protocol_version.clone(),
3071            activation_point: pending.activation_point,
3072            command_path: pending.command_path.clone(),
3073            stdout_path: pending.stdout_path.clone(),
3074            stderr_path: pending.stderr_path.clone(),
3075            status: HookRunStatus::Failure,
3076            exit_code: None,
3077            error: Some(err.to_string()),
3078            completed_at: OffsetDateTime::now_utc(),
3079        },
3080    };
3081
3082    write_json_atomic(
3083        &post_stage_protocol_completion_path(hooks_dir, &protocol_version),
3084        &completion,
3085    )
3086    .await?;
3087    remove_file_if_exists(&pending_path).await?;
3088    Ok(match completion.status {
3089        HookRunStatus::Success => PendingPostStageProtocolHookResult::Succeeded,
3090        HookRunStatus::Failure => PendingPostStageProtocolHookResult::Failed(
3091            completion
3092                .error
3093                .clone()
3094                .unwrap_or_else(|| format!("{POST_STAGE_PROTOCOL_HOOK} hook failed")),
3095        ),
3096    })
3097}
3098
3099pub(crate) async fn run_pending_post_genesis_hook(
3100    layout: &AssetsLayout,
3101) -> Result<PendingPostGenesisHookResult> {
3102    let Some(pending_path) =
3103        find_pending_hook_path(&layout.hooks_pending_dir(), POST_GENESIS_HOOK).await?
3104    else {
3105        return Ok(PendingPostGenesisHookResult::NotRun);
3106    };
3107
3108    let pending = read_json_file::<PendingPostGenesisHook>(&pending_path).await?;
3109    let claim_path = post_genesis_claim_path(&layout.hooks_dir(), &pending.protocol_version);
3110    if !try_create_claim_marker(&claim_path).await? {
3111        return Ok(PendingPostGenesisHookResult::NotRun);
3112    }
3113
3114    let args = vec![
3115        pending.network_name.clone(),
3116        pending.protocol_version.clone(),
3117    ];
3118    let completion = match execute_hook_command(
3119        &pending.command_path,
3120        &args,
3121        &layout.hook_work_dir(POST_GENESIS_HOOK),
3122        &HookLogPaths {
3123            stdout: pending.stdout_path.clone(),
3124            stderr: pending.stderr_path.clone(),
3125        },
3126        None,
3127        false,
3128    )
3129    .await
3130    {
3131        Ok(exit_status) if exit_status.success() => CompletedPostGenesisHook {
3132            network_name: pending.network_name.clone(),
3133            protocol_version: pending.protocol_version.clone(),
3134            command_path: pending.command_path.clone(),
3135            stdout_path: pending.stdout_path.clone(),
3136            stderr_path: pending.stderr_path.clone(),
3137            status: HookRunStatus::Success,
3138            exit_code: exit_status.code(),
3139            error: None,
3140            completed_at: OffsetDateTime::now_utc(),
3141        },
3142        Ok(exit_status) => CompletedPostGenesisHook {
3143            network_name: pending.network_name.clone(),
3144            protocol_version: pending.protocol_version.clone(),
3145            command_path: pending.command_path.clone(),
3146            stdout_path: pending.stdout_path.clone(),
3147            stderr_path: pending.stderr_path.clone(),
3148            status: HookRunStatus::Failure,
3149            exit_code: exit_status.code(),
3150            error: Some(format!(
3151                "{} hook {} exited with status {}",
3152                POST_GENESIS_HOOK,
3153                pending.command_path.display(),
3154                exit_status
3155            )),
3156            completed_at: OffsetDateTime::now_utc(),
3157        },
3158        Err(err) => CompletedPostGenesisHook {
3159            network_name: pending.network_name.clone(),
3160            protocol_version: pending.protocol_version.clone(),
3161            command_path: pending.command_path.clone(),
3162            stdout_path: pending.stdout_path.clone(),
3163            stderr_path: pending.stderr_path.clone(),
3164            status: HookRunStatus::Failure,
3165            exit_code: None,
3166            error: Some(err.to_string()),
3167            completed_at: OffsetDateTime::now_utc(),
3168        },
3169    };
3170
3171    write_json_atomic(
3172        &post_genesis_completion_path(&layout.hooks_dir(), &pending.protocol_version),
3173        &completion,
3174    )
3175    .await?;
3176    remove_file_if_exists(&pending_path).await?;
3177    Ok(match completion.status {
3178        HookRunStatus::Success => PendingPostGenesisHookResult::Succeeded,
3179        HookRunStatus::Failure => PendingPostGenesisHookResult::Failed(
3180            completion
3181                .error
3182                .clone()
3183                .unwrap_or_else(|| format!("{POST_GENESIS_HOOK} hook failed")),
3184        ),
3185    })
3186}
3187
3188pub fn spawn_pending_post_genesis_hook(layout: AssetsLayout) {
3189    tokio::spawn(async move {
3190        match run_pending_post_genesis_hook(&layout).await {
3191            Ok(PendingPostGenesisHookResult::NotRun | PendingPostGenesisHookResult::Succeeded) => {}
3192            Ok(PendingPostGenesisHookResult::Failed(err)) => {
3193                eprintln!("warning: {}", err);
3194            }
3195            Err(err) => {
3196                eprintln!("warning: failed to run {POST_GENESIS_HOOK} hook: {}", err);
3197            }
3198        }
3199    });
3200}
3201
3202pub fn spawn_block_added_hook(
3203    layout: AssetsLayout,
3204    protocol_version: String,
3205    payload: serde_json::Value,
3206) {
3207    tokio::spawn(async move {
3208        if let Err(err) = run_block_added_hook(&layout, &protocol_version, payload).await {
3209            eprintln!("warning: failed to run {BLOCK_ADDED_HOOK} hook: {}", err);
3210        }
3211    });
3212}
3213
3214async fn run_block_added_hook(
3215    layout: &AssetsLayout,
3216    protocol_version: &str,
3217    payload: serde_json::Value,
3218) -> Result<()> {
3219    let Some(command_path) = lookup_network_hook(layout, BLOCK_ADDED_HOOK).await? else {
3220        return Ok(());
3221    };
3222
3223    let log_paths = hook_log_paths(&layout.hook_logs_dir(), BLOCK_ADDED_HOOK, protocol_version);
3224    let args = vec![
3225        layout.network_name().to_string(),
3226        protocol_version.to_string(),
3227    ];
3228    let stdin = hook_payload_stdin(&payload)?;
3229    let exit_status = execute_hook_command(
3230        &command_path,
3231        &args,
3232        &layout.hook_work_dir(BLOCK_ADDED_HOOK),
3233        &log_paths,
3234        Some(&stdin),
3235        true,
3236    )
3237    .await?;
3238    if !exit_status.success() {
3239        return Err(anyhow!(
3240            "{} hook {} exited with status {}",
3241            BLOCK_ADDED_HOOK,
3242            command_path.display(),
3243            exit_status
3244        ));
3245    }
3246    Ok(())
3247}
3248
3249async fn execute_hook_command(
3250    command_path: &Path,
3251    args: &[String],
3252    cwd: &Path,
3253    log_paths: &HookLogPaths,
3254    stdin: Option<&[u8]>,
3255    append_logs: bool,
3256) -> Result<std::process::ExitStatus> {
3257    ensure_executable_hook(command_path).await?;
3258    tokio_fs::create_dir_all(cwd).await?;
3259    let hook_name = command_path
3260        .file_name()
3261        .and_then(OsStr::to_str)
3262        .unwrap_or("hook")
3263        .to_string();
3264    let stdout = open_hook_log_file(&log_paths.stdout, append_logs).await?;
3265    let stderr = open_hook_log_file(&log_paths.stderr, append_logs).await?;
3266    let mut command = Command::new(command_path);
3267    command
3268        .args(args)
3269        .current_dir(cwd)
3270        .stdin(if stdin.is_some() {
3271            Stdio::piped()
3272        } else {
3273            Stdio::null()
3274        })
3275        .stdout(Stdio::piped())
3276        .stderr(Stdio::piped());
3277    let mut child = command.spawn()?;
3278    if let Some(stdin) = stdin {
3279        let mut child_stdin = child
3280            .stdin
3281            .take()
3282            .ok_or_else(|| anyhow!("failed to capture stdin for {}", command_path.display()))?;
3283        child_stdin.write_all(stdin).await?;
3284        child_stdin.shutdown().await?;
3285    }
3286    let child_stdout = child
3287        .stdout
3288        .take()
3289        .ok_or_else(|| anyhow!("failed to capture stdout for {}", command_path.display()))?;
3290    let child_stderr = child
3291        .stderr
3292        .take()
3293        .ok_or_else(|| anyhow!("failed to capture stderr for {}", command_path.display()))?;
3294
3295    let stdout_task = tokio::spawn(stream_hook_output(
3296        hook_name.clone(),
3297        "stdout",
3298        BufReader::new(child_stdout),
3299        stdout,
3300    ));
3301    let stderr_task = tokio::spawn(stream_hook_output(
3302        hook_name.clone(),
3303        "stderr",
3304        BufReader::new(child_stderr),
3305        stderr,
3306    ));
3307
3308    let exit_status = child.wait().await?;
3309    stdout_task
3310        .await
3311        .map_err(|err| anyhow!("failed to join {} stdout task: {}", hook_name, err))??;
3312    stderr_task
3313        .await
3314        .map_err(|err| anyhow!("failed to join {} stderr task: {}", hook_name, err))??;
3315    if let Some(message) = hook_finished_message(&hook_name, &exit_status) {
3316        eprintln!("{}", message);
3317    }
3318    Ok(exit_status)
3319}
3320
3321async fn open_hook_log_file(path: &Path, append: bool) -> Result<tokio_fs::File> {
3322    if let Some(parent) = path.parent() {
3323        tokio_fs::create_dir_all(parent).await?;
3324    }
3325    let mut options = tokio_fs::OpenOptions::new();
3326    options.create(true).write(true);
3327    if append {
3328        options.append(true);
3329    } else {
3330        options.truncate(true);
3331    }
3332    options.open(path).await.map_err(Into::into)
3333}
3334
3335async fn stream_hook_output<R>(
3336    hook_name: String,
3337    stream_name: &'static str,
3338    mut reader: BufReader<R>,
3339    mut log_file: tokio_fs::File,
3340) -> Result<()>
3341where
3342    R: tokio::io::AsyncRead + Unpin,
3343{
3344    let mut buf = Vec::new();
3345    loop {
3346        buf.clear();
3347        let bytes_read = reader.read_until(b'\n', &mut buf).await?;
3348        if bytes_read == 0 {
3349            break;
3350        }
3351
3352        log_file.write_all(&buf).await?;
3353        let line = normalize_hook_output_line(&buf);
3354        eprintln!("{hook_name} {stream_name}: {line}");
3355    }
3356    log_file.flush().await?;
3357    Ok(())
3358}
3359
3360fn normalize_hook_output_line(bytes: &[u8]) -> String {
3361    let mut line = String::from_utf8_lossy(bytes).into_owned();
3362    while line.ends_with('\n') || line.ends_with('\r') {
3363        line.pop();
3364    }
3365    line
3366}
3367
3368fn hook_finished_message(hook_name: &str, status: &std::process::ExitStatus) -> Option<String> {
3369    match status.code() {
3370        Some(0) => None,
3371        Some(code) => Some(format!("{hook_name} finished with exit code {code}")),
3372        None => Some(format!("{hook_name} finished with status {status}")),
3373    }
3374}
3375
3376fn hook_payload_stdin(payload: &serde_json::Value) -> Result<Vec<u8>> {
3377    let mut bytes = serde_json::to_vec(payload)?;
3378    bytes.push(b'\n');
3379    Ok(bytes)
3380}
3381
3382async fn ensure_executable_hook(path: &Path) -> Result<()> {
3383    let metadata = tokio_fs::metadata(path).await?;
3384    if !metadata.is_file() {
3385        return Err(anyhow!("hook path {} is not a file", path.display()));
3386    }
3387    if metadata.permissions().mode() & 0o111 == 0 {
3388        return Err(anyhow!("hook path {} is not executable", path.display()));
3389    }
3390    Ok(())
3391}
3392
3393async fn try_create_claim_marker(path: &Path) -> Result<bool> {
3394    if let Some(parent) = path.parent() {
3395        tokio_fs::create_dir_all(parent).await?;
3396    }
3397    match tokio_fs::OpenOptions::new()
3398        .create_new(true)
3399        .write(true)
3400        .open(path)
3401        .await
3402    {
3403        Ok(_) => Ok(true),
3404        Err(err) if err.kind() == std::io::ErrorKind::AlreadyExists => Ok(false),
3405        Err(err) => Err(err.into()),
3406    }
3407}
3408
3409async fn find_pending_hook_path(dir: &Path, hook_name: &str) -> Result<Option<PathBuf>> {
3410    if !is_dir(dir).await {
3411        return Ok(None);
3412    }
3413
3414    let prefix = format!("{hook_name}-");
3415    let mut matches = Vec::new();
3416    let mut entries = tokio_fs::read_dir(dir).await?;
3417    while let Some(entry) = entries.next_entry().await? {
3418        if !entry.file_type().await?.is_file() {
3419            continue;
3420        }
3421        let file_name = entry.file_name();
3422        let file_name = file_name.to_string_lossy();
3423        if file_name.starts_with(&prefix) && file_name.ends_with(".json") {
3424            matches.push(entry.path());
3425        }
3426    }
3427    matches.sort();
3428    Ok(matches.into_iter().next())
3429}
3430
3431async fn write_json_atomic<T: Serialize>(path: &Path, value: &T) -> Result<()> {
3432    let bytes = serde_json::to_vec_pretty(value)?;
3433    if let Some(parent) = path.parent() {
3434        tokio_fs::create_dir_all(parent).await?;
3435    }
3436    let tmp_path = path.with_extension(format!(
3437        "{}.tmp-{}",
3438        path.extension().and_then(OsStr::to_str).unwrap_or("json"),
3439        std::process::id()
3440    ));
3441    tokio_fs::write(&tmp_path, bytes).await?;
3442    tokio_fs::rename(&tmp_path, path).await?;
3443    Ok(())
3444}
3445
3446async fn read_json_file<T: for<'de> Deserialize<'de>>(path: &Path) -> Result<T> {
3447    let contents = tokio_fs::read(path).await?;
3448    Ok(serde_json::from_slice(&contents)?)
3449}
3450
3451async fn remove_file_if_exists(path: &Path) -> Result<()> {
3452    match tokio_fs::remove_file(path).await {
3453        Ok(()) => Ok(()),
3454        Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(()),
3455        Err(err) => Err(err.into()),
3456    }
3457}
3458
3459async fn detect_current_node_log_format(layout: &AssetsLayout) -> Result<String> {
3460    let version_dir = match layout.latest_protocol_version_dir(1).await {
3461        Ok(version_dir) => version_dir,
3462        Err(_) => return Ok("json".to_string()),
3463    };
3464    let config_path = layout
3465        .node_config_root(1)
3466        .join(version_dir)
3467        .join("config.toml");
3468    if !is_file(&config_path).await {
3469        return Ok("json".to_string());
3470    }
3471
3472    let config_contents = tokio_fs::read_to_string(config_path).await?;
3473    spawn_blocking_result(move || {
3474        let value: toml::Value = toml::from_str(&config_contents)?;
3475        let format = value
3476            .get("logging")
3477            .and_then(|logging| logging.get("format"))
3478            .and_then(|format| format.as_str())
3479            .unwrap_or("json")
3480            .to_string();
3481        Ok(format)
3482    })
3483    .await
3484}
3485
3486async fn load_custom_asset(name: &str) -> Result<CustomAssetPaths> {
3487    let asset_dir = custom_asset_path(name).await?;
3488
3489    let casper_node =
3490        canonicalize_required_file(&asset_dir.join("bin").join("casper-node"), "casper-node")
3491            .await?;
3492    let casper_sidecar = canonicalize_required_file(
3493        &asset_dir.join("bin").join("casper-sidecar"),
3494        "casper-sidecar",
3495    )
3496    .await?;
3497    let chainspec =
3498        canonicalize_required_file(&asset_dir.join("chainspec.toml"), "chainspec").await?;
3499    let node_config =
3500        canonicalize_required_file(&asset_dir.join("node-config.toml"), "node-config").await?;
3501    let sidecar_config =
3502        canonicalize_required_file(&asset_dir.join("sidecar-config.toml"), "sidecar-config")
3503            .await?;
3504
3505    Ok(CustomAssetPaths {
3506        casper_node,
3507        casper_sidecar,
3508        chainspec,
3509        node_config,
3510        sidecar_config,
3511    })
3512}
3513
3514async fn canonicalize_optional_hook(path: &Path) -> Result<Option<PathBuf>> {
3515    match tokio_fs::symlink_metadata(path).await {
3516        Ok(metadata) => {
3517            if metadata.is_dir() || !is_file(path).await {
3518                return Err(anyhow!("hook path {} is not a file", path.display()));
3519            }
3520            Ok(Some(tokio_fs::canonicalize(path).await?))
3521        }
3522        Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(None),
3523        Err(err) => Err(err.into()),
3524    }
3525}
3526
3527async fn canonicalize_required_file(path: &Path, label: &str) -> Result<PathBuf> {
3528    if !is_file(path).await {
3529        return Err(anyhow!("missing {} file {}", label, path.display()));
3530    }
3531    let canonical = tokio_fs::canonicalize(path).await?;
3532    if !is_file(&canonical).await {
3533        return Err(anyhow!("missing {} file {}", label, canonical.display()));
3534    }
3535    Ok(canonical)
3536}
3537
3538fn validate_custom_asset_name(name: &str) -> Result<()> {
3539    let trimmed = name.trim();
3540    if trimmed.is_empty() {
3541        return Err(anyhow!("custom asset name must not be empty"));
3542    }
3543    if trimmed.contains('/') || trimmed.contains('\\') || trimmed.contains("..") {
3544        return Err(anyhow!(
3545            "custom asset name '{}' contains forbidden path characters",
3546            name
3547        ));
3548    }
3549    if !trimmed
3550        .chars()
3551        .all(|ch| ch.is_ascii_alphanumeric() || ch == '-' || ch == '_' || ch == '.')
3552    {
3553        return Err(anyhow!(
3554            "custom asset name '{}' must use [A-Za-z0-9._-]",
3555            name
3556        ));
3557    }
3558    Ok(())
3559}
3560
3561async fn copy_file(src: &Path, dest: &Path) -> Result<()> {
3562    if !is_file(src).await {
3563        return Err(anyhow!("missing source file {}", src.display()));
3564    }
3565    if let Some(parent) = dest.parent() {
3566        tokio_fs::create_dir_all(parent).await?;
3567    }
3568    tokio_fs::copy(src, dest).await?;
3569    Ok(())
3570}
3571
3572async fn symlink_file(src: &Path, dest: &Path) -> Result<()> {
3573    if !is_file(src).await {
3574        return Err(anyhow!("missing source file {}", src.display()));
3575    }
3576    if let Some(parent) = dest.parent() {
3577        tokio_fs::create_dir_all(parent).await?;
3578    }
3579    if let Ok(metadata) = tokio_fs::symlink_metadata(dest).await {
3580        if metadata.is_dir() {
3581            tokio_fs::remove_dir_all(dest).await?;
3582        } else {
3583            tokio_fs::remove_file(dest).await?;
3584        }
3585    }
3586    tokio_fs::symlink(src, dest).await?;
3587    Ok(())
3588}
3589
3590async fn hardlink_file(src: &Path, dest: &Path) -> Result<()> {
3591    if !is_file(src).await {
3592        return Err(anyhow!("missing source file {}", src.display()));
3593    }
3594    if let Some(parent) = dest.parent() {
3595        tokio_fs::create_dir_all(parent).await?;
3596    }
3597    if let Ok(metadata) = tokio_fs::symlink_metadata(dest).await {
3598        if metadata.is_dir() {
3599            return Err(anyhow!("destination {} is a directory", dest.display()));
3600        }
3601        tokio_fs::remove_file(dest).await?;
3602    }
3603    tokio_fs::hard_link(src, dest).await?;
3604    Ok(())
3605}
3606
3607async fn is_dir(path: &Path) -> bool {
3608    tokio_fs::metadata(path)
3609        .await
3610        .map(|meta| meta.is_dir())
3611        .unwrap_or(false)
3612}
3613
3614async fn is_file(path: &Path) -> bool {
3615    tokio_fs::metadata(path)
3616        .await
3617        .map(|meta| meta.is_file())
3618        .unwrap_or(false)
3619}
3620
3621async fn spawn_blocking_result<F, T>(f: F) -> Result<T>
3622where
3623    F: FnOnce() -> Result<T> + Send + 'static,
3624    T: Send + 'static,
3625{
3626    match task::spawn_blocking(f).await {
3627        Ok(result) => result,
3628        Err(err) => Err(anyhow!("blocking task failed: {}", err)),
3629    }
3630}
3631
3632fn update_chainspec_contents(
3633    contents: &str,
3634    protocol_version: &str,
3635    activation_point: &str,
3636    activation_is_string: bool,
3637    network_name: &str,
3638    total_nodes: u32,
3639) -> Result<String> {
3640    let activation_value = if activation_is_string {
3641        format!("'{activation_point}'")
3642    } else {
3643        activation_point.to_string()
3644    };
3645    let updated =
3646        replace_toml_section_value(contents, "protocol", "activation_point", &activation_value)?;
3647    let updated = replace_toml_section_value(
3648        &updated,
3649        "protocol",
3650        "version",
3651        &format!("'{protocol_version}'"),
3652    )?;
3653    let updated =
3654        replace_toml_section_value(&updated, "network", "name", &format!("'{network_name}'"))?;
3655    replace_toml_section_value(
3656        &updated,
3657        "core",
3658        "validator_slots",
3659        &total_nodes.to_string(),
3660    )
3661}
3662
3663fn replace_toml_section_value(
3664    contents: &str,
3665    section: &str,
3666    key: &str,
3667    value: &str,
3668) -> Result<String> {
3669    let mut lines = Vec::new();
3670    let mut current_section = String::new();
3671    let mut replaced = false;
3672
3673    for line in contents.lines() {
3674        let trimmed = line.trim_start();
3675        if trimmed.starts_with('[') && trimmed.ends_with(']') {
3676            current_section = trimmed
3677                .trim_start_matches('[')
3678                .trim_end_matches(']')
3679                .to_string();
3680            lines.push(line.to_string());
3681            continue;
3682        }
3683
3684        if current_section == section && trimmed.starts_with(&format!("{key} =")) {
3685            let indent = &line[..line.len() - trimmed.len()];
3686            lines.push(format!("{indent}{key} = {value}"));
3687            replaced = true;
3688            continue;
3689        }
3690
3691        lines.push(line.to_string());
3692    }
3693
3694    if !replaced {
3695        return Err(anyhow!("missing {}.{} in chainspec", section, key));
3696    }
3697
3698    let mut output = lines.join("\n");
3699    if contents.ends_with('\n') {
3700        output.push('\n');
3701    }
3702    Ok(output)
3703}
3704
3705fn set_string(root: &mut toml::Value, path: &[&str], value: String) -> Result<()> {
3706    set_value(root, path, toml::Value::String(value))
3707}
3708
3709fn set_integer(root: &mut toml::Value, path: &[&str], value: i64) -> Result<()> {
3710    set_value(root, path, toml::Value::Integer(value))
3711}
3712
3713fn set_array(root: &mut toml::Value, path: &[&str], values: Vec<String>) -> Result<()> {
3714    let array = values.into_iter().map(toml::Value::String).collect();
3715    set_value(root, path, toml::Value::Array(array))
3716}
3717
3718fn set_bool(root: &mut toml::Value, path: &[&str], value: bool) -> Result<()> {
3719    set_value(root, path, toml::Value::Boolean(value))
3720}
3721
3722fn set_joining_sync_mode(root: &mut toml::Value) -> Result<()> {
3723    let table = root
3724        .as_table()
3725        .ok_or_else(|| anyhow!("TOML root is not a table"))?;
3726    let node = table.get("node").and_then(toml::Value::as_table);
3727
3728    if node
3729        .map(|node| node.contains_key("sync_to_genesis"))
3730        .unwrap_or(false)
3731    {
3732        set_bool(root, &["node", "sync_to_genesis"], false)
3733    } else if table.contains_key("sync_to_genesis") {
3734        set_bool(root, &["sync_to_genesis"], false)
3735    } else {
3736        set_string(root, &["node", "sync_handling"], "ttl".to_string())
3737    }
3738}
3739
3740fn set_value(root: &mut toml::Value, path: &[&str], value: toml::Value) -> Result<()> {
3741    let table = root
3742        .as_table_mut()
3743        .ok_or_else(|| anyhow!("TOML root is not a table"))?;
3744
3745    let mut current = table;
3746    for key in &path[..path.len() - 1] {
3747        current = ensure_table(current, key);
3748    }
3749    current.insert(path[path.len() - 1].to_string(), value);
3750    Ok(())
3751}
3752
3753fn ensure_table<'a>(table: &'a mut toml::value::Table, key: &str) -> &'a mut toml::value::Table {
3754    if !table.contains_key(key) {
3755        table.insert(
3756            key.to_string(),
3757            toml::Value::Table(toml::value::Table::new()),
3758        );
3759    }
3760    table
3761        .get_mut(key)
3762        .and_then(|v| v.as_table_mut())
3763        .expect("table entry is not a table")
3764}
3765
3766#[cfg(test)]
3767pub(crate) fn test_env_lock() -> &'static std::sync::Mutex<()> {
3768    static LOCK: std::sync::OnceLock<std::sync::Mutex<()>> = std::sync::OnceLock::new();
3769    LOCK.get_or_init(|| std::sync::Mutex::new(()))
3770}
3771
3772#[cfg(test)]
3773mod tests {
3774    use super::*;
3775    use serde_json::{Value, json};
3776    use std::env;
3777    use std::ffi::OsString;
3778    use std::os::unix::process::ExitStatusExt;
3779    use std::path::{Path, PathBuf};
3780    use std::sync::{Arc, MutexGuard};
3781    use tempfile::TempDir;
3782
3783    struct TestDataEnv {
3784        _lock: MutexGuard<'static, ()>,
3785        temp_dir: TempDir,
3786        old_home: Option<OsString>,
3787        old_xdg_data_home: Option<OsString>,
3788    }
3789
3790    impl TestDataEnv {
3791        fn new() -> Self {
3792            let lock = crate::assets::test_env_lock()
3793                .lock()
3794                .unwrap_or_else(|poisoned| poisoned.into_inner());
3795            let temp_dir = tempfile::tempdir().expect("temp dir");
3796            let old_home = env::var_os("HOME");
3797            let old_xdg_data_home = env::var_os("XDG_DATA_HOME");
3798            unsafe {
3799                env::set_var("HOME", temp_dir.path());
3800                env::set_var("XDG_DATA_HOME", temp_dir.path().join("xdg-data"));
3801            }
3802            Self {
3803                _lock: lock,
3804                temp_dir,
3805                old_home,
3806                old_xdg_data_home,
3807            }
3808        }
3809
3810        fn root(&self) -> &Path {
3811            self.temp_dir.path()
3812        }
3813    }
3814
3815    impl Drop for TestDataEnv {
3816        fn drop(&mut self) {
3817            if let Some(value) = &self.old_home {
3818                unsafe {
3819                    env::set_var("HOME", value);
3820                }
3821            } else {
3822                unsafe {
3823                    env::remove_var("HOME");
3824                }
3825            }
3826            if let Some(value) = &self.old_xdg_data_home {
3827                unsafe {
3828                    env::set_var("XDG_DATA_HOME", value);
3829                }
3830            } else {
3831                unsafe {
3832                    env::remove_var("XDG_DATA_HOME");
3833                }
3834            }
3835        }
3836    }
3837
3838    async fn create_fake_binary(path: &Path, label: &str) -> Result<()> {
3839        write_executable_script(
3840            path,
3841            &format!(
3842                "#!/bin/sh\nset -eu\nif [ \"${{1:-}}\" = \"--version\" ]; then\n  echo \"{label} 1.0.0\"\n  exit 0\nfi\nexit 0\n"
3843            ),
3844        )
3845        .await
3846    }
3847
3848    async fn create_test_custom_asset_sources(
3849        root: &Path,
3850        name: &str,
3851    ) -> Result<CustomAssetInstallOptions> {
3852        let source_dir = root.join("sources").join(name);
3853        tokio_fs::create_dir_all(&source_dir).await?;
3854        let node_path = source_dir.join("casper-node");
3855        let sidecar_path = source_dir.join("casper-sidecar");
3856        create_fake_binary(&node_path, "casper-node").await?;
3857        create_fake_binary(&sidecar_path, "casper-sidecar").await?;
3858        let chainspec = source_dir.join("chainspec.toml");
3859        let node_config = source_dir.join("node-config.toml");
3860        let sidecar_config = source_dir.join("sidecar-config.toml");
3861        tokio_fs::write(
3862            &chainspec,
3863            "\
3864[protocol]
3865activation_point = 1
3866version = '1.0.0'
3867
3868[network]
3869name = 'casper-dev'
3870
3871[core]
3872validator_slots = 4
3873rewards_handling = { type = 'standard' }
3874",
3875        )
3876        .await?;
3877        tokio_fs::write(&node_config, "").await?;
3878        tokio_fs::write(&sidecar_config, "").await?;
3879        Ok(CustomAssetInstallOptions {
3880            name: name.to_string(),
3881            casper_node: node_path,
3882            casper_sidecar: sidecar_path,
3883            chainspec,
3884            node_config,
3885            sidecar_config,
3886        })
3887    }
3888
3889    async fn install_test_custom_asset(env: &TestDataEnv, name: &str) -> Result<PathBuf> {
3890        let options = create_test_custom_asset_sources(env.root(), name).await?;
3891        install_custom_asset(&options).await?;
3892        custom_asset_path(name).await
3893    }
3894
3895    async fn install_test_versioned_asset(
3896        asset_version: &str,
3897        chainspec_version: &str,
3898    ) -> Result<PathBuf> {
3899        let version = parse_protocol_version(asset_version)?;
3900        let asset_dir = assets_bundle_root()?.join(format!("v{version}"));
3901        tokio_fs::create_dir_all(asset_dir.join("bin")).await?;
3902        create_fake_binary(&asset_dir.join("bin").join("casper-node"), "casper-node").await?;
3903        create_fake_binary(
3904            &asset_dir.join("bin").join("casper-sidecar"),
3905            "casper-sidecar",
3906        )
3907        .await?;
3908        tokio_fs::write(
3909            asset_dir.join("chainspec.toml"),
3910            format!(
3911                "\
3912[protocol]
3913activation_point = 1
3914version = '{chainspec_version}'
3915
3916[network]
3917name = 'casper-dev'
3918
3919[core]
3920validator_slots = 4
3921rewards_handling = {{ type = 'standard' }}
3922"
3923            ),
3924        )
3925        .await?;
3926        tokio_fs::write(asset_dir.join("node-config.toml"), "").await?;
3927        tokio_fs::write(asset_dir.join("sidecar-config.toml"), "").await?;
3928        Ok(asset_dir)
3929    }
3930
3931    async fn create_test_network_layout(
3932        root: &Path,
3933        network_name: &str,
3934        current_version: &str,
3935    ) -> Result<AssetsLayout> {
3936        let layout = AssetsLayout::new(root.join("networks"), network_name.to_string());
3937        let version_fs = protocol_version_fs(current_version);
3938        tokio_fs::create_dir_all(layout.node_bin_dir(1).join(&version_fs)).await?;
3939        tokio_fs::create_dir_all(layout.node_config_root(1).join(&version_fs)).await?;
3940        tokio_fs::create_dir_all(layout.node_dir(1).join("logs")).await?;
3941        tokio_fs::create_dir_all(layout.node_dir(1).join("storage")).await?;
3942        tokio_fs::create_dir_all(layout.node_dir(1).join("keys")).await?;
3943        tokio_fs::create_dir_all(layout.net_dir().join("chainspec")).await?;
3944        tokio_fs::write(
3945            layout
3946                .node_config_root(1)
3947                .join(&version_fs)
3948                .join("config.toml"),
3949            "[logging]\nformat = \"text\"\n",
3950        )
3951        .await?;
3952        tokio_fs::write(layout.net_dir().join("chainspec/accounts.toml"), "").await?;
3953        Ok(layout)
3954    }
3955
3956    async fn create_add_nodes_network_layout(
3957        root: &Path,
3958        network_name: &str,
3959        current_version: &str,
3960        node_count: u32,
3961    ) -> Result<AssetsLayout> {
3962        let layout = AssetsLayout::new(root.join("networks"), network_name.to_string());
3963        let version_fs = protocol_version_fs(current_version);
3964        tokio_fs::create_dir_all(layout.net_dir().join("chainspec")).await?;
3965        tokio_fs::write(
3966            layout.net_dir().join("chainspec/accounts.toml"),
3967            "network accounts\n",
3968        )
3969        .await?;
3970        tokio_fs::write(
3971            derived_accounts_path(&layout),
3972            "kind,name,key_type,derivation,path,account_hash,balance\n",
3973        )
3974        .await?;
3975
3976        for node_id in 1..=node_count {
3977            let bin_dir = layout.node_bin_dir(node_id).join(&version_fs);
3978            let config_dir = layout.node_config_root(node_id).join(&version_fs);
3979            tokio_fs::create_dir_all(&bin_dir).await?;
3980            tokio_fs::create_dir_all(&config_dir).await?;
3981            tokio_fs::create_dir_all(layout.node_dir(node_id).join("keys")).await?;
3982            tokio_fs::create_dir_all(layout.node_dir(node_id).join("logs")).await?;
3983            tokio_fs::create_dir_all(layout.node_dir(node_id).join("storage")).await?;
3984            tokio_fs::write(bin_dir.join("casper-node"), "node binary\n").await?;
3985            tokio_fs::write(bin_dir.join("casper-sidecar"), "sidecar binary\n").await?;
3986            tokio_fs::write(
3987                config_dir.join("chainspec.toml"),
3988                format!("chainspec for node {node_id}\n"),
3989            )
3990            .await?;
3991            tokio_fs::write(config_dir.join("accounts.toml"), "accounts snapshot\n").await?;
3992            tokio_fs::write(
3993                config_dir.join("config.toml"),
3994                "\
3995[logging]
3996format = \"text\"
3997
3998[consensus]
3999secret_key_path = \"old.pem\"
4000
4001[network]
4002bind_address = \"0.0.0.0:1\"
4003known_addresses = []
4004
4005[storage]
4006path = \"old-storage\"
4007
4008[rest_server]
4009address = \"0.0.0.0:2\"
4010
4011[event_stream_server]
4012address = \"0.0.0.0:3\"
4013
4014[diagnostics_port]
4015socket_path = \"old.sock\"
4016
4017[binary_port_server]
4018address = \"0.0.0.0:4\"
4019allow_request_get_trie = false
4020allow_request_speculative_exec = false
4021",
4022            )
4023            .await?;
4024            tokio_fs::write(
4025                config_dir.join("sidecar.toml"),
4026                "\
4027[rpc_server.main_server]
4028ip_address = \"127.0.0.1\"
4029port = 1
4030
4031[rpc_server.node_client]
4032ip_address = \"127.0.0.1\"
4033port = 2
4034",
4035            )
4036            .await?;
4037            tokio_fs::write(
4038                layout.node_config_root(node_id).join(NODE_LAUNCHER_STATE_FILE),
4039                format!(
4040                    "mode = \"RunNodeAsValidator\"\nversion = \"{}\"\nbinary_path = \"{}\"\nconfig_path = \"{}\"\n",
4041                    current_version,
4042                    bin_dir.join("casper-node").display(),
4043                    config_dir.join("config.toml").display(),
4044                ),
4045            )
4046            .await?;
4047        }
4048
4049        Ok(layout)
4050    }
4051
4052    fn stage_options(asset_name: &str, protocol_version: &str) -> StageProtocolOptions {
4053        StageProtocolOptions {
4054            asset: AssetSelector::Custom(asset_name.to_string()),
4055            protocol_version: protocol_version.to_string(),
4056            activation_point: 123,
4057            chainspec_overrides: Vec::new(),
4058        }
4059    }
4060
4061    fn setup_options(asset: AssetSelector, protocol_version: Option<&str>) -> SetupOptions {
4062        SetupOptions {
4063            nodes: 1,
4064            users: None,
4065            delay_seconds: 1,
4066            network_name: "casper-dev".to_string(),
4067            asset,
4068            protocol_version: protocol_version.map(str::to_string),
4069            chainspec_overrides: Vec::new(),
4070            node_log_format: "json".to_string(),
4071            seed: Arc::from("default"),
4072        }
4073    }
4074
4075    async fn generated_chainspec_version(layout: &AssetsLayout, protocol_version: &str) -> String {
4076        let version_fs = protocol_version_fs(protocol_version);
4077        let chainspec = tokio_fs::read_to_string(
4078            layout
4079                .node_config_root(1)
4080                .join(version_fs)
4081                .join("chainspec.toml"),
4082        )
4083        .await
4084        .unwrap();
4085        parse_chainspec_version(&chainspec).unwrap().to_string()
4086    }
4087
4088    fn add_nodes_options(count: u32) -> AddNodesOptions {
4089        AddNodesOptions {
4090            count,
4091            seed: Arc::from("default"),
4092        }
4093    }
4094
4095    async fn add_nodes_for_test(
4096        layout: &AssetsLayout,
4097        opts: &AddNodesOptions,
4098    ) -> Result<AddNodesResult> {
4099        add_nodes_with_trusted_hash(layout, opts, Some("trusted-hash".to_string())).await
4100    }
4101
4102    async fn clone_node_version(
4103        layout: &AssetsLayout,
4104        node_id: u32,
4105        source_version_fs: &str,
4106        target_version_fs: &str,
4107    ) {
4108        let source_bin_dir = layout.node_bin_dir(node_id).join(source_version_fs);
4109        let target_bin_dir = layout.node_bin_dir(node_id).join(target_version_fs);
4110        let source_config_dir = layout.node_config_root(node_id).join(source_version_fs);
4111        let target_config_dir = layout.node_config_root(node_id).join(target_version_fs);
4112        tokio_fs::create_dir_all(&target_bin_dir).await.unwrap();
4113        tokio_fs::create_dir_all(&target_config_dir).await.unwrap();
4114
4115        for file_name in ["casper-node", "casper-sidecar"] {
4116            tokio_fs::copy(
4117                source_bin_dir.join(file_name),
4118                target_bin_dir.join(file_name),
4119            )
4120            .await
4121            .unwrap();
4122        }
4123        for file_name in [
4124            "chainspec.toml",
4125            "accounts.toml",
4126            "config.toml",
4127            "sidecar.toml",
4128        ] {
4129            tokio_fs::copy(
4130                source_config_dir.join(file_name),
4131                target_config_dir.join(file_name),
4132            )
4133            .await
4134            .unwrap();
4135        }
4136    }
4137
4138    #[test]
4139    fn format_cspr_handles_whole_and_fractional() {
4140        assert_eq!(format_cspr(0), "0");
4141        assert_eq!(format_cspr(1), "0.000000001");
4142        assert_eq!(format_cspr(1_000_000_000), "1");
4143        assert_eq!(format_cspr(1_000_000_001), "1.000000001");
4144        assert_eq!(format_cspr(123_000_000_000), "123");
4145        assert_eq!(format_cspr(123_000_000_456), "123.000000456");
4146    }
4147
4148    #[test]
4149    fn custom_asset_name_validation() {
4150        assert!(validate_custom_asset_name("dev").is_ok());
4151        assert!(validate_custom_asset_name("v2_2_0-local").is_ok());
4152        assert!(validate_custom_asset_name("").is_err());
4153        assert!(validate_custom_asset_name("../bad").is_err());
4154        assert!(validate_custom_asset_name("with space").is_err());
4155    }
4156
4157    #[test]
4158    fn control_socket_path_uses_system_temp_dir() {
4159        let layout = AssetsLayout::new(PathBuf::from("/tmp/networks"), "casper-dev".to_string());
4160        let socket_path = layout.control_socket_path();
4161        assert_eq!(socket_path, std::env::temp_dir().join("casper-dev.socket"));
4162    }
4163
4164    #[test]
4165    fn control_socket_path_sanitizes_network_name() {
4166        let layout = AssetsLayout::new(
4167            PathBuf::from("/tmp/networks"),
4168            "my/network with spaces".to_string(),
4169        );
4170        let socket_path = layout.control_socket_path();
4171        assert_eq!(
4172            socket_path,
4173            std::env::temp_dir().join("my_network_with_spaces.socket")
4174        );
4175    }
4176
4177    #[test]
4178    fn normalize_hook_output_line_trims_newlines() {
4179        assert_eq!(normalize_hook_output_line(b"hello\n"), "hello");
4180        assert_eq!(normalize_hook_output_line(b"hello\r\n"), "hello");
4181        assert_eq!(normalize_hook_output_line(b"hello"), "hello");
4182        assert_eq!(normalize_hook_output_line(b"\n"), "");
4183    }
4184
4185    #[test]
4186    fn hook_finished_message_uses_exit_code_when_available() {
4187        let status = std::process::ExitStatus::from_raw(7 << 8);
4188        assert_eq!(
4189            hook_finished_message("post-stage-protocol", &status),
4190            Some("post-stage-protocol finished with exit code 7".to_string())
4191        );
4192    }
4193
4194    #[test]
4195    fn hook_finished_message_omits_zero_exit_code() {
4196        let status = std::process::ExitStatus::from_raw(0);
4197        assert_eq!(hook_finished_message("block-added", &status), None);
4198    }
4199
4200    #[test]
4201    fn update_chainspec_contents_preserves_inline_core_tables() {
4202        let original = "\
4203[protocol]
4204activation_point = 1
4205version = '2.1.2'
4206
4207[network]
4208name = 'casper-dev'
4209
4210[core]
4211validator_slots = 4
4212rewards_handling = { type = 'sustain', purse_address = 'uref-abc-007' }
4213trap_on_ambiguous_entity_version = false
4214
4215[highway]
4216maximum_round_length = '17 seconds'
4217";
4218
4219        let updated =
4220            update_chainspec_contents(original, "2.1.3", "2", false, "casper-dev", 5).unwrap();
4221
4222        assert!(
4223            updated.contains(
4224                "rewards_handling = { type = 'sustain', purse_address = 'uref-abc-007' }"
4225            )
4226        );
4227        assert!(!updated.contains("[core.rewards_handling]"));
4228        assert!(updated.contains("activation_point = 2"));
4229        assert!(updated.contains("version = '2.1.3'"));
4230        assert!(updated.contains("validator_slots = 5"));
4231    }
4232
4233    #[tokio::test(flavor = "current_thread")]
4234    async fn setup_versioned_asset_without_override_uses_chainspec_version() {
4235        let env = TestDataEnv::new();
4236        install_test_versioned_asset("2.1.3", "2.2.0")
4237            .await
4238            .unwrap();
4239        let layout = AssetsLayout::new(env.root().join("networks"), "casper-dev".to_string());
4240
4241        let result = setup_local(
4242            &layout,
4243            &setup_options(AssetSelector::Versioned("2.1.3".to_string()), None),
4244        )
4245        .await
4246        .unwrap();
4247
4248        assert_eq!(result.protocol_version, "2.2.0");
4249        assert!(is_dir(&layout.node_config_root(1).join("2_2_0")).await);
4250        assert!(!is_dir(&layout.node_dir(1).join("keys")).await);
4251        assert_eq!(generated_chainspec_version(&layout, "2.2.0").await, "2.2.0");
4252    }
4253
4254    #[tokio::test(flavor = "current_thread")]
4255    async fn setup_versioned_asset_with_override_uses_override_version() {
4256        let env = TestDataEnv::new();
4257        install_test_versioned_asset("2.1.3", "2.1.3")
4258            .await
4259            .unwrap();
4260        let layout = AssetsLayout::new(env.root().join("networks"), "casper-dev".to_string());
4261
4262        let result = setup_local(
4263            &layout,
4264            &setup_options(
4265                AssetSelector::Versioned("v2.1.3".to_string()),
4266                Some("2.2.0"),
4267            ),
4268        )
4269        .await
4270        .unwrap();
4271
4272        assert_eq!(result.protocol_version, "2.2.0");
4273        assert!(is_dir(&layout.node_config_root(1).join("2_2_0")).await);
4274        assert_eq!(generated_chainspec_version(&layout, "2.2.0").await, "2.2.0");
4275    }
4276
4277    #[tokio::test(flavor = "current_thread")]
4278    async fn setup_custom_asset_without_override_uses_chainspec_version() {
4279        let env = TestDataEnv::new();
4280        install_test_custom_asset(&env, "dev").await.unwrap();
4281        let layout = AssetsLayout::new(env.root().join("networks"), "casper-dev".to_string());
4282
4283        let result = setup_local(
4284            &layout,
4285            &setup_options(AssetSelector::Custom("dev".to_string()), None),
4286        )
4287        .await
4288        .unwrap();
4289
4290        assert_eq!(result.protocol_version, "1.0.0");
4291        assert!(is_dir(&layout.node_config_root(1).join("1_0_0")).await);
4292        assert_eq!(generated_chainspec_version(&layout, "1.0.0").await, "1.0.0");
4293    }
4294
4295    #[tokio::test(flavor = "current_thread")]
4296    async fn setup_custom_asset_with_override_uses_override_version() {
4297        let env = TestDataEnv::new();
4298        install_test_custom_asset(&env, "dev").await.unwrap();
4299        let layout = AssetsLayout::new(env.root().join("networks"), "casper-dev".to_string());
4300
4301        let result = setup_local(
4302            &layout,
4303            &setup_options(AssetSelector::Custom("dev".to_string()), Some("2.0.0")),
4304        )
4305        .await
4306        .unwrap();
4307
4308        assert_eq!(result.protocol_version, "2.0.0");
4309        assert!(is_dir(&layout.node_config_root(1).join("2_0_0")).await);
4310        assert_eq!(generated_chainspec_version(&layout, "2.0.0").await, "2.0.0");
4311    }
4312
4313    #[tokio::test(flavor = "current_thread")]
4314    async fn setup_applies_chainspec_overrides_to_network_and_node_configs() {
4315        let env = TestDataEnv::new();
4316        install_test_versioned_asset("2.1.3", "2.1.3")
4317            .await
4318            .unwrap();
4319        let layout = AssetsLayout::new(env.root().join("networks"), "casper-dev".to_string());
4320        let mut options = setup_options(AssetSelector::Versioned("2.1.3".to_string()), None);
4321        options.chainspec_overrides = vec![
4322            "core.minimum_era_height=1".to_string(),
4323            "core.test_values=[1, 10]".to_string(),
4324        ];
4325
4326        setup_local(&layout, &options).await.unwrap();
4327
4328        let network_chainspec =
4329            tokio_fs::read_to_string(layout.net_dir().join("chainspec/chainspec.toml"))
4330                .await
4331                .unwrap();
4332        let node_chainspec = tokio_fs::read_to_string(
4333            layout
4334                .node_config_root(1)
4335                .join("2_1_3")
4336                .join("chainspec.toml"),
4337        )
4338        .await
4339        .unwrap();
4340
4341        for chainspec in [network_chainspec, node_chainspec] {
4342            let value: toml::Value = toml::from_str(&chainspec).unwrap();
4343            assert_eq!(
4344                value
4345                    .get("core")
4346                    .and_then(|core| core.get("minimum_era_height"))
4347                    .and_then(toml::Value::as_integer),
4348                Some(1)
4349            );
4350            assert_eq!(
4351                value
4352                    .get("core")
4353                    .and_then(|core| core.get("test_values"))
4354                    .and_then(toml::Value::as_array)
4355                    .map(Vec::len),
4356                Some(2)
4357            );
4358        }
4359    }
4360
4361    #[tokio::test(flavor = "current_thread")]
4362    async fn setup_built_in_chainspec_fields_override_chainspec_overrides() {
4363        let env = TestDataEnv::new();
4364        install_test_versioned_asset("2.1.3", "2.1.3")
4365            .await
4366            .unwrap();
4367        let layout = AssetsLayout::new(env.root().join("networks"), "casper-dev".to_string());
4368        let mut options = setup_options(AssetSelector::Versioned("2.1.3".to_string()), None);
4369        options.nodes = 3;
4370        options.chainspec_overrides = vec!["core.validator_slots=99".to_string()];
4371
4372        setup_local(&layout, &options).await.unwrap();
4373
4374        let chainspec = tokio_fs::read_to_string(
4375            layout
4376                .node_config_root(1)
4377                .join("2_1_3")
4378                .join("chainspec.toml"),
4379        )
4380        .await
4381        .unwrap();
4382        let value: toml::Value = toml::from_str(&chainspec).unwrap();
4383        assert_eq!(
4384            value
4385                .get("core")
4386                .and_then(|core| core.get("validator_slots"))
4387                .and_then(toml::Value::as_integer),
4388            Some(3)
4389        );
4390    }
4391
4392    #[tokio::test(flavor = "current_thread")]
4393    async fn stage_protocol_from_versioned_asset_uses_target_protocol_version() {
4394        let env = TestDataEnv::new();
4395        install_test_versioned_asset("2.1.3", "2.1.3")
4396            .await
4397            .unwrap();
4398        let layout = create_test_network_layout(env.root(), "casper-dev", "1.0.0")
4399            .await
4400            .unwrap();
4401
4402        stage_protocol(
4403            &layout,
4404            &StageProtocolOptions {
4405                asset: AssetSelector::Versioned("2.1.3".to_string()),
4406                protocol_version: "3.0.0".to_string(),
4407                activation_point: 123,
4408                chainspec_overrides: Vec::new(),
4409            },
4410        )
4411        .await
4412        .unwrap();
4413
4414        assert!(is_dir(&layout.node_config_root(1).join("3_0_0")).await);
4415        assert_eq!(generated_chainspec_version(&layout, "3.0.0").await, "3.0.0");
4416        assert!(
4417            !is_file(&pending_post_stage_protocol_hook_path(
4418                &layout.hooks_dir(),
4419                "3.0.0"
4420            ))
4421            .await
4422        );
4423    }
4424
4425    #[tokio::test(flavor = "current_thread")]
4426    async fn stage_protocol_applies_chainspec_overrides_to_staged_node_configs() {
4427        let env = TestDataEnv::new();
4428        install_test_custom_asset(&env, "dev").await.unwrap();
4429        let layout = create_add_nodes_network_layout(env.root(), "casper-dev", "1.0.0", 2)
4430            .await
4431            .unwrap();
4432        let mut options = stage_options("dev", "2.0.0");
4433        options.chainspec_overrides = vec![
4434            "core.minimum_era_height=1".to_string(),
4435            "core.test_values=[1, 10]".to_string(),
4436        ];
4437
4438        stage_protocol(&layout, &options).await.unwrap();
4439
4440        for node_id in 1..=2 {
4441            let chainspec = tokio_fs::read_to_string(
4442                layout
4443                    .node_config_root(node_id)
4444                    .join("2_0_0")
4445                    .join("chainspec.toml"),
4446            )
4447            .await
4448            .unwrap();
4449            let value: toml::Value = toml::from_str(&chainspec).unwrap();
4450            assert_eq!(
4451                value
4452                    .get("core")
4453                    .and_then(|core| core.get("minimum_era_height"))
4454                    .and_then(toml::Value::as_integer),
4455                Some(1)
4456            );
4457            assert_eq!(
4458                value
4459                    .get("core")
4460                    .and_then(|core| core.get("test_values"))
4461                    .and_then(toml::Value::as_array)
4462                    .map(Vec::len),
4463                Some(2)
4464            );
4465        }
4466    }
4467
4468    #[tokio::test(flavor = "current_thread")]
4469    async fn stage_protocol_built_in_fields_override_chainspec_overrides() {
4470        let env = TestDataEnv::new();
4471        install_test_custom_asset(&env, "dev").await.unwrap();
4472        let layout = create_test_network_layout(env.root(), "casper-dev", "1.0.0")
4473            .await
4474            .unwrap();
4475        let mut options = stage_options("dev", "2.0.0");
4476        options.chainspec_overrides = vec![
4477            "protocol.activation_point=999".to_string(),
4478            "core.validator_slots=99".to_string(),
4479        ];
4480
4481        stage_protocol(&layout, &options).await.unwrap();
4482
4483        let chainspec = tokio_fs::read_to_string(
4484            layout
4485                .node_config_root(1)
4486                .join("2_0_0")
4487                .join("chainspec.toml"),
4488        )
4489        .await
4490        .unwrap();
4491        let value: toml::Value = toml::from_str(&chainspec).unwrap();
4492        assert_eq!(
4493            value
4494                .get("protocol")
4495                .and_then(|protocol| protocol.get("activation_point"))
4496                .and_then(toml::Value::as_integer),
4497            Some(123)
4498        );
4499        assert_eq!(
4500            value
4501                .get("core")
4502                .and_then(|core| core.get("validator_slots"))
4503                .and_then(toml::Value::as_integer),
4504            Some(1)
4505        );
4506    }
4507
4508    #[tokio::test(flavor = "current_thread")]
4509    async fn add_nodes_creates_managed_non_genesis_node_assets() {
4510        let env = TestDataEnv::new();
4511        let layout = create_add_nodes_network_layout(env.root(), "casper-dev", "1.0.0", 1)
4512            .await
4513            .unwrap();
4514        let accounts_before =
4515            tokio_fs::read_to_string(layout.net_dir().join("chainspec/accounts.toml"))
4516                .await
4517                .unwrap();
4518
4519        let result = add_nodes_for_test(&layout, &add_nodes_options(1))
4520            .await
4521            .unwrap();
4522
4523        assert_eq!(result.added_node_ids, vec![2]);
4524        assert_eq!(result.total_nodes, 2);
4525        let version_dir = layout.node_config_root(2).join("1_0_0");
4526        assert!(is_file(&layout.node_bin_dir(2).join("1_0_0/casper-node")).await);
4527        assert!(is_file(&layout.node_bin_dir(2).join("1_0_0/casper-sidecar")).await);
4528        assert!(is_file(&version_dir.join("chainspec.toml")).await);
4529        assert!(is_file(&version_dir.join("accounts.toml")).await);
4530        assert!(is_file(&version_dir.join("config.toml")).await);
4531        assert!(is_file(&version_dir.join("sidecar.toml")).await);
4532        assert!(!is_dir(&layout.node_dir(2).join("keys")).await);
4533        assert!(!is_file(&layout.node_dir(2).join("keys/secret_key.pem")).await);
4534        assert_eq!(
4535            tokio_fs::read_to_string(layout.net_dir().join("chainspec/accounts.toml"))
4536                .await
4537                .unwrap(),
4538            accounts_before
4539        );
4540
4541        let config: toml::Value = toml::from_str(
4542            &tokio_fs::read_to_string(version_dir.join("config.toml"))
4543                .await
4544                .unwrap(),
4545        )
4546        .unwrap();
4547        assert_eq!(
4548            config
4549                .get("network")
4550                .and_then(|value| value.get("bind_address"))
4551                .and_then(toml::Value::as_str),
4552            Some("0.0.0.0:22102")
4553        );
4554        assert_eq!(
4555            config
4556                .get("rest_server")
4557                .and_then(|value| value.get("address"))
4558                .and_then(toml::Value::as_str),
4559            Some("0.0.0.0:14102")
4560        );
4561        assert_eq!(
4562            config
4563                .get("node")
4564                .and_then(|value| value.get("trusted_hash"))
4565                .and_then(toml::Value::as_str),
4566            Some("trusted-hash")
4567        );
4568        assert_eq!(
4569            config.get("sync_handling").and_then(toml::Value::as_str),
4570            None
4571        );
4572        assert_eq!(
4573            config
4574                .get("node")
4575                .and_then(|value| value.get("sync_handling"))
4576                .and_then(toml::Value::as_str),
4577            Some("ttl")
4578        );
4579
4580        let summary = tokio_fs::read_to_string(derived_accounts_path(&layout))
4581            .await
4582            .unwrap();
4583        assert!(summary.contains("node,node-2,secp256k1,bip32,m/44'/506'/0'/0/1,"));
4584        assert!(summary.trim_end().ends_with(",0"));
4585    }
4586
4587    #[tokio::test(flavor = "current_thread")]
4588    async fn rollback_added_nodes_removes_assets_and_summary_rows() {
4589        let env = TestDataEnv::new();
4590        let layout = create_add_nodes_network_layout(env.root(), "casper-dev", "1.0.0", 1)
4591            .await
4592            .unwrap();
4593        add_nodes_for_test(&layout, &add_nodes_options(1))
4594            .await
4595            .unwrap();
4596
4597        rollback_added_nodes(&layout, &[2]).await.unwrap();
4598
4599        assert!(!is_dir(&layout.node_dir(2)).await);
4600        let summary = tokio_fs::read_to_string(derived_accounts_path(&layout))
4601            .await
4602            .unwrap();
4603        assert!(!summary.contains("node,node-2,"));
4604        assert_eq!(
4605            summary.trim_end(),
4606            "kind,name,key_type,derivation,path,account_hash,balance"
4607        );
4608    }
4609
4610    #[tokio::test(flavor = "current_thread")]
4611    async fn add_nodes_rejects_zero_count() {
4612        let env = TestDataEnv::new();
4613        let layout = create_add_nodes_network_layout(env.root(), "casper-dev", "1.0.0", 1)
4614            .await
4615            .unwrap();
4616
4617        let err = add_nodes_for_test(&layout, &add_nodes_options(0))
4618            .await
4619            .unwrap_err();
4620
4621        assert!(err.to_string().contains("count must be greater than 0"));
4622    }
4623
4624    #[tokio::test(flavor = "current_thread")]
4625    async fn add_nodes_rejects_non_contiguous_existing_nodes() {
4626        let env = TestDataEnv::new();
4627        let layout = create_add_nodes_network_layout(env.root(), "casper-dev", "1.0.0", 1)
4628            .await
4629            .unwrap();
4630        tokio_fs::rename(layout.node_dir(1), layout.node_dir(2))
4631            .await
4632            .unwrap();
4633
4634        let err = add_nodes_for_test(&layout, &add_nodes_options(1))
4635            .await
4636            .unwrap_err();
4637
4638        assert!(err.to_string().contains("contiguous"));
4639    }
4640
4641    #[tokio::test(flavor = "current_thread")]
4642    async fn add_nodes_copies_active_and_future_staged_versions() {
4643        let env = TestDataEnv::new();
4644        let layout = create_add_nodes_network_layout(env.root(), "casper-dev", "1.0.0", 1)
4645            .await
4646            .unwrap();
4647        clone_node_version(&layout, 1, "1_0_0", "2_0_0").await;
4648
4649        add_nodes_for_test(&layout, &add_nodes_options(1))
4650            .await
4651            .unwrap();
4652
4653        assert!(is_dir(&layout.node_bin_dir(2).join("1_0_0")).await);
4654        assert!(is_file(&layout.node_bin_dir(2).join("2_0_0/casper-node")).await);
4655        assert!(is_file(&layout.node_bin_dir(2).join("2_0_0/casper-sidecar")).await);
4656        assert!(is_file(&layout.node_config_root(2).join("2_0_0/chainspec.toml")).await);
4657        assert!(is_file(&layout.node_config_root(2).join("2_0_0/accounts.toml")).await);
4658        assert!(is_file(&layout.node_config_root(2).join("2_0_0/config.toml")).await);
4659        assert!(is_file(&layout.node_config_root(2).join("2_0_0/sidecar.toml")).await);
4660
4661        let future_config: toml::Value = toml::from_str(
4662            &tokio_fs::read_to_string(layout.node_config_root(2).join("2_0_0/config.toml"))
4663                .await
4664                .unwrap(),
4665        )
4666        .unwrap();
4667        assert_eq!(
4668            future_config
4669                .get("network")
4670                .and_then(|value| value.get("bind_address"))
4671                .and_then(toml::Value::as_str),
4672            Some("0.0.0.0:22102")
4673        );
4674        assert_eq!(
4675            future_config
4676                .get("node")
4677                .and_then(|value| value.get("trusted_hash"))
4678                .and_then(toml::Value::as_str),
4679            Some("trusted-hash")
4680        );
4681
4682        let launcher_state =
4683            tokio_fs::read_to_string(layout.node_config_root(2).join(NODE_LAUNCHER_STATE_FILE))
4684                .await
4685                .unwrap();
4686        assert!(launcher_state.contains("version = \"1.0.0\""));
4687        assert!(launcher_state.contains("1_0_0/config.toml"));
4688    }
4689
4690    #[tokio::test(flavor = "current_thread")]
4691    async fn add_nodes_skips_historical_versions() {
4692        let env = TestDataEnv::new();
4693        let layout = create_add_nodes_network_layout(env.root(), "casper-dev", "2.0.0", 1)
4694            .await
4695            .unwrap();
4696        clone_node_version(&layout, 1, "2_0_0", "1_0_0").await;
4697
4698        add_nodes_for_test(&layout, &add_nodes_options(1))
4699            .await
4700            .unwrap();
4701
4702        assert!(is_dir(&layout.node_bin_dir(2).join("2_0_0")).await);
4703        assert!(!is_dir(&layout.node_bin_dir(2).join("1_0_0")).await);
4704        assert!(!is_dir(&layout.node_config_root(2).join("1_0_0")).await);
4705    }
4706
4707    #[tokio::test(flavor = "current_thread")]
4708    async fn add_nodes_disables_legacy_genesis_sync_mode() {
4709        let env = TestDataEnv::new();
4710        let layout = create_add_nodes_network_layout(env.root(), "casper-dev", "1.0.0", 1)
4711            .await
4712            .unwrap();
4713        let source_config = layout.node_config_root(1).join("1_0_0/config.toml");
4714        let contents = tokio_fs::read_to_string(&source_config).await.unwrap();
4715        tokio_fs::write(
4716            &source_config,
4717            format!("sync_to_genesis = true\n\n{contents}"),
4718        )
4719        .await
4720        .unwrap();
4721
4722        add_nodes_for_test(&layout, &add_nodes_options(1))
4723            .await
4724            .unwrap();
4725
4726        let generated_config = layout.node_config_root(2).join("1_0_0/config.toml");
4727        let config: toml::Value =
4728            toml::from_str(&tokio_fs::read_to_string(generated_config).await.unwrap()).unwrap();
4729        assert_eq!(
4730            config.get("sync_to_genesis").and_then(toml::Value::as_bool),
4731            Some(false)
4732        );
4733        assert!(config.get("sync_handling").is_none());
4734    }
4735
4736    #[tokio::test(flavor = "current_thread")]
4737    async fn add_nodes_disables_legacy_node_genesis_sync_mode() {
4738        let env = TestDataEnv::new();
4739        let layout = create_add_nodes_network_layout(env.root(), "casper-dev", "1.0.0", 1)
4740            .await
4741            .unwrap();
4742        let source_config = layout.node_config_root(1).join("1_0_0/config.toml");
4743        let contents = tokio_fs::read_to_string(&source_config).await.unwrap();
4744        tokio_fs::write(
4745            &source_config,
4746            format!("{contents}\n[node]\nsync_to_genesis = true\n"),
4747        )
4748        .await
4749        .unwrap();
4750
4751        add_nodes_for_test(&layout, &add_nodes_options(1))
4752            .await
4753            .unwrap();
4754
4755        let generated_config = layout.node_config_root(2).join("1_0_0/config.toml");
4756        let config: toml::Value =
4757            toml::from_str(&tokio_fs::read_to_string(generated_config).await.unwrap()).unwrap();
4758        assert_eq!(
4759            config
4760                .get("node")
4761                .and_then(|value| value.get("sync_to_genesis"))
4762                .and_then(toml::Value::as_bool),
4763            Some(false)
4764        );
4765        assert!(
4766            config
4767                .get("node")
4768                .and_then(|value| value.get("sync_handling"))
4769                .is_none()
4770        );
4771    }
4772
4773    #[tokio::test(flavor = "current_thread")]
4774    async fn add_nodes_rejects_migrating_launcher_state() {
4775        let env = TestDataEnv::new();
4776        let layout = create_add_nodes_network_layout(env.root(), "casper-dev", "1.0.0", 1)
4777            .await
4778            .unwrap();
4779        tokio_fs::write(
4780            layout.node_config_root(1).join(NODE_LAUNCHER_STATE_FILE),
4781            "mode = \"MigrateData\"\n",
4782        )
4783        .await
4784        .unwrap();
4785
4786        let err = add_nodes_for_test(&layout, &add_nodes_options(1))
4787            .await
4788            .unwrap_err();
4789
4790        assert!(err.to_string().contains("migration completes"));
4791    }
4792
4793    #[tokio::test(flavor = "current_thread")]
4794    async fn add_nodes_rejects_mixed_active_versions() {
4795        let env = TestDataEnv::new();
4796        let layout = create_add_nodes_network_layout(env.root(), "casper-dev", "1.0.0", 2)
4797            .await
4798            .unwrap();
4799        tokio_fs::write(
4800            layout.node_config_root(2).join(NODE_LAUNCHER_STATE_FILE),
4801            "mode = \"RunNodeAsValidator\"\nversion = \"2.0.0\"\n",
4802        )
4803        .await
4804        .unwrap();
4805
4806        let err = add_nodes_for_test(&layout, &add_nodes_options(1))
4807            .await
4808            .unwrap_err();
4809
4810        assert!(
4811            err.to_string()
4812                .contains("active protocol versions are mixed")
4813        );
4814    }
4815
4816    #[tokio::test(flavor = "current_thread")]
4817    async fn ensure_network_hook_samples_creates_executable_samples() {
4818        let env = TestDataEnv::new();
4819        let layout = create_test_network_layout(env.root(), "casper-dev", "1.0.0")
4820            .await
4821            .unwrap();
4822
4823        ensure_network_hook_samples(&layout).await.unwrap();
4824
4825        for sample in [
4826            PRE_GENESIS_SAMPLE,
4827            POST_GENESIS_SAMPLE,
4828            BLOCK_ADDED_SAMPLE,
4829            PRE_STAGE_PROTOCOL_SAMPLE,
4830            POST_STAGE_PROTOCOL_SAMPLE,
4831        ] {
4832            let sample_path = layout.hooks_dir().join(sample);
4833            assert!(is_file(&sample_path).await);
4834            assert_ne!(
4835                tokio_fs::metadata(&sample_path)
4836                    .await
4837                    .unwrap()
4838                    .permissions()
4839                    .mode()
4840                    & 0o111,
4841                0
4842            );
4843        }
4844    }
4845
4846    #[tokio::test(flavor = "current_thread")]
4847    async fn teardown_preserves_network_hooks_and_removes_other_assets() {
4848        let env = TestDataEnv::new();
4849        let layout = create_test_network_layout(env.root(), "casper-dev", "1.0.0")
4850            .await
4851            .unwrap();
4852        ensure_network_hook_samples(&layout).await.unwrap();
4853
4854        let active_hook = layout.hooks_dir().join(PRE_GENESIS_HOOK);
4855        let hook_marker = layout.hook_work_dir(PRE_GENESIS_HOOK).join("marker.txt");
4856        let node_marker = layout.node_dir(1).join("storage").join("marker.txt");
4857        let state_path = layout.net_dir().join("state.json");
4858
4859        write_executable_script(&active_hook, "#!/bin/sh\nset -eu\n")
4860            .await
4861            .unwrap();
4862        tokio_fs::create_dir_all(layout.hook_work_dir(PRE_GENESIS_HOOK))
4863            .await
4864            .unwrap();
4865        tokio_fs::write(&hook_marker, "keep").await.unwrap();
4866        tokio_fs::write(&node_marker, "remove").await.unwrap();
4867        tokio_fs::write(&state_path, "{}").await.unwrap();
4868
4869        teardown(&layout).await.unwrap();
4870
4871        assert!(is_dir(&layout.net_dir()).await);
4872        assert!(is_file(&active_hook).await);
4873        assert!(is_file(&hook_marker).await);
4874        assert!(!is_dir(&layout.nodes_dir()).await);
4875        assert!(!is_dir(&layout.net_dir().join("chainspec")).await);
4876        assert!(!is_file(&state_path).await);
4877    }
4878
4879    #[tokio::test(flavor = "current_thread")]
4880    async fn install_custom_asset_installs_only_asset_symlinks() {
4881        let env = TestDataEnv::new();
4882        let asset_dir = install_test_custom_asset(&env, "dev").await.unwrap();
4883
4884        assert!(is_file(&asset_dir.join("bin").join("casper-node")).await);
4885        assert!(is_file(&asset_dir.join("bin").join("casper-sidecar")).await);
4886        assert!(is_file(&asset_dir.join("chainspec.toml")).await);
4887        assert!(is_file(&asset_dir.join("node-config.toml")).await);
4888        assert!(is_file(&asset_dir.join("sidecar-config.toml")).await);
4889        assert!(!is_dir(&asset_dir.join(HOOKS_DIR_NAME)).await);
4890    }
4891
4892    #[tokio::test(flavor = "current_thread")]
4893    async fn install_custom_asset_rejects_duplicate_names_without_mutation() {
4894        let env = TestDataEnv::new();
4895        let asset_dir = install_test_custom_asset(&env, "dev").await.unwrap();
4896        let sentinel = asset_dir.join("sentinel.txt");
4897        tokio_fs::write(&sentinel, "keep").await.unwrap();
4898
4899        let duplicate = create_test_custom_asset_sources(env.root(), "dev")
4900            .await
4901            .unwrap();
4902        let err = install_custom_asset(&duplicate).await.unwrap_err();
4903
4904        assert!(err.to_string().contains("already exists"));
4905        assert_eq!(tokio_fs::read_to_string(&sentinel).await.unwrap(), "keep");
4906    }
4907
4908    #[tokio::test(flavor = "current_thread")]
4909    async fn stage_protocol_ignores_sample_hook_files() {
4910        let env = TestDataEnv::new();
4911        let asset_dir = install_test_custom_asset(&env, "dev").await.unwrap();
4912        let layout = create_test_network_layout(env.root(), "casper-dev", "1.0.0")
4913            .await
4914            .unwrap();
4915        write_executable_script(
4916            &asset_dir.join(HOOKS_DIR_NAME).join(PRE_STAGE_PROTOCOL_HOOK),
4917            "#!/bin/sh\nset -eu\necho custom-ran > \"$PWD/custom-hook-ran\"\n",
4918        )
4919        .await
4920        .unwrap();
4921        write_executable_script(
4922            &layout.hooks_dir().join(PRE_STAGE_PROTOCOL_SAMPLE),
4923            "#!/bin/sh\nset -eu\necho sample-ran > \"$PWD/sample-hook-ran\"\n",
4924        )
4925        .await
4926        .unwrap();
4927
4928        stage_protocol(&layout, &stage_options("dev", "2.0.0"))
4929            .await
4930            .unwrap();
4931
4932        assert!(
4933            !is_file(
4934                &layout
4935                    .hook_work_dir(PRE_STAGE_PROTOCOL_HOOK)
4936                    .join("sample-hook-ran")
4937            )
4938            .await
4939        );
4940        assert!(
4941            !is_file(
4942                &layout
4943                    .hook_work_dir(PRE_STAGE_PROTOCOL_HOOK)
4944                    .join("custom-hook-ran")
4945            )
4946            .await
4947        );
4948    }
4949
4950    #[tokio::test(flavor = "current_thread")]
4951    async fn stage_protocol_rolls_back_when_pre_hook_fails() {
4952        let env = TestDataEnv::new();
4953        install_test_custom_asset(&env, "dev").await.unwrap();
4954        let layout = create_test_network_layout(env.root(), "casper-dev", "1.0.0")
4955            .await
4956            .unwrap();
4957        write_executable_script(
4958            &layout.hooks_dir().join(PRE_STAGE_PROTOCOL_HOOK),
4959            "#!/bin/sh\nset -eu\necho pre-hook-start >&2\nexit 7\n",
4960        )
4961        .await
4962        .unwrap();
4963        write_executable_script(
4964            &layout.hooks_dir().join(POST_STAGE_PROTOCOL_HOOK),
4965            "#!/bin/sh\nset -eu\necho post-hook\n",
4966        )
4967        .await
4968        .unwrap();
4969        let stale_pending = pending_post_stage_protocol_hook_path(&layout.hooks_dir(), "2.0.0");
4970        tokio_fs::create_dir_all(layout.hooks_pending_dir())
4971            .await
4972            .unwrap();
4973        tokio_fs::write(&stale_pending, "stale").await.unwrap();
4974
4975        let err = stage_protocol(&layout, &stage_options("dev", "2.0.0"))
4976            .await
4977            .unwrap_err();
4978
4979        assert!(err.to_string().contains(PRE_STAGE_PROTOCOL_HOOK));
4980        assert!(!is_dir(&layout.node_bin_dir(1).join("2_0_0")).await);
4981        assert!(!is_dir(&layout.node_config_root(1).join("2_0_0")).await);
4982        assert!(
4983            !is_file(&pending_post_stage_protocol_hook_path(
4984                &layout.hooks_dir(),
4985                "2.0.0"
4986            ))
4987            .await
4988        );
4989    }
4990
4991    #[tokio::test(flavor = "current_thread")]
4992    async fn prepare_genesis_hooks_runs_pre_hook_and_writes_pending_post_hook_metadata() {
4993        let env = TestDataEnv::new();
4994        let layout = create_test_network_layout(env.root(), "casper-dev", "1.0.0")
4995            .await
4996            .unwrap();
4997        write_executable_script(
4998            &layout.hooks_dir().join(PRE_GENESIS_HOOK),
4999            "#!/bin/sh\nset -eu\nprintf '%s\n' \"$PWD\" > \"$PWD/pre-genesis-cwd\"\nprintf '%s,%s\n' \"$1\" \"$2\" > \"$PWD/pre-genesis-args\"\n",
5000        )
5001        .await
5002        .unwrap();
5003        write_executable_script(
5004            &layout.hooks_dir().join(POST_GENESIS_HOOK),
5005            "#!/bin/sh\nset -eu\necho post-genesis\n",
5006        )
5007        .await
5008        .unwrap();
5009
5010        prepare_genesis_hooks(&layout, "1.0.0").await.unwrap();
5011
5012        let expected_hook_dir = tokio_fs::canonicalize(layout.hook_work_dir(PRE_GENESIS_HOOK))
5013            .await
5014            .unwrap();
5015        assert_eq!(
5016            tokio_fs::read_to_string(
5017                layout
5018                    .hook_work_dir(PRE_GENESIS_HOOK)
5019                    .join("pre-genesis-cwd")
5020            )
5021            .await
5022            .unwrap()
5023            .trim(),
5024            expected_hook_dir.display().to_string()
5025        );
5026        assert_eq!(
5027            tokio_fs::read_to_string(
5028                layout
5029                    .hook_work_dir(PRE_GENESIS_HOOK)
5030                    .join("pre-genesis-args"),
5031            )
5032            .await
5033            .unwrap()
5034            .trim(),
5035            "casper-dev,1.0.0"
5036        );
5037
5038        let pending_path = pending_post_genesis_hook_path(&layout.hooks_dir(), "1.0.0");
5039        let pending: Value =
5040            serde_json::from_slice(&tokio_fs::read(&pending_path).await.unwrap()).unwrap();
5041        assert_eq!(pending["network_name"], "casper-dev");
5042        assert_eq!(pending["protocol_version"], "1.0.0");
5043        assert_eq!(
5044            pending["stdout_path"],
5045            layout
5046                .hook_logs_dir()
5047                .join("post-genesis-1_0_0.stdout.log")
5048                .display()
5049                .to_string()
5050        );
5051        assert_eq!(
5052            pending["stderr_path"],
5053            layout
5054                .hook_logs_dir()
5055                .join("post-genesis-1_0_0.stderr.log")
5056                .display()
5057                .to_string()
5058        );
5059    }
5060
5061    #[tokio::test(flavor = "current_thread")]
5062    async fn stage_protocol_runs_pre_hook_and_writes_pending_post_hook_metadata() {
5063        let env = TestDataEnv::new();
5064        install_test_custom_asset(&env, "dev").await.unwrap();
5065        let layout = create_test_network_layout(env.root(), "casper-dev", "1.0.0")
5066            .await
5067            .unwrap();
5068        let staged_config_dir = layout.node_config_root(1).join("2_0_0");
5069        write_executable_script(
5070            &layout.hooks_dir().join(PRE_STAGE_PROTOCOL_HOOK),
5071            format!(
5072                "#!/bin/sh\nset -eu\nconfig_dir='{}'\n[ -d \"$config_dir\" ]\n[ -f \"$config_dir/chainspec.toml\" ]\ngrep -q 'minimum_era_height = 1' \"$config_dir/chainspec.toml\"\nprintf '%s\n' \"$PWD\" > \"$PWD/pre-hook-cwd\"\nprintf '%s,%s,%s\n' \"$1\" \"$2\" \"$3\" > \"$PWD/pre-hook-args\"\nprintf '\n[hook]\nran = true\n' >> \"$config_dir/chainspec.toml\"\necho pre-stdout\necho pre-stderr >&2\n",
5073                staged_config_dir.display()
5074            ),
5075        )
5076        .await
5077        .unwrap();
5078        write_executable_script(
5079            &layout.hooks_dir().join(POST_STAGE_PROTOCOL_HOOK),
5080            "#!/bin/sh\nset -eu\necho post-hook\n",
5081        )
5082        .await
5083        .unwrap();
5084
5085        let stale_claim = post_stage_protocol_claim_path(&layout.hooks_dir(), "2.0.0");
5086        let stale_completion = post_stage_protocol_completion_path(&layout.hooks_dir(), "2.0.0");
5087        tokio_fs::create_dir_all(layout.hooks_status_dir())
5088            .await
5089            .unwrap();
5090        tokio_fs::write(&stale_claim, "stale").await.unwrap();
5091        tokio_fs::write(&stale_completion, "stale").await.unwrap();
5092
5093        let mut options = stage_options("dev", "2.0.0");
5094        options.chainspec_overrides = vec!["core.minimum_era_height=1".to_string()];
5095        stage_protocol(&layout, &options).await.unwrap();
5096
5097        let expected_net_dir =
5098            tokio_fs::canonicalize(layout.hook_work_dir(PRE_STAGE_PROTOCOL_HOOK))
5099                .await
5100                .unwrap();
5101        assert_eq!(
5102            tokio_fs::read_to_string(
5103                layout
5104                    .hook_work_dir(PRE_STAGE_PROTOCOL_HOOK)
5105                    .join("pre-hook-cwd")
5106            )
5107            .await
5108            .unwrap()
5109            .trim(),
5110            expected_net_dir.display().to_string()
5111        );
5112        assert_eq!(
5113            tokio_fs::read_to_string(
5114                layout
5115                    .hook_work_dir(PRE_STAGE_PROTOCOL_HOOK)
5116                    .join("pre-hook-args"),
5117            )
5118            .await
5119            .unwrap()
5120            .trim(),
5121            "casper-dev,2.0.0,123"
5122        );
5123
5124        let pre_logs = hook_log_paths(&layout.hook_logs_dir(), PRE_STAGE_PROTOCOL_HOOK, "2.0.0");
5125        assert_eq!(
5126            tokio_fs::read_to_string(&pre_logs.stdout)
5127                .await
5128                .unwrap()
5129                .trim(),
5130            "pre-stdout"
5131        );
5132        assert_eq!(
5133            tokio_fs::read_to_string(&pre_logs.stderr)
5134                .await
5135                .unwrap()
5136                .trim(),
5137            "pre-stderr"
5138        );
5139        assert!(
5140            tokio_fs::read_to_string(staged_config_dir.join("chainspec.toml"))
5141                .await
5142                .unwrap()
5143                .contains("[hook]\nran = true")
5144        );
5145
5146        let pending_path = pending_post_stage_protocol_hook_path(&layout.hooks_dir(), "2.0.0");
5147        let pending: Value =
5148            serde_json::from_slice(&tokio_fs::read(&pending_path).await.unwrap()).unwrap();
5149        let expected_post_hook =
5150            tokio_fs::canonicalize(layout.hooks_dir().join(POST_STAGE_PROTOCOL_HOOK))
5151                .await
5152                .unwrap();
5153        assert_eq!(pending["asset_name"], "custom/dev");
5154        assert_eq!(pending["network_name"], "casper-dev");
5155        assert_eq!(pending["protocol_version"], "2.0.0");
5156        assert_eq!(pending["activation_point"], 123);
5157        assert_eq!(
5158            pending["command_path"],
5159            expected_post_hook.display().to_string()
5160        );
5161        assert_eq!(
5162            pending["stdout_path"],
5163            layout
5164                .hook_logs_dir()
5165                .join("post-stage-protocol-2_0_0.stdout.log")
5166                .display()
5167                .to_string()
5168        );
5169        assert_eq!(
5170            pending["stderr_path"],
5171            layout
5172                .hook_logs_dir()
5173                .join("post-stage-protocol-2_0_0.stderr.log")
5174                .display()
5175                .to_string()
5176        );
5177        assert!(!is_file(&stale_claim).await);
5178        assert!(!is_file(&stale_completion).await);
5179    }
5180
5181    #[tokio::test(flavor = "current_thread")]
5182    async fn run_pending_post_genesis_hook_runs_once_with_network_cwd_and_log_redirection() {
5183        let env = TestDataEnv::new();
5184        let layout = create_test_network_layout(env.root(), "casper-dev", "1.0.0")
5185            .await
5186            .unwrap();
5187        write_executable_script(
5188            &layout.hooks_dir().join(POST_GENESIS_HOOK),
5189            "#!/bin/sh\nset -eu\nprintf '%s\n' \"$PWD\" > \"$PWD/post-genesis-cwd\"\nprintf '%s,%s\n' \"$1\" \"$2\" > \"$PWD/post-genesis-args\"\necho hook-stdout\necho hook-stderr >&2\necho run >> \"$PWD/post-genesis-count\"\n",
5190        )
5191        .await
5192        .unwrap();
5193
5194        prepare_genesis_hooks(&layout, "1.0.0").await.unwrap();
5195
5196        assert!(matches!(
5197            run_pending_post_genesis_hook(&layout).await.unwrap(),
5198            PendingPostGenesisHookResult::Succeeded
5199        ));
5200        assert!(matches!(
5201            run_pending_post_genesis_hook(&layout).await.unwrap(),
5202            PendingPostGenesisHookResult::NotRun
5203        ));
5204
5205        let expected_net_dir = tokio_fs::canonicalize(layout.hook_work_dir(POST_GENESIS_HOOK))
5206            .await
5207            .unwrap();
5208        assert_eq!(
5209            tokio_fs::read_to_string(
5210                layout
5211                    .hook_work_dir(POST_GENESIS_HOOK)
5212                    .join("post-genesis-cwd"),
5213            )
5214            .await
5215            .unwrap()
5216            .trim(),
5217            expected_net_dir.display().to_string()
5218        );
5219        assert_eq!(
5220            tokio_fs::read_to_string(
5221                layout
5222                    .hook_work_dir(POST_GENESIS_HOOK)
5223                    .join("post-genesis-args"),
5224            )
5225            .await
5226            .unwrap()
5227            .trim(),
5228            "casper-dev,1.0.0"
5229        );
5230        assert_eq!(
5231            tokio_fs::read_to_string(
5232                layout
5233                    .hook_work_dir(POST_GENESIS_HOOK)
5234                    .join("post-genesis-count"),
5235            )
5236            .await
5237            .unwrap()
5238            .trim(),
5239            "run"
5240        );
5241
5242        let post_logs = hook_log_paths(&layout.hook_logs_dir(), POST_GENESIS_HOOK, "1.0.0");
5243        assert_eq!(
5244            tokio_fs::read_to_string(&post_logs.stdout)
5245                .await
5246                .unwrap()
5247                .trim(),
5248            "hook-stdout"
5249        );
5250        assert_eq!(
5251            tokio_fs::read_to_string(&post_logs.stderr)
5252                .await
5253                .unwrap()
5254                .trim(),
5255            "hook-stderr"
5256        );
5257        assert!(is_file(&post_genesis_completion_path(&layout.hooks_dir(), "1.0.0")).await);
5258    }
5259
5260    #[tokio::test(flavor = "current_thread")]
5261    async fn run_block_added_hook_passes_payload_on_stdin_and_appends_logs() {
5262        let env = TestDataEnv::new();
5263        let layout = create_test_network_layout(env.root(), "casper-dev", "1.0.0")
5264            .await
5265            .unwrap();
5266        write_executable_script(
5267            &layout.hooks_dir().join(BLOCK_ADDED_HOOK),
5268            "#!/bin/sh\nset -eu\nprintf '%s,%s\n' \"$1\" \"$2\" >> \"$PWD/block-added-args\"\ncat >> \"$PWD/block-added-payloads\"\necho hook-stdout\necho hook-stderr >&2\n",
5269        )
5270        .await
5271        .unwrap();
5272
5273        run_block_added_hook(
5274            &layout,
5275            "1.0.0",
5276            json!({
5277                "block_hash": "abc",
5278                "height": 1,
5279                "era_id": 0,
5280            }),
5281        )
5282        .await
5283        .unwrap();
5284        run_block_added_hook(
5285            &layout,
5286            "1.0.0",
5287            json!({
5288                "block_hash": "def",
5289                "height": 2,
5290                "era_id": 0,
5291            }),
5292        )
5293        .await
5294        .unwrap();
5295
5296        assert_eq!(
5297            tokio_fs::read_to_string(
5298                layout
5299                    .hook_work_dir(BLOCK_ADDED_HOOK)
5300                    .join("block-added-args"),
5301            )
5302            .await
5303            .unwrap(),
5304            "casper-dev,1.0.0\ncasper-dev,1.0.0\n"
5305        );
5306        assert_eq!(
5307            tokio_fs::read_to_string(
5308                layout
5309                    .hook_work_dir(BLOCK_ADDED_HOOK)
5310                    .join("block-added-payloads"),
5311            )
5312            .await
5313            .unwrap(),
5314            "{\"block_hash\":\"abc\",\"height\":1,\"era_id\":0}\n{\"block_hash\":\"def\",\"height\":2,\"era_id\":0}\n"
5315        );
5316
5317        let logs = hook_log_paths(&layout.hook_logs_dir(), BLOCK_ADDED_HOOK, "1.0.0");
5318        assert_eq!(
5319            tokio_fs::read_to_string(&logs.stdout).await.unwrap(),
5320            "hook-stdout\nhook-stdout\n"
5321        );
5322        assert_eq!(
5323            tokio_fs::read_to_string(&logs.stderr).await.unwrap(),
5324            "hook-stderr\nhook-stderr\n"
5325        );
5326    }
5327}