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