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