Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
b0dac85
feat(ant-devnet): add opt-in LAN / external-devnet mode
Jul 13, 2026
f82088b
refactor(ant-devnet): extract resolve_evm_info to satisfy clippy too_…
Jul 13, 2026
96ab80a
ant-devnet: address review — validate --host, enforce GET, exclusive …
Jul 13, 2026
998493d
ant-devnet: address review round 2 — bind errors propagate, port/host…
Jul 13, 2026
77c9b5f
fix(pruning): make prune candidacy independent of repair-hint history
mickvandijke Jul 13, 2026
9d180fc
chore: improve audit responder capacity tracking and logging
mickvandijke Jul 9, 2026
426ef16
fix(replication): raise audit responder capacity caps
mickvandijke Jul 9, 2026
622cdb0
fix(replication): batch record prune audit challenges
mickvandijke Jul 9, 2026
c7b0626
fix(replication): align prune audit key limits
mickvandijke Jul 9, 2026
a00b38e
fix(replication): address prune audit review feedback
mickvandijke Jul 9, 2026
4df0fc8
chore(release): cut rc-2026.7.1
jacderida Jul 13, 2026
95959da
Merge pull request #175 from WithAutonomi/fix/prune-candidacy-liveness
jacderida Jul 13, 2026
81c4b87
Merge remote-tracking branch 'upstream/rc-2026.7.1' into fix-audit-ti…
jacderida Jul 13, 2026
57a1155
fix(replication): adapt merged prune-audit grading tests to batch API
jacderida Jul 13, 2026
ed06edb
Merge pull request #170 from WithAutonomi/fix-audit-timeouts-part-cap…
jacderida Jul 13, 2026
7ef46ee
Merge pull request #174 from WithAutonomi/feat/ant-devnet-lan-mode
jacderida Jul 15, 2026
06b97e1
chore(release): promote rc-2026.7.1 to 0.14.4
jacderida Jul 15, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[package]
name = "ant-node"
version = "0.14.3"
version = "0.14.4"
Comment on lines 1 to +3
edition = "2021"
authors = ["David Irvine <david.irvine@maidsafe.net>"]
description = "Pure quantum-proof network node for the Autonomi decentralized network"
Expand Down
34 changes: 26 additions & 8 deletions docs/REPLICATION_DESIGN.md

Large diffs are not rendered by default.

76 changes: 76 additions & 0 deletions src/bin/ant-devnet/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -62,4 +62,80 @@ pub struct Cli {
/// payments against the local chain.
#[arg(long)]
pub enable_evm: bool,

/// Advertise this IPv4 to peers/clients and bind 0.0.0.0, so the devnet is
/// reachable from other devices on the LAN. When omitted, nodes bind
/// loopback (127.0.0.1) as before (single-machine only).
#[arg(long)]
pub host: Option<std::net::Ipv4Addr>,

/// EVM network for node payment verification. `arbitrum-sepolia` makes
/// nodes verify against the real deployed Arbitrum Sepolia contracts —
/// no local Anvil and no embedded wallet key (bring your own funded
/// wallet via an external signer). Omit for the local-Anvil devnet
/// (`--enable-evm`). Mutually exclusive with `--enable-evm`.
#[arg(long, conflicts_with = "enable_evm")]
pub evm_network: Option<String>,

/// Serve the manifest over a read-only HTTP API on this port (binds
/// 0.0.0.0). Any LAN device can then GET
/// `http://<host>:<port>/api/devnet-manifest.json` (and `/api/info`) —
/// no file copying. Open CORS. Suggested: 8088. Requires `--host` (the API
/// advertises a LAN URL, so a loopback-only devnet would be misleading).
#[arg(long, requires = "host", value_parser = clap::value_parser!(u16).range(1..))]
pub serve_port: Option<u16>,
}

