Skip to main content

ant_core/node/
mod.rs

1pub mod binary;
2pub mod daemon;
3// `LocalDevnet` wraps `ant_node::devnet::Devnet`. Gated behind `devnet`
4// so default builds of ant-core don't link ant-node at all.
5#[cfg(feature = "devnet")]
6pub mod devnet;
7pub mod events;
8pub mod process;
9pub mod registry;
10pub mod types;
11
12use std::collections::HashMap;
13use std::path::{Path, PathBuf};
14
15use crate::config;
16use crate::error::{Error, Result};
17use crate::node::binary::ProgressReporter;
18use crate::node::registry::NodeRegistry;
19use crate::node::types::{
20    AddNodeOpts, AddNodeResult, NodeConfig, NodeStatus, NodeStatusResult, NodeStatusSummary,
21    RemoveNodeResult, ResetResult,
22};
23
24/// Add one or more nodes to the registry.
25///
26/// This function:
27/// 1. Resolves the binary (download if needed)
28/// 2. Loads the registry (with file lock)
29/// 3. Validates port ranges match count
30/// 4. Creates data and log directories for each node
31/// 5. Assigns IDs and saves the registry
32///
33/// Does NOT start the nodes. Does NOT require the daemon.
34pub async fn add_nodes(
35    opts: AddNodeOpts,
36    registry_path: &Path,
37    progress: &dyn ProgressReporter,
38) -> Result<AddNodeResult> {
39    // Validate and normalize rewards address
40    validate_rewards_address(&opts.rewards_address)?;
41    let rewards_address = opts.rewards_address.trim().to_string();
42
43    // Cap the number of nodes per call to prevent accidental resource exhaustion
44    const MAX_NODES_PER_CALL: u16 = 1000;
45    if opts.count > MAX_NODES_PER_CALL {
46        return Err(Error::InvalidNodeCount {
47            count: opts.count,
48            max: MAX_NODES_PER_CALL,
49        });
50    }
51
52    // Validate port ranges match count
53    if let Some(ref port_range) = opts.node_port {
54        let range_len = port_range.len();
55        if range_len != 1 && range_len != opts.count {
56            return Err(Error::PortRangeMismatch {
57                range_len,
58                count: opts.count,
59            });
60        }
61    }
62
63    // Resolve the binary (downloads to cache if needed)
64    let install_dir = binary::binary_install_dir()?;
65    let resolved = binary::resolve_binary(
66        &opts.binary_source,
67        opts.upgrade_channel,
68        &install_dir,
69        progress,
70    )
71    .await?;
72    let cached_binary = resolved.path;
73    let version = resolved.version;
74
75    // Load registry with file lock
76    let (mut registry, _lock) = NodeRegistry::load_locked(registry_path)?;
77
78    // Build node configs
79    let mut nodes_added = Vec::with_capacity(opts.count as usize);
80    let env_map: HashMap<String, String> = opts.env_variables.into_iter().collect();
81
82    // Each node gets its own copy under the plain binary name
83    let binary_file_name = binary::BINARY_NAME;
84
85    for i in 0..opts.count {
86        let node_port = resolve_port(&opts.node_port, i, opts.count);
87
88        // We use a placeholder ID (0) here; the registry will assign the real one
89        let placeholder_id = 0;
90
91        let data_dir = node_data_dir(&opts.data_dir_path, placeholder_id);
92        let log_dir = node_log_dir(&opts.log_dir_path, placeholder_id);
93
94        let config = NodeConfig {
95            id: placeholder_id,
96            service_name: String::new(), // assigned by registry.add()
97            rewards_address: rewards_address.clone(),
98            data_dir,
99            log_dir,
100            node_port,
101            binary_path: PathBuf::new(), // placeholder, updated below
102            version: version.clone(),
103            env_variables: env_map.clone(),
104            bootstrap_peers: opts.bootstrap_peers.clone(),
105            upgrade_channel: opts.upgrade_channel,
106            evm_network: opts.evm_network,
107            eviction: None,
108        };
109
110        let assigned_id = registry.add(config);
111
112        // Now update paths with the actual assigned ID
113        let node = registry.get_mut(assigned_id)?;
114        node.data_dir = node_data_dir(&opts.data_dir_path, assigned_id);
115        node.log_dir = node_log_dir(&opts.log_dir_path, assigned_id);
116
117        // Create directories
118        std::fs::create_dir_all(&node.data_dir)?;
119        if let Some(ref log_dir) = node.log_dir {
120            std::fs::create_dir_all(log_dir)?;
121        }
122
123        // Copy the binary into this node's data directory so each node
124        // has its own copy. This allows safe per-node upgrades without
125        // affecting running nodes.
126        let node_binary = node.data_dir.join(binary_file_name);
127        std::fs::copy(&cached_binary, &node_binary)?;
128        #[cfg(unix)]
129        {
130            use std::os::unix::fs::PermissionsExt;
131            std::fs::set_permissions(&node_binary, std::fs::Permissions::from_mode(0o755))?;
132        }
133        node.binary_path = node_binary;
134
135        // Copy bootstrap_peers.toml alongside the binary so the node can
136        // discover production network peers on startup.
137        if let Some(ref bp_path) = resolved.bootstrap_peers_path {
138            let dest = node.data_dir.join(binary::BOOTSTRAP_PEERS_FILE);
139            std::fs::copy(bp_path, &dest)?;
140        }
141
142        nodes_added.push(node.clone());
143    }
144
145    registry.save()?;
146
147    Ok(AddNodeResult { nodes_added })
148}
149
150/// Remove a node from the registry.
151///
152/// Does NOT stop the node. Does NOT require the daemon.
153pub fn remove_node(node_id: u32, registry_path: &Path) -> Result<RemoveNodeResult> {
154    let (mut registry, _lock) = NodeRegistry::load_locked(registry_path)?;
155    let removed = registry.remove(node_id)?;
156    registry.save()?;
157    Ok(RemoveNodeResult { removed })
158}
159
160/// Reset all node state: remove all data directories, log directories, and clear the registry.
161///
162/// This function:
163/// 1. Loads the registry (with file lock)
164/// 2. Iterates over all registered nodes
165/// 3. Removes each node's data directory
166/// 4. Removes each node's log directory (if set)
167/// 5. Clears the registry (empties nodes, resets next_id to 1)
168///
169/// Does NOT check if nodes are running — callers must verify that first.
170pub fn reset(registry_path: &Path) -> Result<ResetResult> {
171    let (mut registry, _lock) = NodeRegistry::load_locked(registry_path)?;
172
173    let mut data_dirs_removed = Vec::new();
174    let mut log_dirs_removed = Vec::new();
175    let nodes_cleared = registry.len() as u32;
176
177    for node in registry.list() {
178        if node.data_dir.exists() {
179            std::fs::remove_dir_all(&node.data_dir)?;
180            data_dirs_removed.push(node.data_dir.clone());
181        }
182        if let Some(ref log_dir) = node.log_dir {
183            if log_dir.exists() {
184                std::fs::remove_dir_all(log_dir)?;
185                log_dirs_removed.push(log_dir.clone());
186            }
187        }
188    }
189
190    registry.clear();
191    registry.save()?;
192
193    Ok(ResetResult {
194        nodes_cleared,
195        data_dirs_removed,
196        log_dirs_removed,
197    })
198}
199
200/// Get the status of all registered nodes without the daemon.
201///
202/// Since the daemon is not running, nodes are reported as `Stopped` — except those carrying a
203/// persisted [`EvictionRecord`](crate::node::types::EvictionRecord), which are reported as `Evicted` (the marker survives independently
204/// of the daemon).
205pub fn node_status_offline(registry_path: &Path) -> Result<NodeStatusResult> {
206    let registry = NodeRegistry::load(registry_path)?;
207    let nodes: Vec<NodeStatusSummary> = registry
208        .list()
209        .iter()
210        .map(|config| {
211            let status = if config.eviction.is_some() {
212                NodeStatus::Evicted
213            } else {
214                NodeStatus::Stopped
215            };
216            NodeStatusSummary {
217                node_id: config.id,
218                name: config.service_name.clone(),
219                version: config.version.clone(),
220                status,
221                pid: None,
222                uptime_secs: None,
223                eviction: config.eviction.clone(),
224            }
225        })
226        .collect();
227    let total_stopped = nodes.len() as u32;
228    Ok(NodeStatusResult {
229        nodes,
230        total_running: 0,
231        total_stopped,
232    })
233}
234
235/// Validate that a rewards address is a valid Ethereum-style address.
236///
237/// Must be `0x` followed by exactly 40 hexadecimal characters.
238fn validate_rewards_address(address: &str) -> Result<()> {
239    let address = address.trim();
240    if address.is_empty() {
241        return Err(Error::InvalidRewardsAddress(
242            "rewards address cannot be empty".to_string(),
243        ));
244    }
245    if !address.starts_with("0x") && !address.starts_with("0X") {
246        return Err(Error::InvalidRewardsAddress(format!(
247            "rewards address must start with '0x', got '{address}'"
248        )));
249    }
250    let hex_part = &address[2..];
251    if hex_part.len() != 40 {
252        return Err(Error::InvalidRewardsAddress(format!(
253            "rewards address must be 42 characters (0x + 40 hex), got {} characters",
254            address.len()
255        )));
256    }
257    if !hex_part.chars().all(|c| c.is_ascii_hexdigit()) {
258        return Err(Error::InvalidRewardsAddress(format!(
259            "rewards address contains non-hex characters: '{address}'"
260        )));
261    }
262    Ok(())
263}
264
265/// Determine the data directory for a node.
266fn node_data_dir(custom_prefix: &Option<PathBuf>, node_id: u32) -> PathBuf {
267    match custom_prefix {
268        Some(prefix) => prefix.join(format!("node-{node_id}")),
269        None => config::data_dir()
270            .expect("Could not determine data directory")
271            .join("nodes")
272            .join(format!("node-{node_id}")),
273    }
274}
275
276/// Determine the log directory for a node.
277/// Returns `None` when no custom log dir prefix was provided (no logging by default).
278fn node_log_dir(custom_prefix: &Option<PathBuf>, node_id: u32) -> Option<PathBuf> {
279    custom_prefix
280        .as_ref()
281        .map(|prefix| prefix.join(format!("node-{node_id}")).join("logs"))
282}
283
284/// Resolve a port from a PortRange for a given node index.
285fn resolve_port(range: &Option<types::PortRange>, index: u16, _count: u16) -> Option<u16> {
286    range.as_ref().and_then(|r| r.port_at(index))
287}
288
289#[cfg(test)]
290mod tests {
291    use super::*;
292    use crate::node::binary::NoopProgress;
293    use crate::node::types::{BinarySource, EvmNetwork, PortRange};
294
295    /// A valid Ethereum address for use in tests.
296    const TEST_ADDR: &str = "0x1234567890abcdef1234567890abcdef12345678";
297
298    fn test_registry_path(dir: &std::path::Path) -> PathBuf {
299        dir.join("node_registry.json")
300    }
301
302    /// Create a fake binary that responds to --version.
303    /// On Windows, uses a .cmd extension so the shell can execute it.
304    fn create_fake_binary(dir: &std::path::Path) -> PathBuf {
305        #[cfg(unix)]
306        {
307            let binary_path = dir.join("fake-antnode");
308            std::fs::write(&binary_path, "#!/bin/sh\necho \"antnode 0.1.0-test\"\n").unwrap();
309            use std::os::unix::fs::PermissionsExt;
310            std::fs::set_permissions(&binary_path, std::fs::Permissions::from_mode(0o755)).unwrap();
311            binary_path
312        }
313        #[cfg(windows)]
314        {
315            let binary_path = dir.join("fake-antnode.cmd");
316            std::fs::write(&binary_path, "@echo off\r\necho antnode 0.1.0-test\r\n").unwrap();
317            binary_path
318        }
319    }
320
321    #[tokio::test]
322    async fn add_single_node_with_local_binary() {
323        let tmp = tempfile::tempdir().unwrap();
324        let binary = create_fake_binary(tmp.path());
325        let reg_path = test_registry_path(tmp.path());
326
327        let opts = AddNodeOpts {
328            count: 1,
329            rewards_address: TEST_ADDR.to_string(),
330            data_dir_path: Some(tmp.path().join("data")),
331            log_dir_path: Some(tmp.path().join("logs")),
332            binary_source: BinarySource::LocalPath(binary),
333            ..Default::default()
334        };
335
336        let result = add_nodes(opts, &reg_path, &NoopProgress).await.unwrap();
337        assert_eq!(result.nodes_added.len(), 1);
338        assert_eq!(result.nodes_added[0].rewards_address, TEST_ADDR);
339        assert_eq!(result.nodes_added[0].id, 1);
340        assert!(result.nodes_added[0].data_dir.exists());
341        assert!(result.nodes_added[0].log_dir.as_ref().unwrap().exists());
342
343        // Verify registry was saved
344        let reg = NodeRegistry::load(&reg_path).unwrap();
345        assert_eq!(reg.len(), 1);
346    }
347
348    #[tokio::test]
349    async fn add_multiple_nodes_with_port_range() {
350        let tmp = tempfile::tempdir().unwrap();
351        let binary = create_fake_binary(tmp.path());
352        let reg_path = test_registry_path(tmp.path());
353
354        let opts = AddNodeOpts {
355            count: 3,
356            rewards_address: TEST_ADDR.to_string(),
357            node_port: Some(PortRange::Range(12000, 12002)),
358            data_dir_path: Some(tmp.path().join("data")),
359            log_dir_path: Some(tmp.path().join("logs")),
360            binary_source: BinarySource::LocalPath(binary),
361            ..Default::default()
362        };
363
364        let result = add_nodes(opts, &reg_path, &NoopProgress).await.unwrap();
365        assert_eq!(result.nodes_added.len(), 3);
366        assert_eq!(result.nodes_added[0].node_port, Some(12000));
367        assert_eq!(result.nodes_added[1].node_port, Some(12001));
368        assert_eq!(result.nodes_added[2].node_port, Some(12002));
369        assert_eq!(result.nodes_added[0].id, 1);
370        assert_eq!(result.nodes_added[1].id, 2);
371        assert_eq!(result.nodes_added[2].id, 3);
372    }
373
374    #[tokio::test]
375    async fn add_nodes_rejects_port_range_mismatch() {
376        let tmp = tempfile::tempdir().unwrap();
377        let binary = create_fake_binary(tmp.path());
378        let reg_path = test_registry_path(tmp.path());
379
380        let opts = AddNodeOpts {
381            count: 3,
382            rewards_address: TEST_ADDR.to_string(),
383            node_port: Some(PortRange::Range(12000, 12001)), // 2 ports, 3 nodes
384            binary_source: BinarySource::LocalPath(binary),
385            ..Default::default()
386        };
387
388        let result = add_nodes(opts, &reg_path, &NoopProgress).await;
389        assert!(result.is_err());
390        assert!(matches!(
391            result.unwrap_err(),
392            Error::PortRangeMismatch { .. }
393        ));
394    }
395
396    #[tokio::test]
397    async fn add_nodes_rejects_empty_rewards_address() {
398        let tmp = tempfile::tempdir().unwrap();
399        let reg_path = test_registry_path(tmp.path());
400
401        let opts = AddNodeOpts {
402            count: 1,
403            rewards_address: "  ".to_string(),
404            ..Default::default()
405        };
406
407        let result = add_nodes(opts, &reg_path, &NoopProgress).await;
408        assert!(result.is_err());
409        assert!(matches!(
410            result.unwrap_err(),
411            Error::InvalidRewardsAddress(_)
412        ));
413    }
414
415    #[test]
416    fn validate_rewards_address_rejects_missing_prefix() {
417        let result = validate_rewards_address("1234567890abcdef1234567890abcdef12345678");
418        assert!(result.is_err());
419    }
420
421    #[test]
422    fn validate_rewards_address_rejects_short_address() {
423        let result = validate_rewards_address("0xabc123");
424        assert!(result.is_err());
425    }
426
427    #[test]
428    fn validate_rewards_address_rejects_non_hex() {
429        let result = validate_rewards_address("0xGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGG");
430        assert!(result.is_err());
431    }
432
433    #[test]
434    fn validate_rewards_address_accepts_valid() {
435        let result = validate_rewards_address(TEST_ADDR);
436        assert!(result.is_ok());
437    }
438
439    #[test]
440    fn validate_rewards_address_accepts_uppercase_hex() {
441        let result = validate_rewards_address("0xABCDEF1234567890ABCDEF1234567890ABCDEF12");
442        assert!(result.is_ok());
443    }
444
445    #[tokio::test]
446    async fn add_nodes_with_custom_data_dir() {
447        let tmp = tempfile::tempdir().unwrap();
448        let binary = create_fake_binary(tmp.path());
449        let reg_path = test_registry_path(tmp.path());
450        let custom_data = tmp.path().join("custom-data");
451
452        let opts = AddNodeOpts {
453            count: 1,
454            rewards_address: TEST_ADDR.to_string(),
455            data_dir_path: Some(custom_data.clone()),
456            binary_source: BinarySource::LocalPath(binary),
457            ..Default::default()
458        };
459
460        let result = add_nodes(opts, &reg_path, &NoopProgress).await.unwrap();
461        assert!(result.nodes_added[0].data_dir.starts_with(&custom_data));
462    }
463
464    #[tokio::test]
465    async fn add_nodes_without_log_dir_sets_none() {
466        let tmp = tempfile::tempdir().unwrap();
467        let binary = create_fake_binary(tmp.path());
468        let reg_path = test_registry_path(tmp.path());
469
470        let opts = AddNodeOpts {
471            count: 1,
472            rewards_address: TEST_ADDR.to_string(),
473            data_dir_path: Some(tmp.path().join("data")),
474            // log_dir_path not set — defaults to None
475            binary_source: BinarySource::LocalPath(binary),
476            ..Default::default()
477        };
478
479        let result = add_nodes(opts, &reg_path, &NoopProgress).await.unwrap();
480        assert!(result.nodes_added[0].log_dir.is_none());
481    }
482
483    #[test]
484    fn remove_node_from_registry() {
485        let tmp = tempfile::tempdir().unwrap();
486        let reg_path = test_registry_path(tmp.path());
487
488        // First add a node directly to the registry
489        let (mut registry, _lock) = NodeRegistry::load_locked(&reg_path).unwrap();
490        registry.add(NodeConfig {
491            id: 0,
492            service_name: String::new(),
493            rewards_address: "0xtest".to_string(),
494            data_dir: PathBuf::from("/tmp/test"),
495            log_dir: None,
496            node_port: None,
497            binary_path: PathBuf::from("/usr/bin/antnode"),
498            version: "0.1.0".to_string(),
499            env_variables: HashMap::new(),
500            bootstrap_peers: vec![],
501            upgrade_channel: None,
502            evm_network: EvmNetwork::default(),
503            eviction: None,
504        });
505        registry.save().unwrap();
506        drop(_lock);
507
508        let result = remove_node(1, &reg_path).unwrap();
509        assert_eq!(result.removed.rewards_address, "0xtest");
510
511        let reg = NodeRegistry::load(&reg_path).unwrap();
512        assert!(reg.is_empty());
513    }
514
515    #[test]
516    fn remove_nonexistent_node_errors() {
517        let tmp = tempfile::tempdir().unwrap();
518        let reg_path = test_registry_path(tmp.path());
519
520        let result = remove_node(999, &reg_path);
521        assert!(result.is_err());
522        assert!(matches!(result.unwrap_err(), Error::NodeNotFound(999)));
523    }
524
525    #[tokio::test]
526    async fn reset_clears_all_nodes_and_directories() {
527        let tmp = tempfile::tempdir().unwrap();
528        let binary = create_fake_binary(tmp.path());
529        let reg_path = test_registry_path(tmp.path());
530
531        // Add 2 nodes
532        let opts = AddNodeOpts {
533            count: 2,
534            rewards_address: TEST_ADDR.to_string(),
535            data_dir_path: Some(tmp.path().join("data")),
536            log_dir_path: Some(tmp.path().join("logs")),
537            binary_source: BinarySource::LocalPath(binary),
538            ..Default::default()
539        };
540
541        let result = add_nodes(opts, &reg_path, &NoopProgress).await.unwrap();
542        assert_eq!(result.nodes_added.len(), 2);
543
544        // Verify directories exist
545        for node in &result.nodes_added {
546            assert!(node.data_dir.exists());
547            assert!(node.log_dir.as_ref().unwrap().exists());
548        }
549
550        // Reset
551        let reset_result = reset(&reg_path).unwrap();
552        assert_eq!(reset_result.nodes_cleared, 2);
553        assert_eq!(reset_result.data_dirs_removed.len(), 2);
554        assert_eq!(reset_result.log_dirs_removed.len(), 2);
555
556        // Verify directories were removed
557        for node in &result.nodes_added {
558            assert!(!node.data_dir.exists());
559            assert!(!node.log_dir.as_ref().unwrap().exists());
560        }
561
562        // Verify registry is empty and next_id reset
563        let reg = NodeRegistry::load(&reg_path).unwrap();
564        assert!(reg.is_empty());
565        assert_eq!(reg.next_id, 1);
566    }
567
568    #[test]
569    fn node_status_offline_shows_all_stopped() {
570        let tmp = tempfile::tempdir().unwrap();
571        let reg_path = test_registry_path(tmp.path());
572
573        // Add two nodes directly to the registry
574        let (mut registry, _lock) = NodeRegistry::load_locked(&reg_path).unwrap();
575        registry.add(NodeConfig {
576            id: 0,
577            service_name: String::new(),
578            rewards_address: "0xtest".to_string(),
579            data_dir: PathBuf::from("/tmp/test1"),
580            log_dir: None,
581            node_port: None,
582            binary_path: PathBuf::from("/usr/bin/antnode"),
583            version: "0.110.0".to_string(),
584            env_variables: HashMap::new(),
585            bootstrap_peers: vec![],
586            upgrade_channel: None,
587            evm_network: EvmNetwork::default(),
588            eviction: None,
589        });
590        registry.add(NodeConfig {
591            id: 0,
592            service_name: String::new(),
593            rewards_address: "0xtest".to_string(),
594            data_dir: PathBuf::from("/tmp/test2"),
595            log_dir: None,
596            node_port: None,
597            binary_path: PathBuf::from("/usr/bin/antnode"),
598            version: "0.110.0".to_string(),
599            env_variables: HashMap::new(),
600            bootstrap_peers: vec![],
601            upgrade_channel: None,
602            evm_network: EvmNetwork::default(),
603            eviction: None,
604        });
605        registry.save().unwrap();
606        drop(_lock);
607
608        let result = node_status_offline(&reg_path).unwrap();
609        assert_eq!(result.nodes.len(), 2);
610        assert_eq!(result.total_running, 0);
611        assert_eq!(result.total_stopped, 2);
612        for node in &result.nodes {
613            assert_eq!(node.status, NodeStatus::Stopped);
614        }
615    }
616
617    #[test]
618    fn node_status_offline_empty_registry() {
619        let tmp = tempfile::tempdir().unwrap();
620        let reg_path = test_registry_path(tmp.path());
621
622        let result = node_status_offline(&reg_path).unwrap();
623        assert!(result.nodes.is_empty());
624        assert_eq!(result.total_running, 0);
625        assert_eq!(result.total_stopped, 0);
626    }
627
628    #[test]
629    fn node_status_offline_reports_evicted_from_marker() {
630        use crate::node::types::EvictionRecord;
631
632        let tmp = tempfile::tempdir().unwrap();
633        let reg_path = test_registry_path(tmp.path());
634
635        let (mut registry, _lock) = NodeRegistry::load_locked(&reg_path).unwrap();
636        registry.add(NodeConfig {
637            id: 0,
638            service_name: String::new(),
639            rewards_address: "0xtest".to_string(),
640            data_dir: PathBuf::from("/tmp/test-evicted"),
641            log_dir: None,
642            node_port: None,
643            binary_path: PathBuf::from("/usr/bin/antnode"),
644            version: "0.110.0".to_string(),
645            env_variables: HashMap::new(),
646            bootstrap_peers: vec![],
647            upgrade_channel: None,
648            evm_network: EvmNetwork::default(),
649            eviction: Some(EvictionRecord {
650                reason: "Low disk space".to_string(),
651                evicted_at: 1_700_000_000,
652                reclaimed_bytes: 1_000_000,
653            }),
654        });
655        registry.save().unwrap();
656        drop(_lock);
657
658        let result = node_status_offline(&reg_path).unwrap();
659        assert_eq!(result.nodes.len(), 1);
660        // The persisted marker makes the node report as Evicted even with the daemon down,
661        // and carries its supplementary reason text through to the summary.
662        assert_eq!(result.nodes[0].status, NodeStatus::Evicted);
663        assert_eq!(
664            result.nodes[0].eviction.as_ref().unwrap().reason,
665            "Low disk space"
666        );
667    }
668
669    #[test]
670    fn reset_empty_registry_succeeds() {
671        let tmp = tempfile::tempdir().unwrap();
672        let reg_path = test_registry_path(tmp.path());
673
674        let result = reset(&reg_path).unwrap();
675        assert_eq!(result.nodes_cleared, 0);
676        assert!(result.data_dirs_removed.is_empty());
677        assert!(result.log_dirs_removed.is_empty());
678    }
679
680    #[tokio::test]
681    async fn reset_then_add_starts_fresh_ids() {
682        let tmp = tempfile::tempdir().unwrap();
683        let binary = create_fake_binary(tmp.path());
684        let reg_path = test_registry_path(tmp.path());
685
686        // Add 2 nodes
687        let opts = AddNodeOpts {
688            count: 2,
689            rewards_address: TEST_ADDR.to_string(),
690            data_dir_path: Some(tmp.path().join("data")),
691            log_dir_path: Some(tmp.path().join("logs")),
692            binary_source: BinarySource::LocalPath(binary.clone()),
693            ..Default::default()
694        };
695        add_nodes(opts, &reg_path, &NoopProgress).await.unwrap();
696
697        // Reset
698        reset(&reg_path).unwrap();
699
700        // Add again — IDs should restart from 1
701        let opts = AddNodeOpts {
702            count: 1,
703            rewards_address: TEST_ADDR.to_string(),
704            data_dir_path: Some(tmp.path().join("data")),
705            log_dir_path: Some(tmp.path().join("logs")),
706            binary_source: BinarySource::LocalPath(binary),
707            ..Default::default()
708        };
709        let result = add_nodes(opts, &reg_path, &NoopProgress).await.unwrap();
710
711        assert_eq!(result.nodes_added[0].id, 1);
712        assert_eq!(result.nodes_added[0].rewards_address, TEST_ADDR);
713    }
714}