Skip to content

feat(ant-devnet): opt-in LAN / external-devnet mode (--host / --evm-network / --serve-port)#174

Open
Nic-dorman wants to merge 4 commits into
mainfrom
feat/ant-devnet-lan-mode
Open

feat(ant-devnet): opt-in LAN / external-devnet mode (--host / --evm-network / --serve-port)#174
Nic-dorman wants to merge 4 commits into
mainfrom
feat/ant-devnet-lan-mode

Conversation

@Nic-dorman

Copy link
Copy Markdown

What

Adds a LAN / external-devnet scenario to the existing ant-devnet harness so a devnet can be tested from another device — a phone, an iOS simulator, or a second machine — rather than only from the host box. Three opt-in flags, all default off:

Flag Effect
--host <ipv4> Bind 0.0.0.0 and advertise this LAN IP in the manifest's bootstrap addresses, so other LAN devices 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> Serve the manifest over a small read-only HTTP API (GET /api/devnet-manifest.json + /api/info) so devices fetch it instead of copying files.
# unchanged default behavior (single machine, local Anvil)
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

Why here

ant-devnet is already the maintained local-devnet tool; this is a focused enhancement to it, not a new binary or module — no new dependencies (the manifest server is hand-rolled HTTP/1.1). It's the node half of enabling on-device testing of the mobile SDK; the client half already shipped in the Autonomi mobile FFI.

Backward compatibility / blast radius

  • Opt-in and default-off. No --hostadvertise_ip = None.local(true) + 127.0.0.1 bootstrap addresses, byte-identical to today.
  • Confined to the harness. Only the ant-devnet binary and the Devnet/DevnetConfig type change. The real node binary (ant-node) and the wire protocol are untouched, and nothing here is on the production node path — so deployed testnets are unaffected.
  • Minor API note: adds one field to the public DevnetConfig (advertise_ip: Option<Ipv4Addr>). It's covered by the Default impl, so ..Default::default() construction is unaffected; only a struct-literal build without ..Default::default() would need the new field.

Tests

  • cli.rs: LAN flags default to None (backward-compat contract); parse into the expected typed values; non-IPv4 --host is rejected.
  • devnet.rs: DevnetConfig::default().advertise_ip is None.
  • cargo fmt --all --check clean; strict clippy gates (-D clippy::panic/unwrap_used/expect_used) pass; release build green.

Proven end-to-end out-of-band: a physical Android device completed a full paid upload + byte-perfect download round-trip against a Sepolia-backed LAN devnet launched with these flags.

🤖 Generated with Claude Code

Extends the ant-devnet harness so a devnet can be reached from another device
on the LAN (phone, simulator, second machine). All three flags are opt-in and
default off — omitting them reproduces today's loopback-only behavior exactly.

- --host <ipv4>: bind 0.0.0.0 and advertise the given LAN IP in the manifest
  bootstrap addresses (adds DevnetConfig::advertise_ip, default None →
  .local(true) + 127.0.0.1, unchanged).
- --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>: read-only manifest HTTP API (GET /api/devnet-manifest.json
  + /api/info); hand-rolled HTTP/1.1, no new dependencies.

Node code and the wire protocol are untouched — only the ant-devnet binary and
the Devnet harness change. Adds CLI parse tests + a default-config test.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Copilot AI review requested due to automatic review settings July 13, 2026 16:09

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Adds opt-in LAN/external-devnet capabilities to the existing ant-devnet harness so a devnet can be accessed from other devices, including (a) advertising a LAN IPv4 in bootstrap addresses, (b) verifying payments against a real external EVM network, and (c) optionally serving the devnet manifest via a minimal HTTP API.

Changes:

  • Extend DevnetConfig with advertise_ip: Option<Ipv4Addr> and use it for bootstrap address construction and local/loopback binding behavior.
  • Add CLI flags --host, --evm-network, and --serve-port and wire them into devnet startup.
  • Implement a small read-only HTTP endpoint for serving the manifest and a derived /api/info payload.

Reviewed changes

Copilot reviewed 3 out of 3 changed files in this pull request and generated 4 comments.

File Description
src/devnet.rs Adds advertise_ip to devnet config, uses it to generate bootstrap MultiAddrs and toggles .local(...), plus a backward-compat test.
src/bin/ant-devnet/main.rs Wires new CLI options into devnet config, adds external EVM network selection, and introduces a tiny manifest HTTP server.
src/bin/ant-devnet/cli.rs Defines the new CLI flags and adds parsing/backward-compat tests.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.


// Start Anvil and deploy contracts if EVM is enabled
let evm_info = if cli.enable_evm {
config.advertise_ip = cli.host;
Comment on lines +232 to +239
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())
}
Comment thread src/bin/ant-devnet/main.rs Outdated
Comment on lines +264 to +276
let req = String::from_utf8_lossy(&buf[..n]);
let raw = req.split_whitespace().nth(1).unwrap_or("/");
let path = raw.split('?').next().unwrap_or("/").trim_end_matches('/');
let (status, body) = match path {
"/api/devnet-manifest.json" => ("200 OK", manifest.as_str()),
"/api/info" => ("200 OK", info.as_str()),
"" | "/api" => (
"200 OK",
"{\"service\":\"ant-devnet manifest API\",\
\"endpoints\":[\"/api/devnet-manifest.json\",\"/api/info\"]}",
),
_ => ("404 Not Found", "{\"error\":\"not found\"}"),
};
Comment thread src/bin/ant-devnet/cli.rs Outdated
Comment on lines +76 to +78
/// (`--enable-evm`).
#[arg(long)]
pub evm_network: Option<String>,
…many_lines

