diff --git a/src/bin/ant-devnet/cli.rs b/src/bin/ant-devnet/cli.rs index ae1e0178..55a34717 100644 --- a/src/bin/ant-devnet/cli.rs +++ b/src/bin/ant-devnet/cli.rs @@ -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, + + /// 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, + + /// Serve the manifest over a read-only HTTP API on this port (binds + /// 0.0.0.0). Any LAN device can then GET + /// `http://:/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, +} + +#[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() + ); + } } diff --git a/src/bin/ant-devnet/main.rs b/src/bin/ant-devnet/main.rs index 44d85b7b..a55a0fa0 100644 --- a/src/bin/ant-devnet/main.rs +++ b/src/bin/ant-devnet/main.rs @@ -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 `: 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 `: 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))] @@ -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())?; + } + + 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> { + 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 @@ -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, + 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:) \ + 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()); + } + }); +} diff --git a/src/devnet.rs b/src/devnet.rs index 83f71f90..704e3db3 100644 --- a/src/devnet.rs +++ b/src/devnet.rs @@ -165,6 +165,10 @@ pub struct DevnetConfig { /// When `Some`, nodes will use this network (e.g. Anvil testnet) for /// on-chain verification. Defaults to Arbitrum One when `None`. pub evm_network: Option, + + /// Optional IPv4 to advertise to peers/clients (LAN devnet). When `Some`, + /// nodes bind 0.0.0.0 and advertise this IP instead of 127.0.0.1. + pub advertise_ip: Option, } impl Default for DevnetConfig { @@ -186,6 +190,7 @@ impl Default for DevnetConfig { enable_node_logging: false, cleanup_data_dir: true, evm_network: None, + advertise_ip: None, } } } @@ -436,7 +441,12 @@ impl Devnet { self.nodes .iter() .take(self.config.bootstrap_count) - .map(|n| MultiAddr::quic(SocketAddr::from((Ipv4Addr::LOCALHOST, n.port)))) + .map(|n| { + MultiAddr::quic(SocketAddr::from(( + self.config.advertise_ip.unwrap_or(Ipv4Addr::LOCALHOST), + n.port, + ))) + }) .collect() } @@ -471,7 +481,12 @@ impl Devnet { )) })? .iter() - .map(|n| MultiAddr::quic(SocketAddr::from((Ipv4Addr::LOCALHOST, n.port)))) + .map(|n| { + MultiAddr::quic(SocketAddr::from(( + self.config.advertise_ip.unwrap_or(Ipv4Addr::LOCALHOST), + n.port, + ))) + }) .collect(); for i in self.config.bootstrap_count..self.config.node_count { @@ -582,7 +597,7 @@ impl Devnet { let mut core_config = CoreNodeConfig::builder() .port(node.port) - .local(true) + .local(self.config.advertise_ip.is_none()) .max_message_size(crate::ant_protocol::MAX_WIRE_MESSAGE_SIZE) .build() .map_err(|e| DevnetError::Core(format!("Failed to create core config: {e}")))?; @@ -774,3 +789,17 @@ impl Drop for Devnet { } } } + +#[cfg(test)] +mod tests { + use super::*; + + /// Backward-compatibility contract: a default config advertises no LAN IP, + /// so nodes bind loopback (`.local(advertise_ip.is_none())` → `.local(true)`) + /// and stamp `127.0.0.1` into the manifest bootstrap addresses — exactly as + /// before the `--host` flag existed. + #[test] + fn default_config_has_no_advertise_ip() { + assert!(DevnetConfig::default().advertise_ip.is_none()); + } +}