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