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