#[cfg(test)]
mod tests {
use super::*;
use std::net::Ipv4Addr;

/// Backward-compatibility contract: with none of the LAN flags, the new
/// options parse to `None`, so behavior is identical to before.
#[test]
fn lan_flags_default_off() {
let cli = Cli::parse_from(["ant-devnet", "--preset", "small"]);
assert!(cli.host.is_none());
assert!(cli.evm_network.is_none());
assert!(cli.serve_port.is_none());
}

/// The LAN flags parse into the expected typed values.
#[test]
fn lan_flags_parse() {
let cli = Cli::parse_from([
"ant-devnet",
"--host",
"192.168.1.100",
"--evm-network",
"arbitrum-sepolia",
"--serve-port",
"8088",
]);
assert_eq!(cli.host, Some(Ipv4Addr::new(192, 168, 1, 100)));
assert_eq!(cli.evm_network.as_deref(), Some("arbitrum-sepolia"));
assert_eq!(cli.serve_port, Some(8088));
}

/// A non-IPv4 `--host` is rejected by clap's value parser.
#[test]
fn host_rejects_non_ipv4() {
assert!(Cli::try_parse_from(["ant-devnet", "--host", "not-an-ip"]).is_err());
}

/// `--serve-port` requires `--host` (it advertises a LAN URL).
#[test]
fn serve_port_requires_host() {
assert!(Cli::try_parse_from(["ant-devnet", "--serve-port", "8088"]).is_err());
}

/// `--serve-port 0` is rejected (an ephemeral port would be advertised as `:0`).
#[test]
fn serve_port_rejects_zero() {
assert!(
Cli::try_parse_from(["ant-devnet", "--host", "192.168.1.5", "--serve-port", "0"])
.is_err()
);
}
}
247 changes: 218 additions & 29 deletions src/bin/ant-devnet/main.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,29 @@
//! ant-devnet CLI entry point.
//!
//! Runs a local devnet for testing. By default all nodes bind loopback
//! (`127.0.0.1`), so only the host machine can reach them.
//!
//! Three opt-in flags extend this to a **LAN / external-devnet** scenario for
//! testing from another device (a phone, a simulator, a second machine). All
//! default off — omitting them reproduces the original loopback behavior:
//!
//! - `--host <ipv4>`: bind `0.0.0.0` and advertise this LAN IP in the manifest's
//! bootstrap addresses, so other devices on the LAN can connect.
//! - `--evm-network arbitrum-sepolia`: verify payments against the real deployed
//! Arbitrum Sepolia contracts (no local Anvil, empty wallet key) — exercises
//! the external-signer payment flow.
//! - `--serve-port <port>`: expose the manifest over a small read-only HTTP API
//! (`GET /api/devnet-manifest.json` + `/api/info`) so devices fetch it instead
//! of copying files.
//!
//! ```text
//! # single-machine, local Anvil (unchanged default behavior)
//! ant-devnet --preset small --enable-evm
//!
//! # LAN devnet backed by Arbitrum Sepolia, manifest served over HTTP
//! ant-devnet --preset small --host 192.168.1.100 \
//! --evm-network arbitrum-sepolia --serve-port 8088
//! ```

#![cfg_attr(not(feature = "logging"), allow(unused_variables))]

Expand Down Expand Up @@ -61,8 +86,86 @@ async fn main() -> color_eyre::Result<()> {
config.stabilization_timeout = std::time::Duration::from_secs(timeout_secs);
}

// Start Anvil and deploy contracts if EVM is enabled
let evm_info = if cli.enable_evm {
// A non-unicast --host would stamp unreachable bootstrap addresses into the
// manifest (LAN mode would fail non-obviously), so reject it early.
if let Some(host) = cli
.host
.filter(|h| h.is_loopback() || h.is_unspecified() || h.is_multicast() || h.is_broadcast())
{
return Err(color_eyre::eyre::eyre!(
"--host must be a routable unicast LAN IPv4 (got {host}); \
loopback/unspecified/multicast/broadcast are not reachable bootstrap addresses"
));
}
config.advertise_ip = cli.host;
let evm_info =
resolve_evm_info(cli.evm_network.as_deref(), cli.enable_evm, &mut config).await?;

let mut devnet = Devnet::new(config).await?;
devnet.start().await?;

let manifest = DevnetManifest {
base_port: devnet.config().base_port,
node_count: devnet.config().node_count,
bootstrap: devnet.bootstrap_addrs(),
data_dir: devnet.config().data_dir.clone(),
created_at: chrono::Utc::now().to_rfc3339(),
evm: evm_info,
};

let json = serde_json::to_string_pretty(&manifest)?;
if let Some(path) = cli.manifest {
tokio::fs::write(&path, &json).await?;
ant_node::logging::info!("Wrote manifest to {}", path.display());
} else {
println!("{json}");
}

// Optional read-only HTTP API so LAN devices fetch the manifest instead of
// copying files (GET /api/devnet-manifest.json + /api/info).
if let Some(port) = cli.serve_port {
serve_manifest_api(port, cli.host, &manifest, json.clone())?;
}
Comment on lines +124 to +128

ant_node::logging::info!("Devnet running. Press Ctrl+C to stop.");
tokio::signal::ctrl_c().await?;

devnet.shutdown().await?;
Ok(())
}