CI runs clippy with -D warnings and the workspace enables clippy::pedantic, so
the expanded main() tripped too_many_lines (121/100). Extract the EVM-backing
resolution into resolve_evm_info; behavior unchanged, main() back to ~85 lines.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Copilot AI review requested due to automatic review settings July 13, 2026 16:41

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 3 out of 3 changed files in this pull request and generated 2 comments.

Comment on lines +227 to +233
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": serde_json::to_value(&manifest.bootstrap).unwrap_or(serde_json::Value::Null),
"evm": evm_block,
});
Comment thread src/bin/ant-devnet/main.rs Outdated
Comment on lines +265 to +269
for stream in listener.incoming() {
let Ok(mut stream) = stream else { continue };
let manifest = manifest_json.clone();
let info = info_json.clone();
std::thread::spawn(move || {
…EVM flags, read timeout

Automated-review follow-ups on the manifest HTTP API + CLI:
- Reject a loopback/unspecified --host early (would stamp unreachable
  bootstrap addresses into the manifest).
- Manifest API now enforces GET and returns 405 for other methods.
- --evm-network and --enable-evm are mutually exclusive at the clap level.
- Cap the manifest-server handler with a 5s socket read timeout so a slow/idle
  client can't tie up a thread (listener binds 0.0.0.0).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Copilot AI review requested due to automatic review settings July 13, 2026 17:09
@Nic-dorman

Copy link
Copy Markdown
Author

Addressed the review feedback in 96ab80a:

  • --host validation — a loopback/unspecified --host is now rejected early with a clear error (it would otherwise stamp unreachable bootstrap addresses into the manifest).
  • GET enforcement — the manifest API now returns 405 for any method other than GET.
  • --evm-network / --enable-evm — made mutually exclusive at the clap level (conflicts_with).
  • Manifest server read timeout — handler sockets get a 5s read timeout so a slow/idle client can't tie up a thread (the listener binds 0.0.0.0).

Not changed, with rationale:

  • local_ip_guess() returning IPv6 — the socket binds 0.0.0.0 (IPv4) and connects to an IPv4 address, so local_addr() is always IPv4; it can't return an IPv6 literal here.
  • host_ip use-after-move — this compiles as written (green build on all three OSes); json!/format! don't consume host_ip the way the comment suggests.

fmt + clippy (-D warnings) + tests green.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 3 out of 3 changed files in this pull request and generated 4 comments.

Comment on lines +123 to +125
if let Some(port) = cli.serve_port {
serve_manifest_api(port, cli.host, &manifest, json.clone())?;
}
Comment thread src/bin/ant-devnet/cli.rs Outdated
Comment on lines +84 to +85
#[arg(long)]
pub serve_port: Option<u16>,
Comment thread src/bin/ant-devnet/main.rs
Comment thread src/bin/ant-devnet/main.rs Outdated
Comment on lines +271 to +281
std::thread::spawn(move || {
use std::io::{Read, Write};
for stream in listener.incoming() {
let Ok(mut stream) = stream else { continue };
// Cap how long a slow/idle client can hold a handler thread — the
// listener binds 0.0.0.0, so an unbounded blocking read is a trivial
// resource-exhaustion vector.
let _ = stream.set_read_timeout(Some(std::time::Duration::from_secs(5)));
let manifest = manifest_json.clone();
let info = info_json.clone();
std::thread::spawn(move || {

@dirvine dirvine left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed current head 96ab80aaa2fbee966a00e83c08b30c36fda32677 with a multi-reviewer pass and local runtime checks.

Verdict: changes requested before merge. The latest commit fixes loopback/unspecified --host, GET-only handling, conflicting EVM flags, and idle-read timeout. Two functional issues remain:

  1. src/bin/ant-devnet/main.rs:260-267--serve-port bind failure is swallowed. spawn_manifest_server only warns and returns, then serve_manifest_api returns Ok(()) and the CLI reports Devnet running. I reproduced this with an occupied port on the current head: the process logged failed to bind ... Address already in use, then continued successfully. Please bind synchronously and propagate the error.

  2. src/bin/ant-devnet/cli.rs:84-85 / main.rs:237,261 — port 0 is accepted but advertised as :0. The OS actually selected port 49241 in my current-head run, while the log and /api/info construction retained port 0. Either reject zero or return/use listener.local_addr().

Also worth fixing in this pass:

  • --serve-port without --host binds 0.0.0.0 and publishes a LAN-looking API URL, but the served manifest still contains loopback bootstrap addresses. Require --host, or bind/report loopback when it is absent.
  • The five-second timeout bounds idle connection lifetime, but the server still creates an unbounded OS thread per connection. A small concurrency cap/inline handler would remove the remaining burst-exhaustion path.
  • --host now rejects loopback/unspecified addresses, but multicast and broadcast values remain accepted and are not usable bootstrap destinations.
  • Current ant-cli does not infer Sepolia from the manifest alone; users need --evm-network arbitrum-sepolia (or local to consume the manifest's custom RPC/contracts). This is not necessarily a blocker for the mobile flow, but should be documented unless the manifest schema gains an explicit network/chain ID.

Local verification on the current head:

  • cargo test --bin ant-devnet — 3 passed
  • cargo test default_config_has_no_advertise_ip --lib — 1 passed
  • strict cargo clippy --bin ant-devnet --all-features — passed
  • runtime occupied-port and port-zero cases reproduced as above

The reported Android paid upload/download result was not independently reproduced during this review.

… hardening

@dirvine review + Copilot follow-ups on the manifest API:
- Bind the manifest listener synchronously in serve_manifest_api and propagate
  the error, so a bind failure (e.g. port in use) fails the run instead of the
  devnet reporting 'running' without its API.
- Reject --serve-port 0 at the parser (range 1..) — it would otherwise bind an
  ephemeral port while /api/info advertises ':0'.
- --serve-port now requires --host (the API publishes a LAN URL; a loopback-only
  devnet would be misleading/unsafe).
- Handle manifest-API connections inline in the single server thread instead of
  spawning an unbounded thread per connection (kept the 5s read timeout).
- Also reject multicast/broadcast --host, not just loopback/unspecified.
- Propagate the bootstrap serialization error in /api/info instead of silently
  substituting null.

Adds serve_port_requires_host + serve_port_rejects_zero CLI tests.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Copilot AI review requested due to automatic review settings July 13, 2026 17:29
@Nic-dorman

Copy link
Copy Markdown
Author

Thanks @dirvine — addressed in 998493d.

Blockers:

  1. Swallowed bind failureserve_manifest_api now binds the listener synchronously and propagates the error via ?, so an occupied port fails the run instead of the devnet reporting running without its API.
  2. --serve-port 0 — rejected at the parser (value_parser = value_parser!(u16).range(1..)), so :0 can't be advertised. (ant-devnet --host … --serve-port 0error: invalid value '0' … 0 is not in 1..=65535.)

Also fixed this pass:

  • --serve-port without --host--serve-port now requires = "host" at the clap level, so it can't publish a LAN URL over a loopback-only devnet.
  • Unbounded thread-per-connection — the server now handles connections inline in its single thread (no per-connection thread), keeping the 5s read timeout. Payloads are tiny and a devnet serves a handful of devices, so serial handling bounds resource use with no burst-exhaustion path.
  • Multicast/broadcast --host — the early validation now also rejects is_multicast() / is_broadcast(), not just loopback/unspecified.
  • Bootstrap serialization/api/info now propagates the serde_json::to_value(&bootstrap) error instead of substituting null.

Not changed here (documentation / follow-up):

  • ant-cli can't infer Sepolia from the manifest alone — correct: the manifest's evm block carries rpc_url/token/vault but no network identifier, so consumers pass --evm-network arbitrum-sepolia (or local to use the manifest's custom RPC/contracts). This is a manifest-schema matter rather than an ant-devnet behavior; the cleanest fix is adding an explicit chain_id to the manifest evm block so clients can infer it. That touches the shared manifest schema (and its FFI consumers), so I'd propose it as a separate follow-up rather than fold it in here — happy to open that if you agree.

Local: cargo fmt --check, strict clippy -D warnings, and cargo test --bin ant-devnet (5 passed) all green; the occupied-port, port-0, serve-port-without-host, and multicast-host cases verified by hand.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 3 out of 3 changed files in this pull request and generated 2 comments.

Comment on lines +124 to +128
// 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 +247 to +256
// 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);

@dirvine dirvine left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Re-reviewed current head 998493dd8a13f842ec2b16d00ffa51fa31240916 against the previous reviewed head.

The previous blockers are addressed:

  • manifest-listener bind errors now propagate;
  • port zero and --serve-port without --host are rejected;
  • loopback/unspecified/multicast/broadcast hosts are rejected;
  • unbounded per-connection threads are removed;
  • bootstrap serialisation errors propagate.

Local/runtime verification:

  • cargo test --bin ant-devnet — 5 passed
  • cargo test default_config_has_no_advertise_ip --lib — passed
  • strict clippy and build — passed
  • occupied-port case now exits with the expected bind error
  • normal GET succeeds and invalid host classes are rejected

Recommendation: approve once the remaining platform CI tests pass. No remaining blocker was found for the stated opt-in trusted-LAN devnet scope. The single-read HTTP parser, process-scoped detached listener, intentional DevnetConfig field addition, and possible data-directory residue on a post-start error are non-blocking limitations here.

At review time, format, clippy, documentation, all three builds, no-logging tests and security audit are green. Ubuntu/macOS/Windows test jobs remain in progress.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants