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