Skip to main content

ant_node/
config.rs

1//! Configuration for ant-node.
2
3use evmlib::Network as EvmNetwork;
4use serde::{Deserialize, Serialize};
5use std::net::{Ipv4Addr, SocketAddr, SocketAddrV4};
6use std::path::{Path, PathBuf};
7
8/// Filename for the persisted node identity keypair.
9pub const NODE_IDENTITY_FILENAME: &str = "node_identity.key";
10
11/// Subdirectory under the root dir that contains per-node data directories.
12pub const NODES_SUBDIR: &str = "nodes";
13
14/// Upgrade channel for auto-updates.
15#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
16#[serde(rename_all = "lowercase")]
17pub enum UpgradeChannel {
18    /// Stable releases only.
19    #[default]
20    Stable,
21    /// Beta releases (includes stable).
22    Beta,
23}
24
25/// Network mode for different deployment scenarios.
26#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
27#[serde(rename_all = "lowercase")]
28pub enum NetworkMode {
29    /// Production mode with full anti-Sybil protection.
30    #[default]
31    Production,
32    /// Testnet mode with relaxed diversity requirements.
33    /// Suitable for single-provider deployments (e.g., Digital Ocean).
34    Testnet,
35    /// Development mode with minimal restrictions.
36    /// Only use for local testing.
37    Development,
38}
39
40/// Testnet-specific configuration for relaxed IP diversity limits.
41///
42/// saorsa-core uses a simple 2-tier model: per-exact-IP and per-subnet
43/// (/24 IPv4, /64 IPv6) limits.  Testnet defaults are permissive so
44/// nodes co-located on a single provider (e.g. Digital Ocean) can join.
45#[derive(Debug, Clone, Serialize, Deserialize)]
46pub struct TestnetConfig {
47    /// Maximum nodes sharing an exact IP address.
48    /// Default: `usize::MAX` (effectively unlimited for testnet).
49    #[serde(default = "default_testnet_max_per_ip")]
50    pub max_per_ip: Option<usize>,
51
52    /// Maximum nodes in the same /24 (IPv4) or /64 (IPv6) subnet.
53    /// Default: `usize::MAX` (effectively unlimited for testnet).
54    #[serde(default = "default_testnet_max_per_subnet")]
55    pub max_per_subnet: Option<usize>,
56}
57
58impl Default for TestnetConfig {
59    fn default() -> Self {
60        Self {
61            max_per_ip: default_testnet_max_per_ip(),
62            max_per_subnet: default_testnet_max_per_subnet(),
63        }
64    }
65}
66
67// These return `Option` because `serde(default = "...")` requires the function's
68// return type to match the field type (`Option<usize>`).
69#[allow(clippy::unnecessary_wraps)]
70const fn default_testnet_max_per_ip() -> Option<usize> {
71    Some(usize::MAX)
72}
73
74#[allow(clippy::unnecessary_wraps)]
75const fn default_testnet_max_per_subnet() -> Option<usize> {
76    Some(usize::MAX)
77}
78
79/// Node configuration.
80#[derive(Debug, Clone, Serialize, Deserialize)]
81pub struct NodeConfig {
82    /// Root directory for node data.
83    #[serde(default = "default_root_dir")]
84    pub root_dir: PathBuf,
85
86    /// Listening port (0 for auto-select).
87    #[serde(default)]
88    pub port: u16,
89
90    /// Force IPv4-only mode.
91    ///
92    /// When true, the node binds only on IPv4 instead of dual-stack.
93    /// Use this on hosts without working IPv6 to avoid advertising
94    /// unreachable addresses to the DHT.
95    #[serde(default)]
96    pub ipv4_only: bool,
97
98    /// Bootstrap peer addresses.
99    #[serde(default)]
100    pub bootstrap: Vec<SocketAddr>,
101
102    /// Network mode (production, testnet, or development).
103    #[serde(default)]
104    pub network_mode: NetworkMode,
105
106    /// Testnet-specific configuration.
107    /// Only used when `network_mode` is `Testnet`.
108    #[serde(default)]
109    pub testnet: TestnetConfig,
110
111    /// Upgrade configuration.
112    #[serde(default)]
113    pub upgrade: UpgradeConfig,
114
115    /// Payment verification configuration.
116    #[serde(default)]
117    pub payment: PaymentConfig,
118
119    /// Storage configuration for chunk persistence.
120    #[serde(default)]
121    pub storage: StorageConfig,
122
123    /// Directory for persisting the close group cache.
124    ///
125    /// When `None` (default), the node's `root_dir` is used — the cache
126    /// file lands alongside `node_identity.key`.
127    #[serde(default)]
128    pub close_group_cache_dir: Option<PathBuf>,
129
130    /// Maximum application-layer message size in bytes.
131    ///
132    /// Tunes the QUIC stream receive window and per-stream read buffer.
133    /// Default: the larger of
134    /// [`MAX_WIRE_MESSAGE_SIZE`](crate::ant_protocol::MAX_WIRE_MESSAGE_SIZE) (5 MiB)
135    /// and [`MAX_REPLICATION_MESSAGE_SIZE`](crate::replication::config::MAX_REPLICATION_MESSAGE_SIZE)
136    /// (10 MiB), so both chunk and replication traffic fit within the transport
137    /// ceiling.
138    #[serde(default = "default_max_message_size")]
139    pub max_message_size: usize,
140
141    /// Log level.
142    #[serde(default = "default_log_level")]
143    pub log_level: String,
144}
145
146/// Auto-upgrade configuration.
147#[derive(Debug, Clone, Serialize, Deserialize)]
148pub struct UpgradeConfig {
149    /// Release channel.
150    #[serde(default)]
151    pub channel: UpgradeChannel,
152
153    /// Check interval in hours.
154    #[serde(default = "default_check_interval")]
155    pub check_interval_hours: u64,
156
157    /// GitHub repository in "owner/repo" format for release monitoring.
158    #[serde(default = "default_github_repo")]
159    pub github_repo: String,
160
161    /// Staged rollout window in hours.
162    ///
163    /// When a new version is detected, each node waits a deterministic delay
164    /// based on its node ID before applying the upgrade. This prevents mass
165    /// restarts and ensures network stability during upgrades.
166    ///
167    /// Set to 0 to disable staged rollout (apply upgrades immediately).
168    #[serde(default = "default_staged_rollout_hours")]
169    pub staged_rollout_hours: u64,
170
171    /// Exit cleanly on upgrade instead of spawning a new process.
172    ///
173    /// When true, the node exits after applying an upgrade and relies on
174    /// an external service manager (systemd, launchd, Windows Service) to
175    /// restart it. When false (default), the node spawns the new binary
176    /// as a child process before exiting.
177    #[serde(default)]
178    pub stop_on_upgrade: bool,
179}
180
181/// EVM network for payment processing.
182///
183/// `Custom` is used by the local-Anvil testnet flow in
184/// `deploy/testnet-v2/`: when an operator stands up a private Anvil
185/// instance with the ANT token + payment vault contracts deployed,
186/// every node points at the Anvil RPC and the deployed addresses
187/// instead of one of the public Arbitrum networks.
188#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
189#[serde(rename_all = "kebab-case", tag = "type")]
190pub enum EvmNetworkConfig {
191    /// Arbitrum One mainnet.
192    #[default]
193    ArbitrumOne,
194    /// Arbitrum Sepolia testnet.
195    ArbitrumSepolia,
196    /// Local / private EVM (e.g. Anvil) with operator-supplied
197    /// contract addresses.
198    Custom {
199        /// HTTP RPC URL of the EVM node (e.g. `http://1.2.3.4:8545`).
200        rpc_url: String,
201        /// Deployed ANT token (ERC-20) contract address.
202        payment_token_address: String,
203        /// Deployed payment vault contract address.
204        payment_vault_address: String,
205    },
206}
207
208impl EvmNetworkConfig {
209    /// Resolve this config into the concrete `evmlib` network used by
210    /// the payment verifier and the rewards wallet.
211    #[must_use]
212    pub fn into_evm_network(self) -> EvmNetwork {
213        match self {
214            Self::ArbitrumOne => EvmNetwork::ArbitrumOne,
215            Self::ArbitrumSepolia => EvmNetwork::ArbitrumSepoliaTest,
216            Self::Custom {
217                rpc_url,
218                payment_token_address,
219                payment_vault_address,
220            } => EvmNetwork::new_custom(&rpc_url, &payment_token_address, &payment_vault_address),
221        }
222    }
223}
224
225/// Payment verification configuration.
226///
227/// All new data requires EVM payment on Arbitrum — there is no way to
228/// disable payment verification. The cache stores previously verified
229/// payments to avoid redundant on-chain lookups.
230#[derive(Debug, Clone, Serialize, Deserialize)]
231pub struct PaymentConfig {
232    /// Cache capacity for verified `XorNames`.
233    #[serde(default = "default_cache_capacity")]
234    pub cache_capacity: usize,
235
236    /// EVM wallet address for receiving payments (e.g., "0x...").
237    /// If not set, the node will not be able to receive payments.
238    #[serde(default)]
239    pub rewards_address: Option<String>,
240
241    /// EVM network for payment processing.
242    #[serde(default)]
243    pub evm_network: EvmNetworkConfig,
244
245    /// Metrics port for Prometheus scraping.
246    /// Set to 0 to disable metrics endpoint.
247    #[serde(default = "default_metrics_port")]
248    pub metrics_port: u16,
249}
250
251impl Default for PaymentConfig {
252    fn default() -> Self {
253        Self {
254            cache_capacity: default_cache_capacity(),
255            rewards_address: None,
256            evm_network: EvmNetworkConfig::default(),
257            metrics_port: default_metrics_port(),
258        }
259    }
260}
261
262const fn default_metrics_port() -> u16 {
263    9100
264}
265
266const fn default_cache_capacity() -> usize {
267    100_000
268}
269
270impl Default for NodeConfig {
271    fn default() -> Self {
272        Self {
273            root_dir: default_root_dir(),
274            port: 0,
275            ipv4_only: false,
276            bootstrap: Vec::new(),
277            network_mode: NetworkMode::default(),
278            testnet: TestnetConfig::default(),
279            upgrade: UpgradeConfig::default(),
280            payment: PaymentConfig::default(),
281            storage: StorageConfig::default(),
282            close_group_cache_dir: None,
283            max_message_size: default_max_message_size(),
284            log_level: default_log_level(),
285        }
286    }
287}
288
289impl NodeConfig {
290    /// Create a testnet configuration preset.
291    ///
292    /// This is a convenience method for setting up a testnet node with
293    /// relaxed anti-Sybil protection, suitable for single-provider deployments.
294    /// Includes default bootstrap nodes for the Autonomi testnet.
295    #[must_use]
296    pub fn testnet() -> Self {
297        Self {
298            network_mode: NetworkMode::Testnet,
299            testnet: TestnetConfig::default(),
300            bootstrap: default_testnet_bootstrap(),
301            ..Self::default()
302        }
303    }
304
305    /// Create a development configuration preset.
306    ///
307    /// This has minimal restrictions and is only suitable for local testing.
308    #[must_use]
309    pub fn development() -> Self {
310        Self {
311            network_mode: NetworkMode::Development,
312            testnet: TestnetConfig {
313                max_per_ip: Some(usize::MAX),
314                max_per_subnet: Some(usize::MAX),
315            },
316            ..Self::default()
317        }
318    }
319
320    /// Check if this configuration is using relaxed security settings.
321    #[must_use]
322    pub fn is_relaxed(&self) -> bool {
323        !matches!(self.network_mode, NetworkMode::Production)
324    }
325
326    /// Load configuration from a TOML file.
327    ///
328    /// # Errors
329    ///
330    /// Returns an error if the file cannot be read or parsed.
331    pub fn from_file(path: &std::path::Path) -> crate::Result<Self> {
332        let content = std::fs::read_to_string(path)?;
333        toml::from_str(&content).map_err(|e| crate::Error::Config(e.to_string()))
334    }
335
336    /// Save configuration to a TOML file.
337    ///
338    /// # Errors
339    ///
340    /// Returns an error if the file cannot be written.
341    pub fn to_file(&self, path: &std::path::Path) -> crate::Result<()> {
342        let content =
343            toml::to_string_pretty(self).map_err(|e| crate::Error::Config(e.to_string()))?;
344        std::fs::write(path, content)?;
345        Ok(())
346    }
347}
348
349impl Default for UpgradeConfig {
350    fn default() -> Self {
351        Self {
352            channel: UpgradeChannel::default(),
353            check_interval_hours: default_check_interval(),
354            github_repo: default_github_repo(),
355            staged_rollout_hours: default_staged_rollout_hours(),
356            stop_on_upgrade: false,
357        }
358    }
359}
360
361fn default_github_repo() -> String {
362    "WithAutonomi/ant-node".to_string()
363}
364
365/// Default base directory for node data (platform data dir for "ant").
366#[must_use]
367pub fn default_root_dir() -> PathBuf {
368    directories::ProjectDirs::from("", "", "ant").map_or_else(
369        || PathBuf::from(".ant"),
370        |dirs| dirs.data_dir().to_path_buf(),
371    )
372}
373
374/// Default directory containing per-node data subdirectories.
375///
376/// Each node gets `{default_root_dir}/nodes/{peer_id}/` where `peer_id` is the
377/// full 64-character hex-encoded node ID.
378#[must_use]
379pub fn default_nodes_dir() -> PathBuf {
380    default_root_dir().join(NODES_SUBDIR)
381}
382
383fn default_max_message_size() -> usize {
384    // Use the larger of the chunk protocol and replication protocol ceilings
385    // so that replication hint batches (up to 10 MiB) are not silently dropped
386    // by the transport layer.
387    crate::replication::config::MAX_REPLICATION_MESSAGE_SIZE
388        .max(crate::ant_protocol::MAX_WIRE_MESSAGE_SIZE)
389}
390
391fn default_log_level() -> String {
392    "info".to_string()
393}
394
395const fn default_check_interval() -> u64 {
396    1 // 1 hour
397}
398
399const fn default_staged_rollout_hours() -> u64 {
400    24 // 24 hour window for staged rollout
401}
402
403// ============================================================================
404// Storage Configuration
405// ============================================================================
406
407/// Storage configuration for chunk persistence.
408///
409/// Controls how chunks are stored, including:
410/// - Whether storage is enabled
411/// - Content verification on read
412/// - Database size limits (auto-scales with available disk by default)
413#[derive(Debug, Clone, Serialize, Deserialize)]
414pub struct StorageConfig {
415    /// Enable chunk storage.
416    /// Default: true
417    #[serde(default = "default_storage_enabled")]
418    pub enabled: bool,
419
420    /// Verify content hash matches address on read.
421    /// Default: true
422    #[serde(default = "default_storage_verify_on_read")]
423    pub verify_on_read: bool,
424
425    /// Explicit LMDB database size cap in GiB.
426    ///
427    /// When set to 0 (default), the map size is computed automatically from
428    /// available disk space at startup and grows on demand when the operator
429    /// adds storage.  Set a non-zero value to impose a hard cap.
430    #[serde(default)]
431    pub db_size_gb: usize,
432
433    /// Minimum free disk space (in MiB) to preserve on the storage partition.
434    ///
435    /// Writes are refused when available space drops below this threshold,
436    /// preventing the node from filling the disk completely.  Default: 500 MiB.
437    #[serde(default = "default_disk_reserve_mb")]
438    pub disk_reserve_mb: u64,
439}
440
441impl Default for StorageConfig {
442    fn default() -> Self {
443        Self {
444            enabled: default_storage_enabled(),
445            verify_on_read: default_storage_verify_on_read(),
446            db_size_gb: 0,
447            disk_reserve_mb: default_disk_reserve_mb(),
448        }
449    }
450}
451
452/// Default: 500 MiB — matches `DEFAULT_DISK_RESERVE` in `storage::lmdb`.
453const fn default_disk_reserve_mb() -> u64 {
454    500
455}
456
457const fn default_storage_enabled() -> bool {
458    true
459}
460
461const fn default_storage_verify_on_read() -> bool {
462    true
463}
464
465// ============================================================================
466// Bootstrap Peers Configuration (shipped config file)
467// ============================================================================
468
469/// The filename for the bootstrap peers configuration file.
470pub const BOOTSTRAP_PEERS_FILENAME: &str = "bootstrap_peers.toml";
471
472/// Environment variable that overrides the bootstrap peers file search path.
473pub const BOOTSTRAP_PEERS_ENV: &str = "ANT_BOOTSTRAP_PEERS_PATH";
474
475/// Bootstrap peers loaded from a shipped configuration file.
476///
477/// This file provides initial peers for first-time network joins.
478#[derive(Debug, Clone, Serialize, Deserialize)]
479pub struct BootstrapPeersConfig {
480    /// The bootstrap peer socket addresses.
481    #[serde(default)]
482    pub peers: Vec<SocketAddr>,
483}
484
485/// The source from which bootstrap peers were resolved.
486#[derive(Debug, Clone, PartialEq, Eq)]
487pub enum BootstrapSource {
488    /// Provided via `--bootstrap` CLI argument or `ANT_BOOTSTRAP` env var.
489    Cli,
490    /// Loaded from an explicit `--config` file.
491    ConfigFile,
492    /// Auto-discovered from a `bootstrap_peers.toml` file.
493    AutoDiscovered(PathBuf),
494    /// No bootstrap peers were found from any source.
495    None,
496}
497
498impl BootstrapPeersConfig {
499    /// Load bootstrap peers from a TOML file at the given path.
500    ///
501    /// # Errors
502    ///
503    /// Returns an error if the file cannot be read or contains invalid TOML.
504    pub fn from_file(path: &Path) -> crate::Result<Self> {
505        let content = std::fs::read_to_string(path)?;
506        toml::from_str(&content).map_err(|e| crate::Error::Config(e.to_string()))
507    }
508
509    /// Search well-known locations for a `bootstrap_peers.toml` file and load it.
510    ///
511    /// Search order (first match wins):
512    /// 1. `$ANT_BOOTSTRAP_PEERS_PATH` environment variable (path to file)
513    /// 2. Same directory as the running executable
514    /// 3. Platform config directory (`~/.config/ant/` on Linux,
515    ///    `~/Library/Application Support/ant/` on macOS,
516    ///    `%APPDATA%\ant\` on Windows)
517    /// 4. System config: `/etc/ant/` (Unix only)
518    ///
519    /// Returns `None` if no file is found in any location.
520    #[must_use]
521    pub fn discover() -> Option<(Self, PathBuf)> {
522        if let Ok(env_path) = std::env::var(BOOTSTRAP_PEERS_ENV) {
523            return Self::load_non_empty_candidate(PathBuf::from(env_path));
524        }
525
526        for path in Self::search_paths() {
527            if let Some(discovered) = Self::load_non_empty_candidate(path) {
528                return Some(discovered);
529            }
530        }
531
532        None
533    }
534
535    fn load_non_empty_candidate(path: PathBuf) -> Option<(Self, PathBuf)> {
536        if !path.is_file() {
537            return None;
538        }
539
540        match Self::from_file(&path) {
541            Ok(config) if !config.peers.is_empty() => Some((config, path)),
542            Ok(_) => None,
543            Err(err) => {
544                crate::logging::warn!(
545                    "Failed to load bootstrap peers from {}: {err}",
546                    path.display(),
547                );
548                None
549            }
550        }
551    }
552
553    /// Build the ordered list of candidate paths to search.
554    fn search_paths() -> Vec<PathBuf> {
555        let mut paths = Vec::new();
556
557        // 1. Environment variable override.
558        if let Ok(env_path) = std::env::var(BOOTSTRAP_PEERS_ENV) {
559            paths.push(PathBuf::from(env_path));
560        }
561
562        // 2. Next to the running executable.
563        if let Ok(exe) = std::env::current_exe() {
564            if let Some(exe_dir) = exe.parent() {
565                paths.push(exe_dir.join(BOOTSTRAP_PEERS_FILENAME));
566            }
567        }
568
569        // 3. Platform config directory.
570        if let Some(proj_dirs) = directories::ProjectDirs::from("", "", "ant") {
571            paths.push(proj_dirs.config_dir().join(BOOTSTRAP_PEERS_FILENAME));
572        }
573
574        // 4. System config (Unix only).
575        #[cfg(unix)]
576        {
577            paths.push(PathBuf::from("/etc/ant").join(BOOTSTRAP_PEERS_FILENAME));
578        }
579
580        paths
581    }
582}
583
584/// Default testnet bootstrap nodes.
585///
586/// These are well-known bootstrap nodes for the Autonomi testnet.
587/// - ant-bootstrap-1 (NYC): 165.22.4.178:12000
588/// - ant-bootstrap-2 (SFO): 164.92.111.156:12000
589fn default_testnet_bootstrap() -> Vec<SocketAddr> {
590    vec![
591        // ant-bootstrap-1 (Digital Ocean NYC1)
592        SocketAddr::V4(SocketAddrV4::new(Ipv4Addr::new(165, 22, 4, 178), 12000)),
593        // ant-bootstrap-2 (Digital Ocean SFO3)
594        SocketAddr::V4(SocketAddrV4::new(Ipv4Addr::new(164, 92, 111, 156), 12000)),
595    ]
596}
597
598#[cfg(test)]
599#[allow(clippy::unwrap_used, clippy::expect_used)]
600mod tests {
601    use super::*;
602    use serial_test::serial;
603
604    #[test]
605    fn test_default_config_has_cache_capacity() {
606        let config = PaymentConfig::default();
607        assert!(config.cache_capacity > 0, "Cache capacity must be positive");
608    }
609
610    #[test]
611    fn test_default_evm_network() {
612        use crate::payment::EvmVerifierConfig;
613        let _config = EvmVerifierConfig::default();
614        // EVM verification is always on — no enabled field
615    }
616
617    #[test]
618    fn test_bootstrap_peers_parse_valid_toml() {
619        let toml_str = r#"
620            peers = [
621                "127.0.0.1:10000",
622                "192.168.1.1:10001",
623            ]
624        "#;
625        let config: BootstrapPeersConfig =
626            toml::from_str(toml_str).expect("valid TOML should parse");
627        assert_eq!(config.peers.len(), 2);
628        assert_eq!(config.peers[0].port(), 10000);
629        assert_eq!(config.peers[1].port(), 10001);
630    }
631
632    #[test]
633    fn test_bootstrap_peers_parse_empty_peers() {
634        let toml_str = r"peers = []";
635        let config: BootstrapPeersConfig =
636            toml::from_str(toml_str).expect("empty peers should parse");
637        assert!(config.peers.is_empty());
638    }
639
640    #[test]
641    fn test_bootstrap_peers_parse_missing_peers_field() {
642        let toml_str = "";
643        let config: BootstrapPeersConfig =
644            toml::from_str(toml_str).expect("missing field should use default");
645        assert!(config.peers.is_empty());
646    }
647
648    #[test]
649    fn test_bootstrap_peers_from_file() {
650        let dir = tempfile::tempdir().expect("create temp dir");
651        let path = dir.path().join("bootstrap_peers.toml");
652        std::fs::write(&path, r#"peers = ["10.0.0.1:10000", "10.0.0.2:10000"]"#)
653            .expect("write file");
654
655        let config = BootstrapPeersConfig::from_file(&path).expect("load from file");
656        assert_eq!(config.peers.len(), 2);
657    }
658
659    #[test]
660    fn test_bootstrap_peers_from_file_invalid_toml() {
661        let dir = tempfile::tempdir().expect("create temp dir");
662        let path = dir.path().join("bootstrap_peers.toml");
663        std::fs::write(&path, "not valid toml [[[").expect("write file");
664
665        assert!(BootstrapPeersConfig::from_file(&path).is_err());
666    }
667
668    /// Env-var-based discovery tests must run serially because they mutate
669    /// a shared process-wide environment variable.
670    #[test]
671    #[serial]
672    fn test_bootstrap_peers_discover_env_var() {
673        // Sub-test 1: valid file with peers is discovered.
674        {
675            let dir = tempfile::tempdir().expect("create temp dir");
676            let path = dir.path().join("bootstrap_peers.toml");
677            std::fs::write(&path, r#"peers = ["10.0.0.1:10000"]"#).expect("write file");
678
679            std::env::set_var(BOOTSTRAP_PEERS_ENV, &path);
680            let result = BootstrapPeersConfig::discover();
681            std::env::remove_var(BOOTSTRAP_PEERS_ENV);
682
683            let (config, discovered_path) = result.expect("should discover from env var");
684            assert_eq!(config.peers.len(), 1);
685            assert_eq!(discovered_path, path);
686        }
687
688        // Sub-test 2: file with empty peers list is skipped.
689        {
690            let dir = tempfile::tempdir().expect("create temp dir");
691            let path = dir.path().join("bootstrap_peers.toml");
692            std::fs::write(&path, r"peers = []").expect("write file");
693
694            std::env::set_var(BOOTSTRAP_PEERS_ENV, &path);
695            let result = BootstrapPeersConfig::discover();
696            std::env::remove_var(BOOTSTRAP_PEERS_ENV);
697
698            assert!(result.is_none(), "empty peers file should be skipped");
699        }
700    }
701
702    #[test]
703    fn test_bootstrap_peers_search_paths_contains_exe_dir() {
704        let paths = BootstrapPeersConfig::search_paths();
705        // At minimum, the exe-dir candidate should be present.
706        assert!(
707            paths
708                .iter()
709                .any(|p| p.file_name().is_some_and(|f| f == BOOTSTRAP_PEERS_FILENAME)),
710            "search paths should include a candidate with the bootstrap peers filename"
711        );
712    }
713}