From f953d033c357b4eef686eff2ad390dc46285aac2 Mon Sep 17 00:00:00 2001 From: Nic Date: Thu, 23 Jul 2026 09:19:56 +0100 Subject: [PATCH 1/9] refactor(core): impl FromStr for PortRange, drop CLI parse_port_range (V2-744) Port-range parsing ("12000" / "12000-12004") lived in ant-cli while the PortRange type lives in ant-core, so every frontend had to reimplement it. Parse via the standard FromStr trait next to the type instead; the CLI now just calls .parse(). Co-Authored-By: Claude Fable 5 --- ant-cli/src/commands/node/add.rs | 31 +-- ant-core/src/error.rs | 3 + ant-core/src/node/types.rs | 57 +++++ vendor/ant-node/Cargo.toml.orig | 198 ++++++++++++++++++ .../.cache/saorsa/bootstrap_cache.json | 7 + .../.cache/saorsa/bootstrap_cache.lock | 0 6 files changed, 270 insertions(+), 26 deletions(-) create mode 100644 vendor/ant-node/Cargo.toml.orig create mode 100644 vendor/saorsa-core/.cache/saorsa/bootstrap_cache.json create mode 100644 vendor/saorsa-core/.cache/saorsa/bootstrap_cache.lock diff --git a/ant-cli/src/commands/node/add.rs b/ant-cli/src/commands/node/add.rs index d9fea24a..e6605b76 100644 --- a/ant-cli/src/commands/node/add.rs +++ b/ant-cli/src/commands/node/add.rs @@ -150,7 +150,11 @@ impl AddArgs { } fn to_add_node_opts(&self) -> anyhow::Result { - let node_port = self.parse_port_range(&self.node_port)?; + let node_port = self + .node_port + .as_deref() + .map(str::parse::) + .transpose()?; let binary_source = if let Some(ref path) = self.path { BinarySource::LocalPath(path.clone()) @@ -189,31 +193,6 @@ impl AddArgs { }) } - fn parse_port_range(&self, input: &Option) -> anyhow::Result> { - match input { - None => Ok(None), - Some(s) => { - if let Some((start, end)) = s.split_once('-') { - let start: u16 = start - .parse() - .map_err(|_| anyhow::anyhow!("Invalid port range start: '{start}'"))?; - let end: u16 = end - .parse() - .map_err(|_| anyhow::anyhow!("Invalid port range end: '{end}'"))?; - if end < start { - anyhow::bail!("Port range end ({end}) must be >= start ({start})"); - } - Ok(Some(PortRange::Range(start, end))) - } else { - let port: u16 = s - .parse() - .map_err(|_| anyhow::anyhow!("Invalid port: '{s}'"))?; - Ok(Some(PortRange::Single(port))) - } - } - } - } - async fn add_via_daemon( &self, config: &DaemonConfig, diff --git a/ant-core/src/error.rs b/ant-core/src/error.rs index 2b62460f..313a61bc 100644 --- a/ant-core/src/error.rs +++ b/ant-core/src/error.rs @@ -44,6 +44,9 @@ pub enum Error { #[error("Port range length ({range_len}) does not match node count ({count})")] PortRangeMismatch { range_len: u16, count: u16 }, + #[error("Invalid port range: {0}")] + InvalidPortRange(String), + #[error("Binary not found at path: {0}")] BinaryNotFound(PathBuf), diff --git a/ant-core/src/node/types.rs b/ant-core/src/node/types.rs index 70481e50..079649d3 100644 --- a/ant-core/src/node/types.rs +++ b/ant-core/src/node/types.rs @@ -279,6 +279,36 @@ impl PortRange { } } +impl std::str::FromStr for PortRange { + type Err = crate::error::Error; + + /// Parse `"12000"` into [`PortRange::Single`] or `"12000-12004"` into + /// [`PortRange::Range`]. + fn from_str(s: &str) -> std::result::Result { + use crate::error::Error; + + if let Some((start, end)) = s.split_once('-') { + let start: u16 = start + .parse() + .map_err(|_| Error::InvalidPortRange(format!("invalid start port '{start}'")))?; + let end: u16 = end + .parse() + .map_err(|_| Error::InvalidPortRange(format!("invalid end port '{end}'")))?; + if end < start { + return Err(Error::InvalidPortRange(format!( + "end ({end}) must be >= start ({start})" + ))); + } + Ok(Self::Range(start, end)) + } else { + let port: u16 = s + .parse() + .map_err(|_| Error::InvalidPortRange(format!("invalid port '{s}'")))?; + Ok(Self::Single(port)) + } + } +} + /// Options for adding one or more nodes to the registry. #[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)] pub struct AddNodeOpts { @@ -586,6 +616,33 @@ mod tests { assert_eq!(pr.port_at(3), None); } + #[test] + fn port_range_parses_single() { + let pr: PortRange = "12000".parse().unwrap(); + assert!(matches!(pr, PortRange::Single(12000))); + } + + #[test] + fn port_range_parses_range() { + let pr: PortRange = "12000-12004".parse().unwrap(); + assert!(matches!(pr, PortRange::Range(12000, 12004))); + } + + #[test] + fn port_range_rejects_inverted_range() { + let err = "12004-12000".parse::().unwrap_err(); + assert!(err.to_string().contains("must be >= start")); + } + + #[test] + fn port_range_rejects_garbage() { + assert!("abc".parse::().is_err()); + assert!("".parse::().is_err()); + assert!("12000-abc".parse::().is_err()); + assert!("-12000".parse::().is_err()); + assert!("70000".parse::().is_err()); + } + #[test] fn binary_source_serializes_with_tag() { let src = BinarySource::Latest; diff --git a/vendor/ant-node/Cargo.toml.orig b/vendor/ant-node/Cargo.toml.orig new file mode 100644 index 00000000..ba489e41 --- /dev/null +++ b/vendor/ant-node/Cargo.toml.orig @@ -0,0 +1,198 @@ +[package] +name = "ant-node" +version = "0.14.2" +edition = "2021" +authors = ["David Irvine "] +description = "Pure quantum-proof network node for the Autonomi decentralized network" +license = "MIT OR Apache-2.0" +repository = "https://github.com/WithAutonomi/ant-node" +keywords = ["p2p", "decentralized", "quantum-safe", "post-quantum", "dht"] +categories = ["network-programming", "cryptography"] +rust-version = "1.75" + +[lib] +name = "ant_node" +path = "src/lib.rs" + +[[bin]] +name = "ant-node" +path = "src/bin/ant-node/main.rs" + +[[bin]] +name = "ant-devnet" +path = "src/bin/ant-devnet/main.rs" + +[dependencies] +# Global allocator. musl's default malloc is significantly slower than +# glibc's under concurrent allocation churn, which matches the node's +# steady-state workload. mimalloc neutralises that regression for the +# musl Linux builds (and tends to beat glibc's allocator too). +mimalloc = "0.1" + +# Wire protocol — the single version-pin shared with ant-client. +# Bumping ant-protocol's `evmlib`/`saorsa-core`/`saorsa-pqc` pins ripples +# through here automatically; we keep a direct saorsa-core dep for +# node-only DHT internals (DHTNode, TrustEvent, DhtNetworkEvent), which +# Cargo unifies with ant-protocol's version constraint. +# +# TODO: swap to `ant-protocol = "2.0.0"` once 2.0.0 is on crates.io. +# Until then, the git pin tracks the matching saorsa-core lineage +# (the rc-2026.4.2 branch) so Cargo can unify the wire types here +# with ant-protocol's re-exports. +ant-protocol = "2.2.2" + +# Core (provides EVERYTHING: networking, DHT, security, trust, storage) +saorsa-core = "0.26.2" +saorsa-pqc = "0.5" + +# Payment verification - autonomi network lookup + EVM payment +evmlib = "0.8.1" +xor_name = "5" + +# Caching - LRU cache for verified XorNames +lru = "0.16.3" +parking_lot = "0.12" # Efficient mutex for cache + +# Storage - LMDB via heed for content-addressed chunk store +heed = "0.22" + +blake3 = "1" + +# Async runtime +tokio = { version = "1.35", features = ["full", "signal"] } +tokio-util = { version = "0.7", features = ["rt"] } +futures = "0.3" + +# CLI +clap = { version = "4.5", features = ["derive", "env"] } + +# Configuration +serde = { version = "1", features = ["derive"] } +toml = "0.8" +directories = "5" + +# Auto-upgrade +reqwest = { version = "0.13", features = ["json", "rustls"], default-features = false } +semver = "1" + +# Logging (optional — behind `logging` feature flag) +tracing = { version = "0.1", optional = true } +tracing-subscriber = { version = "0.3", features = ["env-filter", "json"], optional = true } +tracing-appender = { version = "0.2", optional = true } + +# Error handling +thiserror = "2" +color-eyre = "0.6" + +# Serialization +rmp-serde = "1" +hex = "0.4" + +# Utilities +bytes = "1" +chrono = { version = "0.4", features = ["serde"] } +tempfile = "3" +rand = "0.8" +serde_json = "1" + +# Archive extraction for auto-upgrade +flate2 = "1" +tar = "0.4" +zip = "2" + +# SHA-256 hashing for binary cache integrity +sha2 = "0.10" + +# Cross-platform file locking for upgrade caches +fs2 = "0.4" + +# System page size (for LMDB map alignment during resize) +page_size = "0.6" + +# Protocol serialization +postcard = { version = "1.1.3", features = ["use-std"] } + +[target.'cfg(unix)'.dependencies] +libc = "0.2" + +[target.'cfg(windows)'.dependencies] +self-replace = "1" + +[target.'cfg(target_os = "macos")'.dependencies] +objc2 = "0.6" +objc2-foundation = { version = "0.3", features = ["NSProcessInfo", "NSString"] } + +[dev-dependencies] +tokio-test = "0.4" +proptest = "1" +alloy = { version = "1", features = ["node-bindings"] } +serial_test = "3" + +# E2E test infrastructure (run with --features test-utils) +[[test]] +name = "e2e" +path = "tests/e2e/mod.rs" +required-features = ["test-utils"] + +# v12 storage-bound audit attack PoCs. Uses the test-only one-shot +# commitment builder/verifier helpers, so it requires the test-utils +# feature. CI runs it via `cargo test --test poc_commitment_audit_attacks +# --features test-utils`. +[[test]] +name = "poc_commitment_audit_attacks" +path = "tests/poc_commitment_audit_attacks.rs" +required-features = ["test-utils"] + +# Live responder-handler tests for the v12 audit. Use +# LmdbStorageConfig::test_default(), gated on test-utils. +[[test]] +name = "poc_audit_handler_live" +path = "tests/poc_audit_handler_live.rs" +required-features = ["test-utils"] + +# Bootstrap-stall DoS regression marker (documents the unfixed attack; the +# eventual fix must land with a follow-up test asserting bounded drain). +# Declared like the other PoC suites so CI invokes it explicitly. +[[test]] +name = "poc_bootstrap_stall" +path = "tests/poc_bootstrap_stall.rs" +required-features = ["test-utils"] + +[features] +default = ["logging"] +# Enable tracing/logging infrastructure. +# Included in `default` so dev builds (`cargo build`, `cargo test`) get logging +# automatically. Release builds strip it: +# cargo build --release --no-default-features +logging = ["tracing", "tracing-subscriber", "tracing-appender"] +# Expose test helpers (cache_insert, payment_verifier accessor) for +# integration tests and downstream test harnesses. +test-utils = [] + +[profile.release] +lto = true +codegen-units = 1 +panic = "abort" +strip = true + +[profile.dev] +# Faster builds for development +opt-level = 1 + +[lints.rust] +unsafe_code = "deny" +missing_docs = "warn" + +[lints.clippy] +all = { level = "warn", priority = -1 } +pedantic = { level = "warn", priority = -1 } +nursery = { level = "warn", priority = -1 } +unwrap_used = "deny" +expect_used = "deny" +panic = "deny" +# Allow async functions that will have await statements added later +unused_async = "allow" +# Allow slightly complex functions during initial development +cognitive_complexity = "allow" +# Allow non-const functions during initial development (may need runtime features later) +missing_const_for_fn = "allow" diff --git a/vendor/saorsa-core/.cache/saorsa/bootstrap_cache.json b/vendor/saorsa-core/.cache/saorsa/bootstrap_cache.json new file mode 100644 index 00000000..14c5ebc6 --- /dev/null +++ b/vendor/saorsa-core/.cache/saorsa/bootstrap_cache.json @@ -0,0 +1,7 @@ +{ + "version": 1, + "instance_id": "61794_1765636149551", + "timestamp": "2025-12-13T14:29:09.565437Z", + "contacts": {}, + "checksum": 15130871412783076140 +} \ No newline at end of file diff --git a/vendor/saorsa-core/.cache/saorsa/bootstrap_cache.lock b/vendor/saorsa-core/.cache/saorsa/bootstrap_cache.lock new file mode 100644 index 00000000..e69de29b From 2b272fb48355da83987f0e1d3bc75e76d7350b48 Mon Sep 17 00:00:00 2001 From: Nic Date: Thu, 23 Jul 2026 09:20:32 +0100 Subject: [PATCH 2/9] refactor(core): move env KEY=VALUE parsing onto AddNodeOpts (V2-745) Every frontend passing env vars to AddNodeOpts needs the same KEY=VALUE parsing the CLI hand-rolled; expose it as AddNodeOpts::parse_env_vars. Co-Authored-By: Claude Fable 5 --- ant-cli/src/commands/node/add.rs | 13 +---------- ant-core/src/error.rs | 3 +++ ant-core/src/node/types.rs | 40 ++++++++++++++++++++++++++++++++ 3 files changed, 44 insertions(+), 12 deletions(-) diff --git a/ant-cli/src/commands/node/add.rs b/ant-cli/src/commands/node/add.rs index e6605b76..df2c9759 100644 --- a/ant-cli/src/commands/node/add.rs +++ b/ant-cli/src/commands/node/add.rs @@ -166,18 +166,7 @@ impl AddArgs { BinarySource::Latest }; - let env_variables: Vec<(String, String)> = self - .env - .iter() - .map(|e| { - let parts: Vec<&str> = e.splitn(2, '=').collect(); - if parts.len() == 2 { - Ok((parts[0].to_string(), parts[1].to_string())) - } else { - anyhow::bail!("Invalid env variable format: '{e}'. Expected KEY=VALUE") - } - }) - .collect::>>()?; + let env_variables = AddNodeOpts::parse_env_vars(&self.env)?; Ok(AddNodeOpts { count: self.count, diff --git a/ant-core/src/error.rs b/ant-core/src/error.rs index 313a61bc..2a5c71bf 100644 --- a/ant-core/src/error.rs +++ b/ant-core/src/error.rs @@ -47,6 +47,9 @@ pub enum Error { #[error("Invalid port range: {0}")] InvalidPortRange(String), + #[error("Invalid env variable format: '{0}'. Expected KEY=VALUE")] + InvalidEnvVar(String), + #[error("Binary not found at path: {0}")] BinaryNotFound(PathBuf), diff --git a/ant-core/src/node/types.rs b/ant-core/src/node/types.rs index 079649d3..1661969d 100644 --- a/ant-core/src/node/types.rs +++ b/ant-core/src/node/types.rs @@ -340,6 +340,20 @@ pub struct AddNodeOpts { pub evm_network: EvmNetwork, } +impl AddNodeOpts { + /// Parse `KEY=VALUE` strings (the format frontends accept for node env + /// vars) into the pair list `env_variables` expects. + pub fn parse_env_vars(vars: &[String]) -> crate::error::Result> { + vars.iter() + .map(|e| { + e.split_once('=') + .map(|(k, v)| (k.to_string(), v.to_string())) + .ok_or_else(|| crate::error::Error::InvalidEnvVar(e.clone())) + }) + .collect() + } +} + impl Default for AddNodeOpts { fn default() -> Self { Self { @@ -655,6 +669,32 @@ mod tests { assert!(json.contains("1.0.0")); } + #[test] + fn parse_env_vars_splits_on_first_equals() { + let parsed = AddNodeOpts::parse_env_vars(&[ + "KEY=VALUE".to_string(), + "RUST_LOG=info,ant_node=debug".to_string(), + "EMPTY=".to_string(), + "URL=http://host?a=b".to_string(), + ]) + .unwrap(); + assert_eq!( + parsed, + vec![ + ("KEY".to_string(), "VALUE".to_string()), + ("RUST_LOG".to_string(), "info,ant_node=debug".to_string()), + ("EMPTY".to_string(), String::new()), + ("URL".to_string(), "http://host?a=b".to_string()), + ] + ); + } + + #[test] + fn parse_env_vars_rejects_missing_equals() { + let err = AddNodeOpts::parse_env_vars(&["NOVALUE".to_string()]).unwrap_err(); + assert!(err.to_string().contains("Expected KEY=VALUE")); + } + #[test] fn add_node_opts_default() { let opts = AddNodeOpts::default(); From 3ec9a81c820a354bbf4226dd6686851392480d63 Mon Sep 17 00:00:00 2001 From: Nic Date: Thu, 23 Jul 2026 09:21:35 +0100 Subject: [PATCH 3/9] refactor(core): move bootstrap-peer resolution into ant_core::config (V2-742) The explicit-peers > devnet-manifest > bootstrap_peers.toml priority logic (with manifest-peer socket-addr filtering) lived in ant-cli, but it's business logic every frontend needs. Move it to config::resolve_bootstrap_peers; the no-source case is now a typed Error::NoBootstrapPeers. Co-Authored-By: Claude Fable 5 --- ant-cli/src/main.rs | 35 +------------------------ ant-core/src/config.rs | 59 ++++++++++++++++++++++++++++++++++++++++++ ant-core/src/error.rs | 5 ++++ 3 files changed, 65 insertions(+), 34 deletions(-) diff --git a/ant-cli/src/main.rs b/ant-cli/src/main.rs index b8523a3b..5b29fc84 100644 --- a/ant-cli/src/main.rs +++ b/ant-cli/src/main.rs @@ -209,7 +209,7 @@ async fn build_data_client( } let manifest = load_manifest(ctx)?; - let bootstrap = resolve_bootstrap_from(ctx, manifest.as_ref())?; + let bootstrap = ant_core::config::resolve_bootstrap_peers(&ctx.bootstrap, manifest.as_ref())?; // Explicit network selectors should be isolated from the general client // peer cache. `--bootstrap` and `--devnet-manifest` both mean "use exactly // this network entrypoint", so cached public-network peers must not be @@ -454,39 +454,6 @@ fn resolve_evm_network( } } -/// Resolve bootstrap peers from a pre-loaded manifest. -/// -/// Priority: CLI `--bootstrap` > devnet manifest > `bootstrap_peers.toml` config file. -fn resolve_bootstrap_from( - ctx: &DataCliContext, - manifest: Option<&DevnetManifest>, -) -> anyhow::Result> { - if !ctx.bootstrap.is_empty() { - return Ok(ctx.bootstrap.clone()); - } - - if let Some(m) = manifest { - let bootstrap: Vec = m - .bootstrap - .iter() - .filter_map(MultiAddr::socket_addr) - .collect(); - return Ok(bootstrap); - } - - if let Some(peers) = ant_core::config::load_bootstrap_peers() - .map_err(|e| anyhow::anyhow!("Failed to load bootstrap config: {e}"))? - { - info!("Loaded {} bootstrap peer(s) from config file", peers.len()); - return Ok(peers); - } - - anyhow::bail!( - "No bootstrap peers provided. Use --bootstrap, --devnet-manifest, \ - or install bootstrap_peers.toml to your config directory." - ) -} - async fn create_client_node( bootstrap: &[SocketAddr], allow_loopback: bool, diff --git a/ant-core/src/config.rs b/ant-core/src/config.rs index 806f15a9..d771156c 100644 --- a/ant-core/src/config.rs +++ b/ant-core/src/config.rs @@ -1,6 +1,7 @@ use std::net::SocketAddr; use std::path::PathBuf; +use crate::data::{DevnetManifest, MultiAddr}; use crate::error::{Error, Result}; /// Returns the platform-appropriate data directory for ant. @@ -79,6 +80,37 @@ pub fn load_bootstrap_peers() -> Result>> { Ok(Some(addrs)) } +/// Resolve the bootstrap peers for a client connection. +/// +/// Priority: explicitly supplied peers (e.g. a frontend's `--bootstrap` +/// flag) > devnet manifest peers > the platform `bootstrap_peers.toml` +/// config file. Manifest peers without a resolvable socket address are +/// filtered out. +/// +/// # Errors +/// +/// Returns [`Error::NoBootstrapPeers`] when no source yields any peers, +/// and propagates config-file read/parse failures. +pub fn resolve_bootstrap_peers( + explicit: &[SocketAddr], + manifest: Option<&DevnetManifest>, +) -> Result> { + if !explicit.is_empty() { + return Ok(explicit.to_vec()); + } + + if let Some(m) = manifest { + return Ok(m.bootstrap.iter().filter_map(MultiAddr::socket_addr).collect()); + } + + if let Some(peers) = load_bootstrap_peers()? { + tracing::info!("Loaded {} bootstrap peer(s) from config file", peers.len()); + return Ok(peers); + } + + Err(Error::NoBootstrapPeers) +} + #[derive(serde::Deserialize)] struct BootstrapConfig { peers: Vec, @@ -117,6 +149,33 @@ mod tests { ); } + fn test_manifest(addrs: Vec) -> DevnetManifest { + DevnetManifest { + base_port: 10000, + node_count: addrs.len(), + bootstrap: addrs.into_iter().map(MultiAddr::quic).collect(), + data_dir: PathBuf::new(), + created_at: String::new(), + evm: None, + } + } + + #[test] + fn resolve_bootstrap_prefers_explicit_peers() { + let explicit: Vec = vec!["10.0.0.1:10000".parse().unwrap()]; + let manifest = test_manifest(vec!["10.0.0.2:10000".parse().unwrap()]); + let peers = resolve_bootstrap_peers(&explicit, Some(&manifest)).unwrap(); + assert_eq!(peers, explicit); + } + + #[test] + fn resolve_bootstrap_uses_manifest_when_no_explicit_peers() { + let addr: SocketAddr = "10.0.0.2:10000".parse().unwrap(); + let manifest = test_manifest(vec![addr]); + let peers = resolve_bootstrap_peers(&[], Some(&manifest)).unwrap(); + assert_eq!(peers, vec![addr]); + } + #[test] fn load_bootstrap_peers_returns_none_when_no_file() { // Set config dir to a temp location where no file exists diff --git a/ant-core/src/error.rs b/ant-core/src/error.rs index 2a5c71bf..951add6b 100644 --- a/ant-core/src/error.rs +++ b/ant-core/src/error.rs @@ -71,6 +71,11 @@ pub enum Error { #[error("Failed to parse bootstrap_peers.toml: {0}")] BootstrapConfigParse(String), + #[error( + "No bootstrap peers available: pass peers explicitly, use a devnet manifest, or install bootstrap_peers.toml in the config directory" + )] + NoBootstrapPeers, + #[error("Node count {count} exceeds maximum of {max} per call")] InvalidNodeCount { count: u16, max: u16 }, From 4f2ecb7ba4b300053759a6b036cf4372f50bae1e Mon Sep 17 00:00:00 2001 From: Nic Date: Thu, 23 Jul 2026 09:22:24 +0100 Subject: [PATCH 4/9] refactor(core): route node add/reset through the daemon client (V2-741) ant node add / ant node reset hand-rolled reqwest POSTs against the daemon while every other endpoint goes through ant_core::node::daemon::client. Add client::add_node and client::reset and use them from the CLI. Co-Authored-By: Claude Fable 5 --- ant-cli/src/commands/node/add.rs | 27 +--------------- ant-cli/src/commands/node/reset.rs | 19 +----------- ant-core/src/node/daemon/client.rs | 49 ++++++++++++++++++++++++++++-- 3 files changed, 49 insertions(+), 46 deletions(-) diff --git a/ant-cli/src/commands/node/add.rs b/ant-cli/src/commands/node/add.rs index df2c9759..8ff8c1fb 100644 --- a/ant-cli/src/commands/node/add.rs +++ b/ant-cli/src/commands/node/add.rs @@ -103,7 +103,7 @@ impl AddArgs { // Check if daemon is running; if so, POST to API; otherwise call directly let config = DaemonConfig::default(); let result = match client::status(&config).await { - Ok(status) if status.running => self.add_via_daemon(&config, &opts).await?, + Ok(status) if status.running => client::add_node(&config, &opts).await?, _ => self.add_directly(&config, &opts).await?, }; @@ -182,31 +182,6 @@ impl AddArgs { }) } - async fn add_via_daemon( - &self, - config: &DaemonConfig, - opts: &AddNodeOpts, - ) -> anyhow::Result { - let info = client::info(config); - let api_base = info - .api_base - .ok_or_else(|| anyhow::anyhow!("Daemon is running but API base URL not available"))?; - - let client = reqwest::Client::new(); - let resp = client - .post(format!("{api_base}/nodes")) - .json(opts) - .send() - .await?; - - if resp.status().is_success() { - Ok(resp.json().await?) - } else { - let body = resp.text().await?; - anyhow::bail!("Daemon returned error: {body}"); - } - } - async fn add_directly( &self, config: &DaemonConfig, diff --git a/ant-cli/src/commands/node/reset.rs b/ant-cli/src/commands/node/reset.rs index f6f2ad8c..2d17d909 100644 --- a/ant-cli/src/commands/node/reset.rs +++ b/ant-cli/src/commands/node/reset.rs @@ -48,7 +48,7 @@ impl ResetArgs { } let result = if daemon_running { - self.reset_via_daemon(&config).await? + client::reset(&config).await? } else { self.reset_directly(&config)? }; @@ -85,23 +85,6 @@ impl ResetArgs { Ok(()) } - async fn reset_via_daemon(&self, config: &DaemonConfig) -> anyhow::Result { - let info = client::info(config); - let api_base = info - .api_base - .ok_or_else(|| anyhow::anyhow!("Daemon is running but API base URL not available"))?; - - let client = reqwest::Client::new(); - let resp = client.post(format!("{api_base}/reset")).send().await?; - - if resp.status().is_success() { - Ok(resp.json().await?) - } else { - let body = resp.text().await?; - anyhow::bail!("Daemon returned error: {body}"); - } - } - fn reset_directly(&self, config: &DaemonConfig) -> anyhow::Result { let result = ant_core::node::reset(&config.registry_path)?; Ok(result) diff --git a/ant-core/src/node/daemon/client.rs b/ant-core/src/node/daemon/client.rs index eeb81a4f..bf55789b 100644 --- a/ant-core/src/node/daemon/client.rs +++ b/ant-core/src/node/daemon/client.rs @@ -5,8 +5,9 @@ use crate::error::{Error, Result}; use crate::node::daemon::health::FleetHealth; use crate::node::process::detach; use crate::node::types::{ - DaemonConfig, DaemonInfo, DaemonStartResult, DaemonStatus, DaemonStopResult, NodeStarted, - NodeStatusResult, NodeStopped, RemoveNodeResult, StartNodeResult, StopNodeResult, + AddNodeOpts, AddNodeResult, DaemonConfig, DaemonInfo, DaemonStartResult, DaemonStatus, + DaemonStopResult, NodeStarted, NodeStatusResult, NodeStopped, RemoveNodeResult, ResetResult, + StartNodeResult, StopNodeResult, }; /// Get the daemon's current status by querying its REST API. @@ -205,6 +206,50 @@ pub async fn stop_node(config: &DaemonConfig, node_id: u32) -> Result Result { + let port = read_port_file(&config.port_file_path).ok_or(Error::DaemonNotRunning)?; + + let url = format!("http://127.0.0.1:{port}/api/v1/nodes"); + let resp = reqwest::Client::new() + .post(&url) + .json(opts) + .send() + .await + .map_err(|e| Error::HttpRequest(e.to_string()))?; + + if resp.status().is_success() { + resp.json::() + .await + .map_err(|e| Error::HttpRequest(e.to_string())) + } else { + let body = resp.text().await.unwrap_or_default(); + Err(Error::HttpRequest(body)) + } +} + +/// Reset all node state — clear the registry and remove node data/log +/// directories — via the daemon REST API. +pub async fn reset(config: &DaemonConfig) -> Result { + let port = read_port_file(&config.port_file_path).ok_or(Error::DaemonNotRunning)?; + + let url = format!("http://127.0.0.1:{port}/api/v1/reset"); + let resp = reqwest::Client::new() + .post(&url) + .send() + .await + .map_err(|e| Error::HttpRequest(e.to_string()))?; + + if resp.status().is_success() { + resp.json::() + .await + .map_err(|e| Error::HttpRequest(e.to_string())) + } else { + let body = resp.text().await.unwrap_or_default(); + Err(Error::HttpRequest(body)) + } +} + /// Dismiss a node — remove it from the registry — via the daemon REST API. /// /// Intended for evicted nodes (whose data directory has already been deleted), but the daemon will From 892f81517abe61c5a7e2ec8468cc4949ab6a6daf Mon Sep 17 00:00:00 2001 From: Nic Date: Thu, 23 Jul 2026 09:22:52 +0100 Subject: [PATCH 5/9] fix(core): resolve service name to node ID via the daemon API (V2-740) ant node start/stop --service-name read node_registry.json directly to translate name -> ID even while the daemon (the registry's owner) is running, so the daemon could mutate the registry between the CLI's read and the API call. Add client::resolve_node_id_by_name, which resolves through GET /nodes/status, and drop the CLI's direct registry read. Co-Authored-By: Claude Fable 5 --- ant-cli/src/commands/node/start.rs | 9 +++------ ant-cli/src/commands/node/stop.rs | 8 +++----- ant-core/src/error.rs | 3 +++ ant-core/src/node/daemon/client.rs | 15 +++++++++++++++ 4 files changed, 24 insertions(+), 11 deletions(-) diff --git a/ant-cli/src/commands/node/start.rs b/ant-cli/src/commands/node/start.rs index 506e2095..75cb51e8 100644 --- a/ant-cli/src/commands/node/start.rs +++ b/ant-cli/src/commands/node/start.rs @@ -33,12 +33,9 @@ impl StartArgs { service_name: &str, json_output: bool, ) -> anyhow::Result<()> { - // Look up node ID by service name via the registry - let registry = ant_core::node::registry::NodeRegistry::load(&config.registry_path)?; - let node = registry - .find_by_service_name(service_name) - .ok_or_else(|| anyhow::anyhow!("No node found with service name '{service_name}'"))?; - let node_id = node.id; + // Resolve the node ID through the daemon API — the daemon owns the + // registry, so the CLI must not read node_registry.json directly. + let node_id = client::resolve_node_id_by_name(config, service_name).await?; let result = client::start_node(config, node_id).await?; diff --git a/ant-cli/src/commands/node/stop.rs b/ant-cli/src/commands/node/stop.rs index b522734b..8c2bfc0c 100644 --- a/ant-cli/src/commands/node/stop.rs +++ b/ant-cli/src/commands/node/stop.rs @@ -33,11 +33,9 @@ impl StopArgs { service_name: &str, json_output: bool, ) -> anyhow::Result<()> { - let registry = ant_core::node::registry::NodeRegistry::load(&config.registry_path)?; - let node = registry - .find_by_service_name(service_name) - .ok_or_else(|| anyhow::anyhow!("No node found with service name '{service_name}'"))?; - let node_id = node.id; + // Resolve the node ID through the daemon API — the daemon owns the + // registry, so the CLI must not read node_registry.json directly. + let node_id = client::resolve_node_id_by_name(config, service_name).await?; let result = client::stop_node(config, node_id).await?; diff --git a/ant-core/src/error.rs b/ant-core/src/error.rs index 951add6b..cfa212db 100644 --- a/ant-core/src/error.rs +++ b/ant-core/src/error.rs @@ -11,6 +11,9 @@ pub enum Error { #[error("Node not found: {0}")] NodeNotFound(u32), + #[error("No node found with service name '{0}'")] + NodeNotFoundByName(String), + #[error("Node already running: {0}")] NodeAlreadyRunning(u32), diff --git a/ant-core/src/node/daemon/client.rs b/ant-core/src/node/daemon/client.rs index bf55789b..90704b34 100644 --- a/ant-core/src/node/daemon/client.rs +++ b/ant-core/src/node/daemon/client.rs @@ -312,6 +312,21 @@ pub async fn node_status(config: &DaemonConfig) -> Result { } } +/// Resolve a node's ID from its service name via the daemon REST API. +/// +/// Goes through the daemon — the registry's owner — rather than reading +/// node_registry.json directly, so the lookup cannot observe a +/// half-written registry or race a concurrent mutation by the daemon. +pub async fn resolve_node_id_by_name(config: &DaemonConfig, service_name: &str) -> Result { + let status = node_status(config).await?; + status + .nodes + .iter() + .find(|n| n.name == service_name) + .map(|n| n.node_id) + .ok_or_else(|| Error::NodeNotFoundByName(service_name.to_string())) +} + /// Stop all running nodes via the daemon REST API. pub async fn stop_all_nodes(config: &DaemonConfig) -> Result { let port = read_port_file(&config.port_file_path).ok_or(Error::DaemonNotRunning)?; From 20c389cd78a9f4a8474408536d43a99801c6696c Mon Sep 17 00:00:00 2001 From: Nic Date: Thu, 23 Jul 2026 09:23:39 +0100 Subject: [PATCH 6/9] refactor(cli): consolidate the two ProgressReporter impls (V2-746) CliProgress (node add, unicode bar to stdout) and CliUpdateProgress (self-update, plain percent to stderr) were near-identical impls of the same trait. Keep one CliProgress in ant-cli/src/progress.rs, writing to stderr like the rest of the progress UI. Also fixes: ant node add --json no longer interleaves download-progress output with the JSON result (NoopProgress in JSON mode, matching ant update). Co-Authored-By: Claude Fable 5 --- ant-cli/src/commands/node/add.rs | 43 ++++++++------------------------ ant-cli/src/commands/update.rs | 22 ++-------------- ant-cli/src/progress.rs | 33 ++++++++++++++++++++++++ 3 files changed, 46 insertions(+), 52 deletions(-) diff --git a/ant-cli/src/commands/node/add.rs b/ant-cli/src/commands/node/add.rs index 8ff8c1fb..5dc61e7f 100644 --- a/ant-cli/src/commands/node/add.rs +++ b/ant-cli/src/commands/node/add.rs @@ -3,7 +3,7 @@ use std::path::PathBuf; use clap::Args; use colored::Colorize; -use ant_core::node::binary::ProgressReporter; +use ant_core::node::binary::{NoopProgress, ProgressReporter}; use ant_core::node::daemon::client; use ant_core::node::types::DaemonConfig; use ant_core::node::types::{ @@ -104,7 +104,7 @@ impl AddArgs { let config = DaemonConfig::default(); let result = match client::status(&config).await { Ok(status) if status.running => client::add_node(&config, &opts).await?, - _ => self.add_directly(&config, &opts).await?, + _ => self.add_directly(&config, &opts, json_output).await?, }; if json_output { @@ -186,38 +186,17 @@ impl AddArgs { &self, config: &DaemonConfig, opts: &AddNodeOpts, + json_output: bool, ) -> anyhow::Result { - let progress = CliProgress; + // Suppress progress in JSON mode so stdout stays parseable. + let progress: Box = if json_output { + Box::new(NoopProgress) + } else { + Box::new(crate::progress::CliProgress) + }; let result = - ant_core::node::add_nodes(opts.clone(), &config.registry_path, &progress).await?; + ant_core::node::add_nodes(opts.clone(), &config.registry_path, progress.as_ref()) + .await?; Ok(result) } } - -/// CLI progress reporter that prints to the terminal. -struct CliProgress; - -impl ProgressReporter for CliProgress { - fn report_started(&self, message: &str) { - println!("{} {message}", "⟳".cyan()); - } - - fn report_progress(&self, bytes: u64, total: u64) { - if total > 0 { - let pct = (bytes as f64 / total as f64 * 100.0) as u32; - let bar_width = 30; - let filled = (pct as usize * bar_width) / 100; - let empty = bar_width - filled; - let bar = format!( - "{}{}", - "█".repeat(filled).cyan(), - "░".repeat(empty).dimmed() - ); - print!("\r {} {bar} {pct:>3}%", "Downloading".dimmed()); - } - } - - fn report_complete(&self, message: &str) { - println!("\r{} {message}", "✓".green().bold()); - } -} diff --git a/ant-cli/src/commands/update.rs b/ant-cli/src/commands/update.rs index 45de66ca..3b65070c 100644 --- a/ant-cli/src/commands/update.rs +++ b/ant-cli/src/commands/update.rs @@ -4,25 +4,7 @@ use colored::Colorize; use ant_core::node::binary::NoopProgress; use ant_core::update; -/// Progress reporter that prints to the terminal. -struct CliUpdateProgress; - -impl ant_core::node::binary::ProgressReporter for CliUpdateProgress { - fn report_started(&self, message: &str) { - eprintln!("{}", message.dimmed()); - } - - fn report_progress(&self, bytes: u64, total: u64) { - if total > 0 { - let pct = (bytes as f64 / total as f64 * 100.0) as u64; - eprint!("\r{}", format!(" Downloading... {pct}%").dimmed()); - } - } - - fn report_complete(&self, message: &str) { - eprintln!("\r{}", message.green()); - } -} +use crate::progress::CliProgress; #[derive(Args)] pub struct UpdateArgs { @@ -72,7 +54,7 @@ impl UpdateArgs { let progress: Box = if json_output { Box::new(NoopProgress) } else { - Box::new(CliUpdateProgress) + Box::new(CliProgress) }; let result = update::perform_update(&check, progress.as_ref()).await?; diff --git a/ant-cli/src/progress.rs b/ant-cli/src/progress.rs index 17112667..d8a01774 100644 --- a/ant-cli/src/progress.rs +++ b/ant-cli/src/progress.rs @@ -13,9 +13,12 @@ use std::io::{self, IsTerminal, Write}; use std::sync::OnceLock; use std::time::Duration; +use colored::Colorize; use indicatif::{MultiProgress, ProgressBar, ProgressStyle}; use tracing_subscriber::fmt::MakeWriter; +use ant_core::node::binary::ProgressReporter; + static MULTI: OnceLock = OnceLock::new(); /// The shared `MultiProgress` instance. Created on first access. @@ -51,6 +54,36 @@ pub fn attach(pb: ProgressBar) -> ProgressBar { } } +/// Terminal implementation of ant-core's `ProgressReporter` (binary +/// downloads during `node add` and self-update). Writes to stderr so +/// stdout stays clean for command output. +pub struct CliProgress; + +impl ProgressReporter for CliProgress { + fn report_started(&self, message: &str) { + eprintln!("{} {message}", "⟳".cyan()); + } + + fn report_progress(&self, bytes: u64, total: u64) { + if total > 0 { + let pct = (bytes as f64 / total as f64 * 100.0) as u32; + let bar_width = 30; + let filled = (pct as usize * bar_width) / 100; + let empty = bar_width - filled; + let bar = format!( + "{}{}", + "█".repeat(filled).cyan(), + "░".repeat(empty).dimmed() + ); + eprint!("\r {} {bar} {pct:>3}%", "Downloading".dimmed()); + } + } + + fn report_complete(&self, message: &str) { + eprintln!("\r{} {message}", "✓".green().bold()); + } +} + /// Tracing writer that suspends active progress bars while writing log lines. #[derive(Clone)] pub struct ProgressAwareWriter; From c8b032b10b63bd4f7a0c0497e4f4f4a1bdbd2ae8 Mon Sep 17 00:00:00 2001 From: Nic Date: Thu, 23 Jul 2026 09:26:55 +0100 Subject: [PATCH 7/9] chore: changelog for the V2-189 CLI-thinning wave + fmt Co-Authored-By: Claude Fable 5 --- CHANGELOG.md | 5 +++++ ant-core/src/config.rs | 6 +++++- 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index cd8eb0ef..185a6394 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Fixed +- `ant node start`/`ant node stop` with `--service-name` now resolve the node ID through the daemon API instead of reading `node_registry.json` directly, eliminating a race against concurrent registry mutations by the daemon. +- `ant node add --json` no longer interleaves binary-download progress with the JSON result; progress output now goes to stderr (was stdout) and is suppressed entirely in JSON mode. + ### Changed - Default network binding changed from IPv4-only to IPv6 dual-stack. Hosts without a working IPv6 stack should pass `--ipv4-only` to avoid advertising unreachable v6 addresses to the DHT (which causes slow connects and junk address records). - `ant file upload` now writes datamaps as `..datamap` instead of stripping the extension. Uploading `photo.jpg` produces `photo.jpg.datamap` (was `photo.datamap`). Existing datamaps remain readable. @@ -18,6 +22,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - `ant file download --datamap` now reads both msgpack (canonical) and legacy JSON datamaps, so datamaps produced by older versions of the GUI download cleanly via the CLI. ### Internal +- CLI-audit thinning (V2-189): `PortRange` parsing (`FromStr`), env `KEY=VALUE` parsing (`AddNodeOpts::parse_env_vars`), and bootstrap-peer resolution (`config::resolve_bootstrap_peers`) moved from ant-cli into ant-core; `node add`/`node reset` daemon calls now go through `ant_core::node::daemon::client` (new `add_node`/`reset`/`resolve_node_id_by_name` functions) instead of hand-rolled HTTP; the two CLI `ProgressReporter` impls collapsed into one. - New `ant_core::datamap_file` module owns the on-disk datamap format (msgpack canonical, JSON legacy auto-detect on read) and naming convention. `ant-cli` and consumers like `ant-gui` route through this single helper instead of reimplementing serialization. ## [0.1.1] - 2026-03-28 diff --git a/ant-core/src/config.rs b/ant-core/src/config.rs index d771156c..d14eb5eb 100644 --- a/ant-core/src/config.rs +++ b/ant-core/src/config.rs @@ -100,7 +100,11 @@ pub fn resolve_bootstrap_peers( } if let Some(m) = manifest { - return Ok(m.bootstrap.iter().filter_map(MultiAddr::socket_addr).collect()); + return Ok(m + .bootstrap + .iter() + .filter_map(MultiAddr::socket_addr) + .collect()); } if let Some(peers) = load_bootstrap_peers()? { From ec6d5d5d7992cd1a1f2296dec26568137b3d1076 Mon Sep 17 00:00:00 2001 From: Nic Date: Thu, 23 Jul 2026 11:45:47 +0100 Subject: [PATCH 8/9] fix(core): error on empty devnet manifest peers; drop stray vendor artifacts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit resolve_bootstrap_peers now returns NoBootstrapPeers when a selected manifest is empty or every entry is filtered out, instead of Ok([]) — a selected manifest never falls back to the public config. Also removes accidentally committed vendor cache/.orig files and ignores them. Co-Authored-By: Claude Fable 5 --- .gitignore | 2 + ant-core/src/config.rs | 39 +++- vendor/ant-node/Cargo.toml.orig | 198 ------------------ .../.cache/saorsa/bootstrap_cache.json | 7 - .../.cache/saorsa/bootstrap_cache.lock | 0 5 files changed, 36 insertions(+), 210 deletions(-) delete mode 100644 vendor/ant-node/Cargo.toml.orig delete mode 100644 vendor/saorsa-core/.cache/saorsa/bootstrap_cache.json delete mode 100644 vendor/saorsa-core/.cache/saorsa/bootstrap_cache.lock diff --git a/.gitignore b/.gitignore index fc94dea5..8f84e465 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,7 @@ /target .cargo/config.toml +.cache/ +*.orig .claude/plans/ .claude/scheduled_tasks.lock .claude/settings.local.json diff --git a/ant-core/src/config.rs b/ant-core/src/config.rs index d14eb5eb..0071de6f 100644 --- a/ant-core/src/config.rs +++ b/ant-core/src/config.rs @@ -85,12 +85,14 @@ pub fn load_bootstrap_peers() -> Result>> { /// Priority: explicitly supplied peers (e.g. a frontend's `--bootstrap` /// flag) > devnet manifest peers > the platform `bootstrap_peers.toml` /// config file. Manifest peers without a resolvable socket address are -/// filtered out. +/// filtered out. A selected manifest is authoritative: if it yields no +/// usable peers, resolution fails rather than falling back to the +/// config file. /// /// # Errors /// -/// Returns [`Error::NoBootstrapPeers`] when no source yields any peers, -/// and propagates config-file read/parse failures. +/// Returns [`Error::NoBootstrapPeers`] when the selected source yields +/// no peers, and propagates config-file read/parse failures. pub fn resolve_bootstrap_peers( explicit: &[SocketAddr], manifest: Option<&DevnetManifest>, @@ -100,11 +102,18 @@ pub fn resolve_bootstrap_peers( } if let Some(m) = manifest { - return Ok(m + let peers: Vec = m .bootstrap .iter() .filter_map(MultiAddr::socket_addr) - .collect()); + .collect(); + // An explicitly selected manifest never falls back to the public + // config: an empty (or fully filtered) manifest is an error here, + // not later when the first data operation fails. + if peers.is_empty() { + return Err(Error::NoBootstrapPeers); + } + return Ok(peers); } if let Some(peers) = load_bootstrap_peers()? { @@ -180,6 +189,26 @@ mod tests { assert_eq!(peers, vec![addr]); } + #[test] + fn resolve_bootstrap_errors_on_empty_manifest() { + let manifest = test_manifest(vec![]); + let err = resolve_bootstrap_peers(&[], Some(&manifest)).unwrap_err(); + assert!(matches!(err, Error::NoBootstrapPeers)); + } + + #[test] + fn resolve_bootstrap_errors_when_all_manifest_peers_filtered() { + // A non-IP transport has no socket address, so the peer is + // filtered out and the manifest yields nothing usable. + let bt: MultiAddr = "/bt/00:11:22:33:44:55/rfcomm/1".parse().unwrap(); + assert!(bt.socket_addr().is_none()); + let mut manifest = test_manifest(vec![]); + manifest.bootstrap = vec![bt]; + manifest.node_count = 1; + let err = resolve_bootstrap_peers(&[], Some(&manifest)).unwrap_err(); + assert!(matches!(err, Error::NoBootstrapPeers)); + } + #[test] fn load_bootstrap_peers_returns_none_when_no_file() { // Set config dir to a temp location where no file exists diff --git a/vendor/ant-node/Cargo.toml.orig b/vendor/ant-node/Cargo.toml.orig deleted file mode 100644 index ba489e41..00000000 --- a/vendor/ant-node/Cargo.toml.orig +++ /dev/null @@ -1,198 +0,0 @@ -[package] -name = "ant-node" -version = "0.14.2" -edition = "2021" -authors = ["David Irvine "] -description = "Pure quantum-proof network node for the Autonomi decentralized network" -license = "MIT OR Apache-2.0" -repository = "https://github.com/WithAutonomi/ant-node" -keywords = ["p2p", "decentralized", "quantum-safe", "post-quantum", "dht"] -categories = ["network-programming", "cryptography"] -rust-version = "1.75" - -[lib] -name = "ant_node" -path = "src/lib.rs" - -[[bin]] -name = "ant-node" -path = "src/bin/ant-node/main.rs" - -[[bin]] -name = "ant-devnet" -path = "src/bin/ant-devnet/main.rs" - -[dependencies] -# Global allocator. musl's default malloc is significantly slower than -# glibc's under concurrent allocation churn, which matches the node's -# steady-state workload. mimalloc neutralises that regression for the -# musl Linux builds (and tends to beat glibc's allocator too). -mimalloc = "0.1" - -# Wire protocol — the single version-pin shared with ant-client. -# Bumping ant-protocol's `evmlib`/`saorsa-core`/`saorsa-pqc` pins ripples -# through here automatically; we keep a direct saorsa-core dep for -# node-only DHT internals (DHTNode, TrustEvent, DhtNetworkEvent), which -# Cargo unifies with ant-protocol's version constraint. -# -# TODO: swap to `ant-protocol = "2.0.0"` once 2.0.0 is on crates.io. -# Until then, the git pin tracks the matching saorsa-core lineage -# (the rc-2026.4.2 branch) so Cargo can unify the wire types here -# with ant-protocol's re-exports. -ant-protocol = "2.2.2" - -# Core (provides EVERYTHING: networking, DHT, security, trust, storage) -saorsa-core = "0.26.2" -saorsa-pqc = "0.5" - -# Payment verification - autonomi network lookup + EVM payment -evmlib = "0.8.1" -xor_name = "5" - -# Caching - LRU cache for verified XorNames -lru = "0.16.3" -parking_lot = "0.12" # Efficient mutex for cache - -# Storage - LMDB via heed for content-addressed chunk store -heed = "0.22" - -blake3 = "1" - -# Async runtime -tokio = { version = "1.35", features = ["full", "signal"] } -tokio-util = { version = "0.7", features = ["rt"] } -futures = "0.3" - -# CLI -clap = { version = "4.5", features = ["derive", "env"] } - -# Configuration -serde = { version = "1", features = ["derive"] } -toml = "0.8" -directories = "5" - -# Auto-upgrade -reqwest = { version = "0.13", features = ["json", "rustls"], default-features = false } -semver = "1" - -# Logging (optional — behind `logging` feature flag) -tracing = { version = "0.1", optional = true } -tracing-subscriber = { version = "0.3", features = ["env-filter", "json"], optional = true } -tracing-appender = { version = "0.2", optional = true } - -# Error handling -thiserror = "2" -color-eyre = "0.6" - -# Serialization -rmp-serde = "1" -hex = "0.4" - -# Utilities -bytes = "1" -chrono = { version = "0.4", features = ["serde"] } -tempfile = "3" -rand = "0.8" -serde_json = "1" - -# Archive extraction for auto-upgrade -flate2 = "1" -tar = "0.4" -zip = "2" - -# SHA-256 hashing for binary cache integrity -sha2 = "0.10" - -# Cross-platform file locking for upgrade caches -fs2 = "0.4" - -# System page size (for LMDB map alignment during resize) -page_size = "0.6" - -# Protocol serialization -postcard = { version = "1.1.3", features = ["use-std"] } - -[target.'cfg(unix)'.dependencies] -libc = "0.2" - -[target.'cfg(windows)'.dependencies] -self-replace = "1" - -[target.'cfg(target_os = "macos")'.dependencies] -objc2 = "0.6" -objc2-foundation = { version = "0.3", features = ["NSProcessInfo", "NSString"] } - -[dev-dependencies] -tokio-test = "0.4" -proptest = "1" -alloy = { version = "1", features = ["node-bindings"] } -serial_test = "3" - -# E2E test infrastructure (run with --features test-utils) -[[test]] -name = "e2e" -path = "tests/e2e/mod.rs" -required-features = ["test-utils"] - -# v12 storage-bound audit attack PoCs. Uses the test-only one-shot -# commitment builder/verifier helpers, so it requires the test-utils -# feature. CI runs it via `cargo test --test poc_commitment_audit_attacks -# --features test-utils`. -[[test]] -name = "poc_commitment_audit_attacks" -path = "tests/poc_commitment_audit_attacks.rs" -required-features = ["test-utils"] - -# Live responder-handler tests for the v12 audit. Use -# LmdbStorageConfig::test_default(), gated on test-utils. -[[test]] -name = "poc_audit_handler_live" -path = "tests/poc_audit_handler_live.rs" -required-features = ["test-utils"] - -# Bootstrap-stall DoS regression marker (documents the unfixed attack; the -# eventual fix must land with a follow-up test asserting bounded drain). -# Declared like the other PoC suites so CI invokes it explicitly. -[[test]] -name = "poc_bootstrap_stall" -path = "tests/poc_bootstrap_stall.rs" -required-features = ["test-utils"] - -[features] -default = ["logging"] -# Enable tracing/logging infrastructure. -# Included in `default` so dev builds (`cargo build`, `cargo test`) get logging -# automatically. Release builds strip it: -# cargo build --release --no-default-features -logging = ["tracing", "tracing-subscriber", "tracing-appender"] -# Expose test helpers (cache_insert, payment_verifier accessor) for -# integration tests and downstream test harnesses. -test-utils = [] - -[profile.release] -lto = true -codegen-units = 1 -panic = "abort" -strip = true - -[profile.dev] -# Faster builds for development -opt-level = 1 - -[lints.rust] -unsafe_code = "deny" -missing_docs = "warn" - -[lints.clippy] -all = { level = "warn", priority = -1 } -pedantic = { level = "warn", priority = -1 } -nursery = { level = "warn", priority = -1 } -unwrap_used = "deny" -expect_used = "deny" -panic = "deny" -# Allow async functions that will have await statements added later -unused_async = "allow" -# Allow slightly complex functions during initial development -cognitive_complexity = "allow" -# Allow non-const functions during initial development (may need runtime features later) -missing_const_for_fn = "allow" diff --git a/vendor/saorsa-core/.cache/saorsa/bootstrap_cache.json b/vendor/saorsa-core/.cache/saorsa/bootstrap_cache.json deleted file mode 100644 index 14c5ebc6..00000000 --- a/vendor/saorsa-core/.cache/saorsa/bootstrap_cache.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "version": 1, - "instance_id": "61794_1765636149551", - "timestamp": "2025-12-13T14:29:09.565437Z", - "contacts": {}, - "checksum": 15130871412783076140 -} \ No newline at end of file diff --git a/vendor/saorsa-core/.cache/saorsa/bootstrap_cache.lock b/vendor/saorsa-core/.cache/saorsa/bootstrap_cache.lock deleted file mode 100644 index e69de29b..00000000 From 243a462fb625c28a69756984c1e0f13d891ed886 Mon Sep 17 00:00:00 2001 From: Nic Date: Thu, 23 Jul 2026 09:31:50 +0100 Subject: [PATCH 9/9] fix(cli): refuse ambiguous EVM selection; move EVM resolution into ant-core (V2-743, V2-471) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --evm-network defaulted to arbitrum-one unconditionally, so a user passing --devnet-manifest with a custom EVM block (but not --evm-network local) silently paid against the mainnet vault address on the manifest's chain: the tx 'succeeds' as a no-op against an EOA, gas is spent, ANT allowance goes to a useless spender, and every chunk PUT then fails median-payment verification. The failure reads like a payment-protocol bug, not a flag bug (V2-471). - string->EvmNetwork resolution (incl. manifest evm-block parsing for 'local') moves to ant_core::config::resolve_evm_network with typed errors, per the V2-189 audit (finding #5) — frontends share one implementation - the default stays arbitrum-one EXCEPT when a devnet manifest carrying an evm block is loaded: that combination now errors (Error::EvmNetworkAmbiguous) and asks for an explicit choice, so the trap path fails loudly before any tokens move while plain mainnet usage is unchanged - selecting a preset while the manifest carries an EVM block warns that the manifest's EVM config is ignored - ant-cli drops its direct reqwest dependency (last use was the moved URL parse) Co-Authored-By: Claude Fable 5 --- CHANGELOG.md | 1 + Cargo.lock | 1 - ant-cli/Cargo.toml | 1 - ant-cli/src/cli.rs | 10 ++- ant-cli/src/main.rs | 57 ++++++---------- ant-core/src/config.rs | 147 ++++++++++++++++++++++++++++++++++++++++- ant-core/src/error.rs | 14 ++++ 7 files changed, 188 insertions(+), 43 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 185a6394..08e0c27e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - `ant node add --json` no longer interleaves binary-download progress with the JSON result; progress output now goes to stderr (was stdout) and is suppressed entirely in JSON mode. ### Changed +- `--evm-network` still defaults to `arbitrum-one`, **except** when a devnet manifest carrying an EVM block is loaded: that combination now errors and asks for an explicit choice (`local` to use the manifest, or a preset to override it). The old behavior silently overrode the manifest's EVM config, producing no-op mainnet-vault transactions on other chains that spent gas and set a useless ANT allowance before every chunk PUT failed payment verification. An explicit preset selected alongside a manifest EVM block now prints a warning that the manifest's EVM config is ignored. No change for mainnet users or read-only operations. - Default network binding changed from IPv4-only to IPv6 dual-stack. Hosts without a working IPv6 stack should pass `--ipv4-only` to avoid advertising unreachable v6 addresses to the DHT (which causes slow connects and junk address records). - `ant file upload` now writes datamaps as `..datamap` instead of stripping the extension. Uploading `photo.jpg` produces `photo.jpg.datamap` (was `photo.datamap`). Existing datamaps remain readable. - `ant file upload` no longer silently overwrites an existing datamap. Repeated uploads of the same source path produce `name-2.datamap`, `name-3.datamap`, … capped at 100 attempts. Pass `--overwrite` to restore the previous behaviour. diff --git a/Cargo.lock b/Cargo.lock index 20ed6086..6c2ededb 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -824,7 +824,6 @@ dependencies = [ "colored", "hex", "indicatif", - "reqwest 0.12.28", "rmp-serde", "serde", "serde_json", diff --git a/ant-cli/Cargo.toml b/ant-cli/Cargo.toml index 39b3cd81..34303c90 100644 --- a/ant-cli/Cargo.toml +++ b/ant-cli/Cargo.toml @@ -19,7 +19,6 @@ clap = { version = "4", features = ["derive", "env"] } colored = "3.1.1" hex = "0.4" indicatif = "0.17" -reqwest = { version = "0.12", features = ["json"] } rmp-serde = "1" serde = { version = "1", features = ["derive"] } serde_json = "1" diff --git a/ant-cli/src/cli.rs b/ant-cli/src/cli.rs index 109ddb67..39cfb5d3 100644 --- a/ant-cli/src/cli.rs +++ b/ant-cli/src/cli.rs @@ -76,9 +76,13 @@ pub struct Cli { #[arg(short, long, action = ArgAction::Count)] pub verbose: u8, - /// EVM network for payment processing (arbitrum-one, arbitrum-sepolia, local). - #[arg(long, default_value = "arbitrum-one")] - pub evm_network: String, + /// EVM network for payment processing: arbitrum-one, arbitrum-sepolia, + /// or local (reads the devnet manifest's EVM config). Defaults to + /// arbitrum-one — except when a devnet manifest with EVM config is + /// loaded, where an explicit choice is required so an irreversible + /// on-chain payment never silently targets the wrong network. + #[arg(long)] + pub evm_network: Option, #[command(subcommand)] pub command: Commands, diff --git a/ant-cli/src/main.rs b/ant-cli/src/main.rs index 5b29fc84..fb21be25 100644 --- a/ant-cli/src/main.rs +++ b/ant-cli/src/main.rs @@ -13,8 +13,8 @@ use tracing_subscriber::{fmt, layer::SubscriberExt, util::SubscriberInitExt, Env use ant_core::data::{ peer_cache::{self, BootstrapAddressFilter}, - Client, ClientConfig, CoreNodeConfig, CustomNetwork, DevnetManifest, EvmAddress, EvmNetwork, - IPDiversityConfig, MultiAddr, NodeMode, P2PNode, Wallet, MAX_WIRE_MESSAGE_SIZE, + Client, ClientConfig, CoreNodeConfig, DevnetManifest, EvmNetwork, IPDiversityConfig, MultiAddr, + NodeMode, P2PNode, Wallet, MAX_WIRE_MESSAGE_SIZE, }; use cli::{Cli, Commands}; @@ -180,7 +180,7 @@ struct DataCliContext { quote_timeout_secs: u64, store_timeout_secs: Option, chunk_get_timeout_secs: Option, - evm_network: String, + evm_network: Option, quote_concurrency: Option, store_concurrency: Option, } @@ -357,7 +357,7 @@ async fn build_data_client( let key = private_key .as_ref() .ok_or_else(|| anyhow::anyhow!("SECRET_KEY environment variable required"))?; - let network = resolve_evm_network(&ctx.evm_network, manifest.as_ref())?; + let network = resolve_evm_network(ctx.evm_network.as_deref(), manifest.as_ref())?; let wallet = create_wallet(key, network)?; info!("Wallet configured for EVM payments"); client = client.with_wallet(wallet); @@ -411,47 +411,30 @@ fn resolve_evm_network_and_manifest( ctx: &DataCliContext, ) -> anyhow::Result<(EvmNetwork, Option)> { let manifest = load_manifest(ctx)?; - let network = resolve_evm_network(&ctx.evm_network, manifest.as_ref())?; + let network = resolve_evm_network(ctx.evm_network.as_deref(), manifest.as_ref())?; Ok((network, manifest)) } +/// Resolve the EVM network through ant-core, with a CLI-side warning for +/// the one remaining silent-discard path: a devnet manifest that carries +/// an EVM block while a preset network is selected (V2-471). fn resolve_evm_network( - evm_network: &str, + evm_network: Option<&str>, manifest: Option<&DevnetManifest>, ) -> anyhow::Result { - match evm_network { - "arbitrum-one" => Ok(EvmNetwork::ArbitrumOne), - "arbitrum-sepolia" => Ok(EvmNetwork::ArbitrumSepoliaTest), - "local" => { - if let Some(m) = manifest { - if let Some(ref evm) = m.evm { - let rpc_url: reqwest::Url = evm - .rpc_url - .parse() - .map_err(|e| anyhow::anyhow!("Invalid RPC URL: {e}"))?; - let token_addr: EvmAddress = evm - .payment_token_address - .parse() - .map_err(|e| anyhow::anyhow!("Invalid token address: {e}"))?; - let vault_addr: EvmAddress = evm - .payment_vault_address - .parse() - .map_err(|e| anyhow::anyhow!("Invalid payment vault address: {e}"))?; - return Ok(EvmNetwork::Custom(CustomNetwork { - rpc_url_http: rpc_url, - payment_token_address: token_addr, - payment_vault_address: vault_addr, - })); - } - } - anyhow::bail!("EVM network 'local' requires --devnet-manifest with EVM info") - } - other => { - anyhow::bail!( - "Unsupported EVM network: {other}. Use 'arbitrum-one', 'arbitrum-sepolia', or 'local'." - ) + if let (Some(m), Some(name)) = (manifest, evm_network) { + if m.evm.is_some() && name != "local" { + eprintln!( + "warning: the devnet manifest contains an EVM block, but \ + --evm-network={name} selects a preset; the manifest's EVM \ + config is ignored. Pass --evm-network local to use it." + ); } } + Ok(ant_core::config::resolve_evm_network( + evm_network, + manifest, + )?) } async fn create_client_node( diff --git a/ant-core/src/config.rs b/ant-core/src/config.rs index 0071de6f..a2baccba 100644 --- a/ant-core/src/config.rs +++ b/ant-core/src/config.rs @@ -1,7 +1,7 @@ use std::net::SocketAddr; use std::path::PathBuf; -use crate::data::{DevnetManifest, MultiAddr}; +use crate::data::{CustomNetwork, DevnetManifest, EvmAddress, EvmNetwork, MultiAddr}; use crate::error::{Error, Result}; /// Returns the platform-appropriate data directory for ant. @@ -124,6 +124,60 @@ pub fn resolve_bootstrap_peers( Err(Error::NoBootstrapPeers) } +/// Resolve the EVM network for payment operations. +/// +/// `name` is the frontend's network selector (e.g. the CLI's +/// `--evm-network` flag): `arbitrum-one`, `arbitrum-sepolia`, or `local`, +/// which reads the RPC URL and contract addresses from the devnet +/// manifest's `evm` block. +/// +/// With no selector, the default is Arbitrum One (mainnet) — **unless** +/// the devnet manifest carries an `evm` block. Defaulting to mainnet +/// used to silently discard that config: the mainnet vault address +/// doesn't exist on other chains, so payments "succeeded" as no-op +/// transactions and every subsequent chunk PUT failed verification +/// (V2-471). In that one ambiguous case the caller must choose +/// explicitly or get [`Error::EvmNetworkAmbiguous`]. +pub fn resolve_evm_network( + name: Option<&str>, + manifest: Option<&DevnetManifest>, +) -> Result { + match name { + None => { + if manifest.is_some_and(|m| m.evm.is_some()) { + Err(Error::EvmNetworkAmbiguous) + } else { + Ok(EvmNetwork::ArbitrumOne) + } + } + Some("arbitrum-one") => Ok(EvmNetwork::ArbitrumOne), + Some("arbitrum-sepolia") => Ok(EvmNetwork::ArbitrumSepoliaTest), + Some("local") => { + let evm = manifest + .and_then(|m| m.evm.as_ref()) + .ok_or(Error::EvmManifestRequired)?; + let rpc_url: reqwest::Url = evm + .rpc_url + .parse() + .map_err(|e| Error::InvalidEvmManifest(format!("invalid RPC URL: {e}")))?; + let payment_token_address: EvmAddress = + evm.payment_token_address.parse().map_err(|e| { + Error::InvalidEvmManifest(format!("invalid payment token address: {e}")) + })?; + let payment_vault_address: EvmAddress = + evm.payment_vault_address.parse().map_err(|e| { + Error::InvalidEvmManifest(format!("invalid payment vault address: {e}")) + })?; + Ok(EvmNetwork::Custom(CustomNetwork { + rpc_url_http: rpc_url, + payment_token_address, + payment_vault_address, + })) + } + Some(other) => Err(Error::UnsupportedEvmNetwork(other.to_string())), + } +} + #[derive(serde::Deserialize)] struct BootstrapConfig { peers: Vec, @@ -189,6 +243,97 @@ mod tests { assert_eq!(peers, vec![addr]); } + fn manifest_with_evm() -> DevnetManifest { + let mut m = test_manifest(vec!["10.0.0.2:10000".parse().unwrap()]); + m.evm = Some(ant_protocol::DevnetEvmInfo { + rpc_url: "http://127.0.0.1:8545".to_string(), + wallet_private_key: + "0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80".to_string(), + payment_token_address: "0x5FbDB2315678afecb367f032d93F642f64180aa3".to_string(), + payment_vault_address: "0xe7f1725E7734CE288F8367e1Bb143E90bb3F0512".to_string(), + }); + m + } + + #[test] + fn resolve_evm_network_defaults_to_mainnet_without_manifest_evm() { + assert!(matches!( + resolve_evm_network(None, None), + Ok(EvmNetwork::ArbitrumOne) + )); + let no_evm = test_manifest(vec![]); + assert!(matches!( + resolve_evm_network(None, Some(&no_evm)), + Ok(EvmNetwork::ArbitrumOne) + )); + } + + #[test] + fn resolve_evm_network_requires_choice_when_manifest_has_evm() { + // A manifest with an EVM block plus no explicit selection is the + // V2-471 trap: refuse rather than silently pay against mainnet. + assert!(matches!( + resolve_evm_network(None, Some(&manifest_with_evm())), + Err(Error::EvmNetworkAmbiguous) + )); + } + + #[test] + fn resolve_evm_network_maps_presets() { + assert!(matches!( + resolve_evm_network(Some("arbitrum-one"), None), + Ok(EvmNetwork::ArbitrumOne) + )); + assert!(matches!( + resolve_evm_network(Some("arbitrum-sepolia"), None), + Ok(EvmNetwork::ArbitrumSepoliaTest) + )); + } + + #[test] + fn resolve_evm_network_local_reads_manifest() { + let manifest = manifest_with_evm(); + let network = resolve_evm_network(Some("local"), Some(&manifest)).unwrap(); + match network { + EvmNetwork::Custom(custom) => { + assert_eq!(custom.rpc_url_http.as_str(), "http://127.0.0.1:8545/"); + assert_eq!( + format!("{:?}", custom.payment_token_address).to_lowercase(), + "0x5fbdb2315678afecb367f032d93f642f64180aa3" + ); + } + other => panic!("expected Custom network, got {other:?}"), + } + } + + #[test] + fn resolve_evm_network_local_requires_manifest_evm_block() { + // No manifest at all, and a manifest without an evm block. + assert!(matches!( + resolve_evm_network(Some("local"), None), + Err(Error::EvmManifestRequired) + )); + let no_evm = test_manifest(vec![]); + assert!(matches!( + resolve_evm_network(Some("local"), Some(&no_evm)), + Err(Error::EvmManifestRequired) + )); + } + + #[test] + fn resolve_evm_network_rejects_unknown_and_bad_manifest_values() { + assert!(matches!( + resolve_evm_network(Some("mainnet"), None), + Err(Error::UnsupportedEvmNetwork(_)) + )); + let mut bad = manifest_with_evm(); + bad.evm.as_mut().unwrap().payment_vault_address = "not-an-address".to_string(); + assert!(matches!( + resolve_evm_network(Some("local"), Some(&bad)), + Err(Error::InvalidEvmManifest(_)) + )); + } + #[test] fn resolve_bootstrap_errors_on_empty_manifest() { let manifest = test_manifest(vec![]); diff --git a/ant-core/src/error.rs b/ant-core/src/error.rs index cfa212db..d9365b9f 100644 --- a/ant-core/src/error.rs +++ b/ant-core/src/error.rs @@ -79,6 +79,20 @@ pub enum Error { )] NoBootstrapPeers, + #[error( + "Devnet manifest contains EVM config but no EVM network was selected: pass 'local' to use the manifest's EVM config, or an explicit preset ('arbitrum-one', 'arbitrum-sepolia') to override it" + )] + EvmNetworkAmbiguous, + + #[error("Unsupported EVM network: {0}. Use 'arbitrum-one', 'arbitrum-sepolia', or 'local'.")] + UnsupportedEvmNetwork(String), + + #[error("EVM network 'local' requires a devnet manifest with EVM info")] + EvmManifestRequired, + + #[error("Invalid EVM info in devnet manifest: {0}")] + InvalidEvmManifest(String), + #[error("Node count {count} exceeds maximum of {max} per call")] InvalidNodeCount { count: u16, max: u16 },