casper_devnet/
cli.rs

1use crate::assets::{self, AssetsLayout, SetupOptions};
2use crate::process::{self, ProcessHandle, RunningProcess, StartPlan};
3use crate::state::{ProcessKind, ProcessRecord, ProcessStatus, State};
4use anyhow::{anyhow, Result};
5use backoff::backoff::Backoff;
6use backoff::ExponentialBackoff;
7use casper_types::contract_messages::MessagePayload;
8use casper_types::execution::ExecutionResult;
9use casper_types::U512;
10use clap::{Args, Parser, Subcommand};
11use directories::BaseDirs;
12use futures::StreamExt;
13use spinners::{Spinner, Spinners};
14use std::collections::{HashMap, HashSet};
15use std::os::unix::process::ExitStatusExt;
16use std::path::{Path, PathBuf};
17use std::sync::atomic::Ordering;
18use std::sync::Arc;
19use tokio::sync::mpsc::{unbounded_channel, UnboundedSender};
20use tokio::sync::Mutex;
21use veles_casper_rust_sdk::sse::event::SseEvent;
22use veles_casper_rust_sdk::sse::{self, config::ListenerConfig};
23
24/// CLI entrypoint for the devnet launcher.
25#[derive(Parser)]
26#[command(name = "nctl")]
27#[command(
28    about = "casper-devnet launcher for local Casper Network development networks",
29    long_about = None
30)]
31pub struct Cli {
32    #[command(subcommand)]
33    command: Command,
34}
35
36/// Top-level CLI subcommands.
37#[derive(Subcommand)]
38enum Command {
39    /// Setup assets (if needed) and start the devnet.
40    Start(StartArgs),
41    /// Manage assets bundles.
42    Assets(AssetsArgs),
43}
44
45/// Arguments for `nctl start`.
46#[derive(Args, Clone)]
47struct StartArgs {
48    /// Network name used in assets paths and configs.
49    #[arg(long, default_value = "casper-dev")]
50    network_name: String,
51
52    /// Override the base path for network runtime assets.
53    #[arg(long, value_name = "PATH")]
54    net_path: Option<PathBuf>,
55
56    /// Protocol version to use from the assets store (e.g. 2.1.1).
57    #[arg(long)]
58    protocol_version: Option<String>,
59
60    /// Number of nodes to create and start.
61    #[arg(long = "node-count", aliases = ["nodes", "validators"], default_value_t = 4)]
62    node_count: u32,
63
64    /// Number of user accounts to generate (defaults to node count).
65    #[arg(long)]
66    users: Option<u32>,
67
68    /// Genesis activation delay in seconds.
69    #[arg(long, default_value_t = 3)]
70    delay: u64,
71
72    /// Log level for child processes (passed as `RUST_LOG`).
73    #[arg(long = "log-level", default_value = "info")]
74    log_level: String,
75
76    /// Log format for node config files.
77    #[arg(long, default_value = "json")]
78    node_log_format: String,
79
80    /// Create assets and exit without starting processes.
81    #[arg(long)]
82    setup_only: bool,
83
84    /// Rebuild assets even if they already exist.
85    #[arg(long)]
86    force_setup: bool,
87
88    /// Deterministic seed for devnet key generation.
89    #[arg(long, default_value = "default")]
90    seed: Arc<str>,
91}
92
93/// Asset management arguments.
94#[derive(Args)]
95struct AssetsArgs {
96    #[command(subcommand)]
97    command: AssetsCommand,
98}
99
100/// Asset management subcommands.
101#[derive(Subcommand)]
102enum AssetsCommand {
103    /// Extract a local assets bundle into the assets store.
104    Add(AssetsAddArgs),
105    /// Download assets bundles from the upstream release.
106    Pull(AssetsPullArgs),
107    /// List available protocol versions in the assets store.
108    List,
109}
110
111/// Arguments for `nctl assets add`.
112#[derive(Args, Clone)]
113struct AssetsAddArgs {
114    /// Path to a local assets bundle (.tar.gz).
115    #[arg(value_name = "PATH")]
116    path: PathBuf,
117}
118
119/// Arguments for `nctl assets pull`.
120#[derive(Args, Clone)]
121struct AssetsPullArgs {
122    /// Target triple to select from release assets.
123    #[arg(long)]
124    target: Option<String>,
125
126    /// Re-download and replace any existing assets.
127    #[arg(long)]
128    force: bool,
129}
130
131/// Parses CLI and runs the selected subcommand.
132pub async fn run() -> Result<()> {
133    let cli = Cli::parse();
134    match cli.command {
135        Command::Start(args) => run_start(args).await,
136        Command::Assets(args) => run_assets(args).await,
137    }
138}
139
140async fn run_start(args: StartArgs) -> Result<()> {
141    let assets_root = match &args.net_path {
142        Some(path) => path.clone(),
143        None => assets::default_assets_root()?,
144    };
145    let layout = AssetsLayout::new(assets_root, args.network_name.clone());
146    let assets_path = shorten_home_path(&layout.net_dir().display().to_string());
147    println!("assets path: {}", assets_path);
148    let assets_exist = layout.exists().await;
149    if !args.setup_only && !args.force_setup && assets_exist {
150        println!("resuming network operations on {}", layout.network_name());
151    }
152    let protocol_version = resolve_protocol_version(&args.protocol_version).await?;
153
154    if args.setup_only {
155        return run_setup_only(&layout, &args, &protocol_version).await;
156    }
157
158    if args.force_setup {
159        assets::teardown(&layout).await?;
160        assets::setup_local(&layout, &setup_options(&args, &protocol_version)).await?;
161    } else if !assets_exist {
162        assets::setup_local(&layout, &setup_options(&args, &protocol_version)).await?;
163    }
164
165    if !layout.exists().await {
166        return Err(anyhow!(
167            "assets missing under {}; run with --setup-only to create them",
168            shorten_home_path(&layout.net_dir().display().to_string())
169        ));
170    }
171
172    let rust_log = args.log_level.clone();
173
174    let plan = StartPlan { rust_log };
175
176    let state_path = layout.net_dir().join("state.json");
177    let state = Arc::new(Mutex::new(State::new(state_path).await?));
178    let started = {
179        let mut state = state.lock().await;
180        process::start(&layout, &plan, &mut state).await?
181    };
182
183    print_pids(&started);
184    print_start_banner(&layout, &started).await;
185    print_derived_accounts_summary(&layout).await;
186
187    let node_ids = unique_node_ids(&started);
188    let details = format_network_details(&layout, &started).await;
189    let health = Arc::new(Mutex::new(SseHealth::new(node_ids.clone(), details)));
190    start_sse_spinner(&health).await;
191    spawn_sse_listeners(&layout, &node_ids, health, Arc::clone(&state)).await;
192
193    let (event_tx, mut event_rx) = unbounded_channel();
194    spawn_ctrlc_listener(event_tx.clone());
195    spawn_exit_watchers(started, event_tx);
196
197    if let Some(event) = event_rx.recv().await {
198        match event {
199            RunEvent::CtrlC => {
200                let mut state = state.lock().await;
201                process::stop(&mut state).await?;
202            }
203            RunEvent::ProcessExit {
204                id,
205                pid,
206                code,
207                signal,
208            } => {
209                let mut state = state.lock().await;
210                update_exited_process(&mut state, &id, code, signal).await?;
211                log_exit(&id, pid, code, signal);
212                process::stop(&mut state).await?;
213            }
214        }
215    }
216
217    Ok(())
218}
219
220async fn run_setup_only(
221    layout: &AssetsLayout,
222    args: &StartArgs,
223    protocol_version: &str,
224) -> Result<()> {
225    if args.force_setup {
226        assets::teardown(layout).await?;
227        assets::setup_local(layout, &setup_options(args, protocol_version)).await?;
228        print_derived_accounts_summary(layout).await;
229        return Ok(());
230    }
231
232    if layout.exists().await {
233        println!(
234            "assets already exist at {}; use --force-setup to rebuild",
235            shorten_home_path(&layout.net_dir().display().to_string())
236        );
237        print_derived_accounts_summary(layout).await;
238        return Ok(());
239    }
240
241    assets::setup_local(layout, &setup_options(args, protocol_version)).await?;
242    print_derived_accounts_summary(layout).await;
243    Ok(())
244}
245
246fn record_pid(record: &ProcessRecord) -> Option<u32> {
247    if let Some(handle) = &record.pid_handle {
248        let pid = handle.load(Ordering::SeqCst);
249        if pid != 0 {
250            return Some(pid);
251        }
252    }
253    record.pid
254}
255
256fn setup_options(args: &StartArgs, protocol_version: &str) -> SetupOptions {
257    SetupOptions {
258        nodes: args.node_count,
259        users: args.users,
260        delay_seconds: args.delay,
261        network_name: args.network_name.clone(),
262        protocol_version: protocol_version.to_string(),
263        node_log_format: args.node_log_format.clone(),
264        seed: Arc::clone(&args.seed),
265    }
266}
267
268fn print_pids(records: &[RunningProcess]) {
269    for record in records {
270        if let Some(pid) = record_pid(&record.record) {
271            println!(
272                "{} pid={} ({:?})",
273                record.record.id, pid, record.record.kind
274            );
275        }
276    }
277}
278
279async fn format_network_details(layout: &AssetsLayout, processes: &[RunningProcess]) -> String {
280    let symlink_root = layout.net_dir();
281    let mut node_pids: HashMap<u32, u32> = HashMap::new();
282    let mut sidecar_pids: HashMap<u32, u32> = HashMap::new();
283    let mut process_logs: HashMap<u32, Vec<(ProcessKind, u32)>> = HashMap::new();
284
285    for process in processes {
286        if let Some(pid) = record_pid(&process.record) {
287            match process.record.kind {
288                ProcessKind::Node => {
289                    node_pids.insert(process.record.node_id, pid);
290                }
291                ProcessKind::Sidecar => {
292                    sidecar_pids.insert(process.record.node_id, pid);
293                }
294            }
295            process_logs
296                .entry(process.record.node_id)
297                .or_default()
298                .push((process.record.kind.clone(), pid));
299        }
300    }
301
302    let node_ids = unique_node_ids(processes);
303
304    let mut lines = Vec::new();
305    lines.push("network details".to_string());
306    for node_id in node_ids {
307        let node_pid = node_pids
308            .get(&node_id)
309            .map(|pid| pid.to_string())
310            .unwrap_or_else(|| "-".to_string());
311        let sidecar_pid = sidecar_pids
312            .get(&node_id)
313            .map(|pid| pid.to_string())
314            .unwrap_or_else(|| "-".to_string());
315        lines.push(format!(
316            "node-{} pid={} sidecar pid={}",
317            node_id, node_pid, sidecar_pid
318        ));
319        if let Some(entries) = process_logs.get(&node_id) {
320            let mut entries = entries.clone();
321            entries.sort_by_key(|entry| process_kind_label(&entry.0).to_string());
322            for (kind, pid) in entries {
323                let (stdout_link, stderr_link) = log_symlink_paths(&symlink_root, &kind, node_id);
324                lines.push(format!(
325                    "  {} pid={} stdout={} stderr={}",
326                    process_kind_label(&kind),
327                    pid,
328                    stdout_link,
329                    stderr_link
330                ));
331            }
332        }
333        lines.push(format!("  rest:   {}", assets::rest_endpoint(node_id)));
334        lines.push(format!("  sse:    {}", assets::sse_endpoint(node_id)));
335        lines.push(format!("  rpc:    {}", assets::rpc_endpoint(node_id)));
336        lines.push(format!("  binary: {}", assets::binary_address(node_id)));
337        lines.push(format!("  gossip: {}", assets::network_address(node_id)));
338    }
339
340    lines.join("\n")
341}
342
343fn process_kind_label(kind: &ProcessKind) -> &'static str {
344    match kind {
345        ProcessKind::Node => "node",
346        ProcessKind::Sidecar => "sidecar",
347    }
348}
349
350fn shorten_home_path(path: &str) -> String {
351    let path = Path::new(path);
352    let Some(base_dirs) = BaseDirs::new() else {
353        return path.display().to_string();
354    };
355    let home = base_dirs.home_dir();
356    match path.strip_prefix(home) {
357        Ok(stripped) => {
358            let mut shorthand = PathBuf::from("~");
359            shorthand.push(stripped);
360            shorthand.display().to_string()
361        }
362        Err(_) => path.display().to_string(),
363    }
364}
365
366fn log_symlink_paths(symlink_root: &Path, kind: &ProcessKind, node_id: u32) -> (String, String) {
367    let base = match kind {
368        ProcessKind::Node => format!("node-{}", node_id),
369        ProcessKind::Sidecar => format!("sidecar-{}", node_id),
370    };
371    let stdout_link = symlink_root.join(format!("{}.stdout", base));
372    let stderr_link = symlink_root.join(format!("{}.stderr", base));
373    (
374        shorten_home_path(&stdout_link.display().to_string()),
375        shorten_home_path(&stderr_link.display().to_string()),
376    )
377}
378
379async fn print_derived_accounts_summary(layout: &AssetsLayout) {
380    if let Some(summary) = assets::derived_accounts_summary(layout).await {
381        println!("derived accounts");
382        for line in summary.lines() {
383            println!("  {}", line);
384        }
385    }
386}
387
388fn unique_node_ids(processes: &[RunningProcess]) -> Vec<u32> {
389    let mut nodes = HashSet::new();
390    for process in processes {
391        nodes.insert(process.record.node_id);
392    }
393    let mut ids: Vec<u32> = nodes.into_iter().collect();
394    ids.sort_unstable();
395    ids
396}
397
398enum RunEvent {
399    CtrlC,
400    ProcessExit {
401        id: String,
402        pid: Option<u32>,
403        code: Option<i32>,
404        signal: Option<i32>,
405    },
406}
407
408fn spawn_ctrlc_listener(tx: UnboundedSender<RunEvent>) {
409    tokio::spawn(async move {
410        if tokio::signal::ctrl_c().await.is_ok() {
411            let _ = tx.send(RunEvent::CtrlC);
412        }
413    });
414}
415
416fn spawn_exit_watchers(processes: Vec<RunningProcess>, tx: UnboundedSender<RunEvent>) {
417    for running in processes {
418        let tx = tx.clone();
419        tokio::spawn(async move {
420            let id = running.record.id.clone();
421            match running.handle {
422                ProcessHandle::Child(mut child) => {
423                    if let Ok(status) = child.wait().await {
424                        let pid = record_pid(&running.record).or_else(|| child.id());
425                        let code = status.code();
426                        let signal = status.signal();
427                        let _ = tx.send(RunEvent::ProcessExit {
428                            id: id.clone(),
429                            pid,
430                            code,
431                            signal,
432                        });
433                    }
434                }
435                ProcessHandle::Task(handle) => {
436                    let status = handle.await;
437                    let pid = record_pid(&running.record);
438                    let (code, signal) = match status {
439                        Ok(Ok(())) => (Some(0), None),
440                        Ok(Err(_)) => (None, None),
441                        Err(_) => (None, None),
442                    };
443                    let _ = tx.send(RunEvent::ProcessExit {
444                        id: id.clone(),
445                        pid,
446                        code,
447                        signal,
448                    });
449                }
450            }
451        });
452    }
453}
454
455const SSE_WAIT_MESSAGE: &str = "Waiting for SSE connection...";
456const BLOCK_WAIT_MESSAGE: &str = "Waiting for new blocks...";
457
458struct SseHealth {
459    expected_nodes: HashSet<u32>,
460    versions: HashMap<u32, String>,
461    announced: bool,
462    block_seen: bool,
463    sse_spinner: Option<Spinner>,
464    block_spinner: Option<Spinner>,
465    details: String,
466}
467
468impl SseHealth {
469    fn new(node_ids: Vec<u32>, details: String) -> Self {
470        Self {
471            expected_nodes: node_ids.into_iter().collect(),
472            versions: HashMap::new(),
473            announced: false,
474            block_seen: false,
475            sse_spinner: None,
476            block_spinner: None,
477            details,
478        }
479    }
480}
481
482async fn should_log_primary(node_id: u32, health: &Arc<Mutex<SseHealth>>) -> bool {
483    if node_id != 1 {
484        return false;
485    }
486    let state = health.lock().await;
487    state.announced
488}
489
490fn start_spinner(message: &str) -> Spinner {
491    Spinner::new(Spinners::Dots, message.to_string())
492}
493
494async fn start_sse_spinner(health: &Arc<Mutex<SseHealth>>) {
495    let mut state = health.lock().await;
496    if state.sse_spinner.is_none() {
497        state.sse_spinner = Some(start_spinner(SSE_WAIT_MESSAGE));
498    }
499}
500
501async fn spawn_sse_listeners(
502    _layout: &AssetsLayout,
503    node_ids: &[u32],
504    health: Arc<Mutex<SseHealth>>,
505    state: Arc<Mutex<State>>,
506) {
507    for node_id in node_ids {
508        let node_id = *node_id;
509        let endpoint = assets::sse_endpoint(node_id);
510        let health = Arc::clone(&health);
511        let state = Arc::clone(&state);
512        tokio::spawn(async move {
513            run_sse_listener(node_id, endpoint, health, state).await;
514        });
515    }
516}
517
518async fn run_sse_listener(
519    node_id: u32,
520    endpoint: String,
521    health: Arc<Mutex<SseHealth>>,
522    state: Arc<Mutex<State>>,
523) {
524    let mut backoff = ExponentialBackoff::default();
525
526    loop {
527        let config = match ListenerConfig::builder()
528            .with_endpoint(endpoint.clone())
529            .build()
530        {
531            Ok(config) => config,
532            Err(_) => {
533                if !sleep_backoff(&mut backoff).await {
534                    return;
535                }
536                continue;
537            }
538        };
539
540        let stream = match sse::listener(config).await {
541            Ok(stream) => {
542                backoff.reset();
543                stream
544            }
545            Err(_) => {
546                if !sleep_backoff(&mut backoff).await {
547                    return;
548                }
549                continue;
550            }
551        };
552
553        futures::pin_mut!(stream);
554        let mut stream_failed = false;
555        while let Some(event) = stream.next().await {
556            match event {
557                Ok(sse_event) => match sse_event {
558                    SseEvent::ApiVersion(version) => {
559                        record_api_version(node_id, version.to_string(), &health).await;
560                    }
561                    SseEvent::BlockAdded { block_hash, block } => {
562                        if node_id == 1 {
563                            if let Err(err) = record_last_block_height(&state, block.height()).await
564                            {
565                                eprintln!("warning: failed to record last block height: {}", err);
566                            }
567                        }
568                        if should_log_primary(node_id, &health).await {
569                            mark_block_seen(&health).await;
570                            let prefix = timestamp_prefix();
571                            println!(
572                                "{} Block {} added (height={} era={})",
573                                prefix,
574                                block_hash,
575                                block.height(),
576                                block.era_id().value()
577                            );
578                        }
579                    }
580                    SseEvent::TransactionAccepted(transaction) => {
581                        if node_id == 1 {
582                            let prefix = timestamp_prefix();
583                            println!("{} Transaction {} accepted", prefix, transaction.hash());
584                        }
585                    }
586                    SseEvent::TransactionProcessed {
587                        transaction_hash,
588                        execution_result,
589                        messages,
590                        ..
591                    } => {
592                        if node_id == 1 {
593                            let tx_hash = transaction_hash.to_string();
594                            let prefix = timestamp_prefix();
595                            log_transaction_processed(
596                                &prefix,
597                                &tx_hash,
598                                &execution_result,
599                                &messages,
600                            );
601                        }
602                    }
603                    _ => {}
604                },
605                Err(_) => {
606                    stream_failed = true;
607                    break;
608                }
609            }
610        }
611
612        if stream_failed && !sleep_backoff(&mut backoff).await {
613            return;
614        }
615    }
616}
617
618async fn record_api_version(node_id: u32, version: String, health: &Arc<Mutex<SseHealth>>) {
619    let (summary, details, sse_spinner) = {
620        let mut state = health.lock().await;
621        if !state.expected_nodes.contains(&node_id) {
622            return;
623        }
624        state.versions.insert(node_id, version);
625        if state.announced || state.versions.len() != state.expected_nodes.len() {
626            return;
627        }
628
629        let summary = version_summary(&state.versions);
630        let details = state.details.clone();
631        let sse_spinner = state.sse_spinner.take();
632        if state.block_spinner.is_none() {
633            state.block_spinner = Some(start_spinner(BLOCK_WAIT_MESSAGE));
634        }
635        state.announced = true;
636        state.block_seen = false;
637        (summary, details, sse_spinner)
638    };
639
640    if let Some(mut spinner) = sse_spinner {
641        spinner.stop_with_message("SSE connection established.".to_string());
642    }
643    println!("Network is healthy ({})", summary);
644    println!("{}", details);
645}
646
647async fn mark_block_seen(health: &Arc<Mutex<SseHealth>>) {
648    let block_spinner = {
649        let mut state = health.lock().await;
650        if state.block_seen {
651            return;
652        }
653        state.block_seen = true;
654        state.block_spinner.take()
655    };
656
657    if let Some(mut spinner) = block_spinner {
658        spinner.stop_with_message(BLOCK_WAIT_MESSAGE.to_string());
659    }
660}
661
662async fn record_last_block_height(state: &Arc<Mutex<State>>, height: u64) -> Result<()> {
663    let mut state = state.lock().await;
664    if state.last_block_height == Some(height) {
665        return Ok(());
666    }
667    state.last_block_height = Some(height);
668    state.touch().await?;
669    Ok(())
670}
671
672fn version_summary(versions: &HashMap<u32, String>) -> String {
673    let mut unique: Vec<String> = versions.values().cloned().collect();
674    unique.sort();
675    unique.dedup();
676    if unique.len() == 1 {
677        format!("version {}", unique[0])
678    } else {
679        format!("versions {}", unique.join(", "))
680    }
681}
682
683async fn sleep_backoff(backoff: &mut ExponentialBackoff) -> bool {
684    if let Some(delay) = backoff.next_backoff() {
685        tokio::time::sleep(delay).await;
686        return true;
687    }
688    false
689}
690
691fn log_transaction_processed(
692    prefix: &str,
693    transaction_hash: &str,
694    execution_result: &ExecutionResult,
695    messages: &[casper_types::contract_messages::Message],
696) {
697    let consumed = execution_result.consumed();
698    let consumed_cspr = format_cspr_u512(&consumed);
699    if let Some(error) = execution_result.error_message() {
700        println!(
701            "{} Transaction {} processed failed ({}) gas={} gas_cspr={}",
702            prefix, transaction_hash, error, consumed, consumed_cspr
703        );
704    } else {
705        println!(
706            "{} Transaction {} processed succeeded gas={} gas_cspr={}",
707            prefix, transaction_hash, consumed, consumed_cspr
708        );
709    }
710
711    for message in messages {
712        let entity = message.entity_addr().to_formatted_string();
713        let topic = message.topic_name();
714        let payload = format_message_payload(message.payload());
715        println!("{} 📨 {} {}: {}", prefix, entity, topic, payload);
716    }
717}
718
719fn timestamp_prefix() -> String {
720    time::OffsetDateTime::now_utc()
721        .format(&time::format_description::well_known::Rfc3339)
722        .unwrap_or_else(|_| "unknown-time".to_string())
723}
724
725fn format_message_payload(payload: &MessagePayload) -> String {
726    match payload {
727        MessagePayload::Bytes(bytes) => format!("0x{}", encode_hex(bytes.as_ref())),
728        MessagePayload::String(value) => format!("{:?}", value),
729    }
730}
731
732fn encode_hex(bytes: &[u8]) -> String {
733    let mut out = String::with_capacity(bytes.len() * 2);
734    for byte in bytes {
735        use std::fmt::Write;
736        let _ = write!(&mut out, "{:02x}", byte);
737    }
738    out
739}
740
741fn format_cspr_u512(motes: &U512) -> String {
742    let motes_str = motes.to_string();
743    let digits = motes_str.len();
744    if digits <= 9 {
745        let frac = format!("{:0>9}", motes_str);
746        let frac = frac.trim_end_matches('0');
747        if frac.is_empty() {
748            return "0".to_string();
749        }
750        return format!("0.{}", frac);
751    }
752
753    let split = digits - 9;
754    let (whole, frac) = motes_str.split_at(split);
755    let frac = frac.trim_end_matches('0');
756    if frac.is_empty() {
757        return whole.to_string();
758    }
759    format!("{}.{}", whole, frac)
760}
761
762async fn update_exited_process(
763    state: &mut State,
764    id: &str,
765    code: Option<i32>,
766    signal: Option<i32>,
767) -> Result<()> {
768    for record in &mut state.processes {
769        if record.id == id {
770            record.last_status = ProcessStatus::Exited;
771            record.exit_code = code;
772            record.exit_signal = signal;
773            record.stopped_at = Some(time::OffsetDateTime::now_utc());
774            break;
775        }
776    }
777    state.touch().await?;
778    Ok(())
779}
780
781fn log_exit(id: &str, pid: Option<u32>, code: Option<i32>, signal: Option<i32>) {
782    if let Some(pid) = pid {
783        if let Some(signal) = signal {
784            println!(
785                "process {} (pid {}) exited due to signal {}",
786                id, pid, signal
787            );
788        } else if let Some(code) = code {
789            println!("process {} (pid {}) exited with code {}", id, pid, code);
790        } else {
791            println!("process {} (pid {}) exited", id, pid);
792        }
793    } else if let Some(signal) = signal {
794        println!("process {} exited due to signal {}", id, signal);
795    } else if let Some(code) = code {
796        println!("process {} exited with code {}", id, code);
797    } else {
798        println!("process {} exited", id);
799    }
800}
801
802async fn print_start_banner(layout: &AssetsLayout, processes: &[RunningProcess]) {
803    let total_nodes = layout.count_nodes().await.unwrap_or(0);
804    let target = format!("all nodes ({})", total_nodes);
805    let sidecars = processes
806        .iter()
807        .filter(|proc| matches!(proc.record.kind, crate::state::ProcessKind::Sidecar))
808        .count();
809    println!(
810        "started {} process(es) for {} (sidecars: {})",
811        processes.len(),
812        target,
813        sidecars
814    );
815}
816
817fn looks_like_url(path: &Path) -> bool {
818    let value = path.to_string_lossy();
819    value.starts_with("http://") || value.starts_with("https://")
820}
821
822async fn run_assets(args: AssetsArgs) -> Result<()> {
823    match args.command {
824        AssetsCommand::Add(add) => run_assets_add(add).await,
825        AssetsCommand::Pull(pull) => run_assets_pull(pull).await,
826        AssetsCommand::List => run_assets_list().await,
827    }
828}
829
830async fn run_assets_add(args: AssetsAddArgs) -> Result<()> {
831    if looks_like_url(&args.path) {
832        return Err(anyhow!(
833            "assets URL is not supported yet; provide a local .tar.gz path"
834        ));
835    }
836    assets::install_assets_bundle(&args.path).await?;
837    println!(
838        "assets installed into {}",
839        assets::assets_bundle_root()?.display()
840    );
841    Ok(())
842}
843
844async fn run_assets_pull(args: AssetsPullArgs) -> Result<()> {
845    assets::pull_assets_bundles(args.target.as_deref(), args.force).await?;
846    Ok(())
847}
848
849async fn run_assets_list() -> Result<()> {
850    let mut versions = assets::list_bundle_versions().await?;
851    if versions.is_empty() {
852        println!("no assets found");
853        return Ok(());
854    }
855    versions.sort_by(|a, b| b.cmp(a));
856    for version in versions {
857        println!("{}", version);
858    }
859    Ok(())
860}
861
862async fn resolve_protocol_version(candidate: &Option<String>) -> Result<String> {
863    if let Some(raw) = candidate {
864        let version = assets::parse_protocol_version(raw)?;
865        if !assets::has_bundle_version(&version).await? {
866            let argv0 = std::env::args()
867                .next()
868                .unwrap_or_else(|| "casper-devnet".to_string());
869            let pull_cmd = format!("{} assets pull", argv0);
870            let add_cmd = format!("{} assets add <path-to-assets.tar.gz>", argv0);
871            return Err(anyhow!(
872                "assets for version {} not found; run `{}` or `{}`",
873                version,
874                pull_cmd,
875                add_cmd
876            ));
877        }
878        return Ok(version.to_string());
879    }
880    match assets::most_recent_bundle_version().await {
881        Ok(version) => Ok(version.to_string()),
882        Err(_) => {
883            let argv0 = std::env::args()
884                .next()
885                .unwrap_or_else(|| "casper-devnet".to_string());
886            let pull_cmd = format!("{} assets pull", argv0);
887            let add_cmd = format!("{} assets add <path-to-assets.tar.gz>", argv0);
888            Err(anyhow!(
889                "no assets found; run `{}` or `{}`",
890                pull_cmd,
891                add_cmd
892            ))
893        }
894    }
895}
896
897#[cfg(test)]
898mod tests {
899    use super::{encode_hex, format_cspr_u512, format_message_payload, shorten_home_path};
900    use casper_types::contract_messages::MessagePayload;
901    use casper_types::U512;
902    use directories::BaseDirs;
903
904    #[test]
905    fn format_cspr_u512_handles_whole_and_fractional() {
906        assert_eq!(format_cspr_u512(&U512::zero()), "0");
907        assert_eq!(format_cspr_u512(&U512::from(1u64)), "0.000000001");
908        assert_eq!(format_cspr_u512(&U512::from(1_000_000_000u64)), "1");
909        assert_eq!(
910            format_cspr_u512(&U512::from(1_000_000_001u64)),
911            "1.000000001"
912        );
913        assert_eq!(
914            format_cspr_u512(&U512::from_dec_str("123000000000").unwrap()),
915            "123"
916        );
917        assert_eq!(
918            format_cspr_u512(&U512::from_dec_str("123000000456").unwrap()),
919            "123.000000456"
920        );
921    }
922
923    #[test]
924    fn format_message_payload_renders_string_with_quotes() {
925        let payload = MessagePayload::String("hello".to_string());
926        assert_eq!(format_message_payload(&payload), "\"hello\"");
927    }
928
929    #[test]
930    fn encode_hex_renders_lowercase() {
931        assert_eq!(encode_hex(&[0x00, 0xAB, 0x0f]), "00ab0f");
932    }
933
934    #[test]
935    fn shorten_home_path_replaces_home_prefix() {
936        let Some(base_dirs) = BaseDirs::new() else {
937            return;
938        };
939        let home = base_dirs.home_dir();
940        let shortened = shorten_home_path(&home.to_string_lossy());
941        assert_eq!(shortened, "~");
942
943        let nested = home.join("devnet/logs/stdout.log");
944        let shortened_nested = shorten_home_path(&nested.to_string_lossy());
945        assert!(shortened_nested.starts_with("~"));
946        assert!(shortened_nested.contains("devnet"));
947    }
948
949    #[test]
950    fn shorten_home_path_keeps_relative_paths() {
951        let input = "relative/path";
952        assert_eq!(shorten_home_path(input), input);
953    }
954}