/// Resolve which EVM backing the devnet uses, updating `config` accordingly:
/// an **external** network (`--evm-network`, e.g. Arbitrum Sepolia verified
/// against the real deployed contracts, no embedded wallet key); a **local
/// Anvil** chain (`--enable-evm`); or **none**. External takes precedence.
async fn resolve_evm_info(
evm_network: Option<&str>,
enable_evm: bool,
config: &mut DevnetConfig,
) -> color_eyre::Result<Option<DevnetEvmInfo>> {
if let Some(net_name) = evm_network {
let network = match net_name {
"arbitrum-sepolia" => evmlib::Network::ArbitrumSepoliaTest,
other => {
return Err(color_eyre::eyre::eyre!(
"Unsupported --evm-network {other} (supported: arbitrum-sepolia)"
))
}
};
let rpc_url = network.rpc_url().to_string();
let token_addr = format!("{:?}", network.payment_token_address());
let vault_addr = format!("{:?}", network.payment_vault_address());
ant_node::logging::info!(
"Using external EVM network {net_name}: rpc={rpc_url} token={token_addr} vault={vault_addr}"
);
config.evm_network = Some(network);
Ok(Some(DevnetEvmInfo {
rpc_url,
wallet_private_key: String::new(),
payment_token_address: token_addr,
payment_vault_address: vault_addr,
}))
} else if enable_evm {
ant_node::logging::info!("Starting local Anvil blockchain for EVM payment enforcement...");
let testnet = evmlib::testnet::Testnet::new()
.await
Expand Down Expand Up @@ -94,39 +197,125 @@ async fn main() -> color_eyre::Result<()> {
// This is necessary because AnvilInstance stops Anvil when dropped
std::mem::forget(testnet);

Some(DevnetEvmInfo {
Ok(Some(DevnetEvmInfo {
rpc_url,
wallet_private_key: wallet_key,
payment_token_address: token_addr,
payment_vault_address: vault_addr,
})
}))
} else {
None
};

let mut devnet = Devnet::new(config).await?;
devnet.start().await?;

let manifest = DevnetManifest {
base_port: devnet.config().base_port,
node_count: devnet.config().node_count,
bootstrap: devnet.bootstrap_addrs(),
data_dir: devnet.config().data_dir.clone(),
created_at: chrono::Utc::now().to_rfc3339(),
evm: evm_info,
};

let json = serde_json::to_string_pretty(&manifest)?;
if let Some(path) = cli.manifest {
tokio::fs::write(&path, &json).await?;
ant_node::logging::info!("Wrote manifest to {}", path.display());
} else {
println!("{json}");
Ok(None)
}
}

ant_node::logging::info!("Devnet running. Press Ctrl+C to stop.");
tokio::signal::ctrl_c().await?;

devnet.shutdown().await?;
/// Build the `/api/info` payload for `manifest` and start the read-only HTTP
/// server on `port`. `host` is the advertised LAN IP when set, otherwise a
/// best-effort local-IP guess is used for the URLs it reports.
fn serve_manifest_api(
port: u16,
host: Option<std::net::Ipv4Addr>,
manifest: &DevnetManifest,
manifest_json: String,
) -> color_eyre::Result<()> {
let host_ip = host.map_or_else(local_ip_guess, |i| i.to_string());
let evm_block = manifest.evm.as_ref().map_or(serde_json::Value::Null, |e| {
let loopback = e.rpc_url.contains("127.0.0.1") || e.rpc_url.contains("localhost");
serde_json::json!({
"rpc_url": e.rpc_url,
"network": if loopback { "local-anvil" } else { "external" },
"reachable_from_lan": !loopback,
"note": if loopback {
format!(
"Anvil binds loopback on the host — bridge it (socat \
TCP-LISTEN:8545,fork,bind=0.0.0.0 TCP:127.0.0.1:<anvil-port>) \
and use http://{host_ip}:8545/."
)
} else {
"Public RPC — reachable directly from any device.".to_string()
},
})
});
let bootstrap = serde_json::to_value(&manifest.bootstrap)?;
let info = serde_json::json!({
"host_ip": host_ip,
"manifest_url": format!("http://{host_ip}:{port}/api/devnet-manifest.json"),
"node_count": manifest.node_count as u64,
"bootstrap": bootstrap,
"evm": evm_block,
});
let info_json = serde_json::to_string_pretty(&info)?;
// Bind synchronously so a failure (e.g. the port is already in use)
// propagates to the caller instead of the devnet silently coming up
// without its manifest API.
let listener = std::net::TcpListener::bind(("0.0.0.0", port)).map_err(|e| {
color_eyre::eyre::eyre!("failed to bind manifest API on 0.0.0.0:{port}: {e}")
})?;
ant_node::logging::info!(
"manifest API on http://0.0.0.0:{port}/api/devnet-manifest.json (+ /api/info)"
);
spawn_manifest_server(listener, manifest_json, info_json);
Ok(())
}

/// Best-effort primary LAN IP (src of the default route) for the info endpoint.
fn local_ip_guess() -> String {
std::net::UdpSocket::bind("0.0.0.0:0")
.and_then(|s| {
s.connect("1.1.1.1:80")?;
Ok(s.local_addr()?.ip().to_string())
})
.unwrap_or_else(|_| "127.0.0.1".to_string())
}

/// Run a tiny read-only HTTP server on `listener` (its own thread) exposing the
/// manifest over the LAN. GET-only, open CORS; hand-rolled HTTP/1.1 so there's
/// no new dependency. Connections are handled **inline, one at a time** — the
/// payloads are tiny and a devnet serves a handful of LAN devices, so a single
/// thread bounds resource use (no per-connection thread to exhaust). Each
/// connection gets a read timeout so a slow/idle client can't stall the loop.
/// The thread is detached and dies when the process exits on Ctrl+C.
fn spawn_manifest_server(
listener: std::net::TcpListener,
manifest_json: String,
info_json: String,
) {
std::thread::spawn(move || {
use std::io::{Read, Write};
for stream in listener.incoming() {
let Ok(mut stream) = stream else { continue };
let _ = stream.set_read_timeout(Some(std::time::Duration::from_secs(5)));
let mut buf = [0u8; 2048];
let n = stream.read(&mut buf).unwrap_or(0);
let req = String::from_utf8_lossy(&buf[..n]);
let mut tokens = req.split_whitespace();
let method = tokens.next().unwrap_or("");
let raw = tokens.next().unwrap_or("/");
let path = raw.split('?').next().unwrap_or("/").trim_end_matches('/');
// Read-only API — only GET is allowed; anything else is 405.
let (status, body) = if method == "GET" {
match path {
"/api/devnet-manifest.json" => ("200 OK", manifest_json.as_str()),
"/api/info" => ("200 OK", info_json.as_str()),
"" | "/api" => (
"200 OK",
"{\"service\":\"ant-devnet manifest API\",\
\"endpoints\":[\"/api/devnet-manifest.json\",\"/api/info\"]}",
),
_ => ("404 Not Found", "{\"error\":\"not found\"}"),
}
} else {
(
"405 Method Not Allowed",
"{\"error\":\"method not allowed\"}",
)
};
let resp = format!(
"HTTP/1.1 {status}\r\nContent-Type: application/json\r\n\
Access-Control-Allow-Origin: *\r\nCache-Control: no-store\r\n\
Content-Length: {}\r\nConnection: close\r\n\r\n{body}",
body.len()
);
let _ = stream.write_all(resp.as_bytes());
}
});
}
Loading
Loading