diff --git a/.claude/skills/e2e-node-management-test/SKILL.md b/.claude/skills/e2e-node-management-test/SKILL.md
index 60d1f9e..f75f8bc 100644
--- a/.claude/skills/e2e-node-management-test/SKILL.md
+++ b/.claude/skills/e2e-node-management-test/SKILL.md
@@ -83,6 +83,27 @@ Verify: `running` is `true`, `api_base` contains a URL like `http://127.0.0.1:
` | Pin the HTTP port. `0` means OS-assigned (the default behavior). |
+| `--listen-addr ` | Bind address. Defaults to `127.0.0.1`. |
+
+Pin the port and bind on all interfaces — useful when the daemon runs inside a container and the API needs to be reachable through a port mapping:
+
+```
+$ ant node daemon start --listen-addr 0.0.0.0 --port 8765
+```
+
+```
+$ docker run -d -p 8765:8765 my/ant-image \
+ ant node daemon start --listen-addr 0.0.0.0 --port 8765
+```
+
+> **Warning:** the daemon has no authentication. Binding to a non-loopback address exposes node management — start, stop, reset, registry mutation — to anyone who can reach the port. Only do this when the network path is controlled (e.g. a container with an explicit port mapping or a trusted private network).
+
#### `ant node daemon stop`
Shut down the running daemon. Sends SIGTERM and waits for exit.
diff --git a/ant-cli/src/commands/node/daemon.rs b/ant-cli/src/commands/node/daemon.rs
index d69cca6..a5f981e 100644
--- a/ant-cli/src/commands/node/daemon.rs
+++ b/ant-cli/src/commands/node/daemon.rs
@@ -1,13 +1,33 @@
-use clap::Subcommand;
+use std::net::IpAddr;
+
+use clap::{Args, Subcommand};
use colored::Colorize;
use ant_core::node::daemon::client;
use ant_core::node::types::DaemonConfig;
+/// Bind overrides shared by `daemon start` and `daemon run`.
+#[derive(Args, Clone, Debug, Default)]
+pub struct BindArgs {
+ /// Pin the daemon's HTTP port. Unset (default) lets the OS assign one;
+ /// `0` is also accepted as an explicit OS-assigned request.
+ #[arg(long, value_name = "PORT")]
+ pub port: Option,
+
+ /// Address the daemon binds to. Defaults to `127.0.0.1`.
+ ///
+ /// Binding to a non-loopback address (e.g. `0.0.0.0`) exposes node
+ /// management to anyone who can reach the port. The daemon has no
+ /// authentication — only do this when the network path is controlled
+ /// (e.g. inside a container with an explicit port mapping).
+ #[arg(long, value_name = "IP")]
+ pub listen_addr: Option,
+}
+
#[derive(Subcommand)]
pub enum DaemonCommand {
/// Launch the daemon as a detached background process
- Start,
+ Start(BindArgs),
/// Shut down the running daemon
Stop,
/// Show whether the daemon is running and summary stats
@@ -16,7 +36,19 @@ pub enum DaemonCommand {
Info,
/// Run the daemon in the foreground (used internally)
#[command(hide = true)]
- Run,
+ Run(BindArgs),
+}
+
+/// Overlay user-provided bind overrides onto `DaemonConfig::default()`.
+fn apply_bind_args(args: &BindArgs) -> DaemonConfig {
+ let mut config = DaemonConfig::default();
+ if let Some(port) = args.port {
+ config.port = Some(port);
+ }
+ if let Some(addr) = args.listen_addr {
+ config.listen_addr = addr;
+ }
+ config
}
fn format_uptime(secs: u64) -> String {
@@ -50,10 +82,13 @@ fn resolve_port(config: &DaemonConfig, status_port: Option) -> Option
impl DaemonCommand {
pub async fn execute(self, json_output: bool) -> anyhow::Result<()> {
- let config = DaemonConfig::default();
+ let config = match &self {
+ DaemonCommand::Start(args) | DaemonCommand::Run(args) => apply_bind_args(args),
+ _ => DaemonConfig::default(),
+ };
match self {
- DaemonCommand::Start => {
+ DaemonCommand::Start(args) => {
let result = client::start(&config).await?;
if json_output {
println!("{}", serde_json::to_string(&result)?);
@@ -67,6 +102,13 @@ impl DaemonCommand {
if let Some(p) = port {
println!(" {} http://127.0.0.1:{p}/console", "Console".dimmed());
}
+ if args.port.is_some() || args.listen_addr.is_some() {
+ println!(
+ " {} the running daemon was started with different settings; \
+ stop it first to apply --port / --listen-addr",
+ "Note:".yellow()
+ );
+ }
} else {
let pid = result.pid.to_string().bold();
let port = resolve_port(&config, result.port);
@@ -179,7 +221,7 @@ impl DaemonCommand {
}
}
}
- DaemonCommand::Run => {
+ DaemonCommand::Run(_) => {
client::run(config).await?;
}
}
diff --git a/ant-core/src/data/client/chunk.rs b/ant-core/src/data/client/chunk.rs
index dab11a8..8bbf503 100644
--- a/ant-core/src/data/client/chunk.rs
+++ b/ant-core/src/data/client/chunk.rs
@@ -3,9 +3,11 @@
//! Chunks are immutable, content-addressed data blocks where the address
//! is the BLAKE3 hash of the content.
+use crate::data::client::batch::{finalize_batch_payment, PreparedChunk};
use crate::data::client::peer_cache::record_peer_outcome;
use crate::data::client::Client;
use crate::data::error::{Error, Result};
+use ant_protocol::evm::{QuoteHash, TxHash};
use ant_protocol::transport::{MultiAddr, PeerId};
use ant_protocol::{
compute_address, detect_proof_type, send_and_await_chunk_response, ChunkGetRequest,
@@ -14,6 +16,7 @@ use ant_protocol::{
};
use bytes::Bytes;
use futures::stream::{FuturesUnordered, StreamExt};
+use std::collections::HashMap;
use std::future::Future;
use std::time::{Duration, Instant};
use tracing::{debug, warn};
@@ -361,6 +364,43 @@ impl Client {
pub async fn chunk_exists(&self, address: &XorName) -> Result {
self.chunk_get(address).await.map(|opt| opt.is_some())
}
+
+ /// Finalize a single-chunk publish after an external signer has paid.
+ ///
+ /// Single-chunk analogue of [`Client::finalize_upload`]. Takes a
+ /// [`PreparedChunk`] (from [`Client::prepare_chunk_payment`]) and a
+ /// `quote_hash -> tx_hash` map containing receipts for every non-zero
+ /// quote in the chunk's payment. Builds the `PaymentProof` and stores
+ /// the chunk on `CLOSE_GROUP_MAJORITY` peers, returning its address.
+ ///
+ /// Wave-batch payment shape only. Single-chunk publishes don't need
+ /// Merkle batching: one chunk's worth of quotes is well below the
+ /// wave-batch threshold.
+ ///
+ /// # Errors
+ ///
+ /// Returns an error if the proof construction fails (e.g. missing
+ /// `tx_hash` for a non-zero quote) or if fewer than
+ /// `CLOSE_GROUP_MAJORITY` peers accept the chunk.
+ pub async fn finalize_chunk(
+ &self,
+ prepared: PreparedChunk,
+ tx_hash_map: &HashMap,
+ ) -> Result {
+ let mut paid = finalize_batch_payment(vec![prepared], tx_hash_map)?;
+ // finalize_batch_payment returns one PaidChunk per PreparedChunk
+ // input; we passed exactly one. If that invariant is ever violated
+ // it's an upstream bug — fail loudly rather than silently address-0.
+ let chunk = paid.pop().ok_or_else(|| {
+ Error::Payment(
+ "finalize_batch_payment returned no paid chunks for a single \
+ prepared chunk — internal invariant violated"
+ .into(),
+ )
+ })?;
+ self.chunk_put_to_close_group(chunk.content, chunk.proof_bytes, &chunk.quoted_peers)
+ .await
+ }
}
#[cfg(test)]
diff --git a/ant-core/src/node/daemon/client.rs b/ant-core/src/node/daemon/client.rs
index d70d4de..2331d57 100644
--- a/ant-core/src/node/daemon/client.rs
+++ b/ant-core/src/node/daemon/client.rs
@@ -120,7 +120,9 @@ pub async fn start(config: &DaemonConfig) -> Result {
.to_str()
.ok_or_else(|| Error::ProcessSpawn("Executable path is not valid UTF-8".to_string()))?;
- let pid = detach::spawn_detached(exe_str, &["node", "daemon", "run"])?;
+ let args = daemon_run_args(config);
+ let arg_refs: Vec<&str> = args.iter().map(String::as_str).collect();
+ let pid = detach::spawn_detached(exe_str, &arg_refs)?;
// Wait briefly for the daemon to write its port file
let mut port = None;
@@ -349,6 +351,25 @@ fn validate_daemon_process(pid: u32) -> bool {
}
}
+/// Build the arg list passed to the detached `ant node daemon run` child.
+///
+/// Overrides are forwarded explicitly so the child binds to the same address
+/// and port the caller asked for. Unset fields fall through to the child's
+/// own defaults (loopback + OS-assigned port).
+fn daemon_run_args(config: &DaemonConfig) -> Vec {
+ let defaults = DaemonConfig::default();
+ let mut args = vec!["node".to_string(), "daemon".to_string(), "run".to_string()];
+ if let Some(port) = config.port {
+ args.push("--port".to_string());
+ args.push(port.to_string());
+ }
+ if config.listen_addr != defaults.listen_addr {
+ args.push("--listen-addr".to_string());
+ args.push(config.listen_addr.to_string());
+ }
+ args
+}
+
fn read_port_file(path: &Path) -> Option {
std::fs::read_to_string(path)
.ok()
@@ -432,3 +453,74 @@ fn is_process_alive(pid: u32) -> bool {
success != 0 && exit_code == STILL_ACTIVE as u32
}
}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use std::net::Ipv4Addr;
+
+ #[test]
+ fn run_args_default_config_has_no_overrides() {
+ let config = DaemonConfig::default();
+ let args = daemon_run_args(&config);
+ assert_eq!(args, vec!["node", "daemon", "run"]);
+ }
+
+ #[test]
+ fn run_args_forward_explicit_port() {
+ let config = DaemonConfig {
+ port: Some(8765),
+ ..DaemonConfig::default()
+ };
+ let args = daemon_run_args(&config);
+ assert_eq!(args, vec!["node", "daemon", "run", "--port", "8765"]);
+ }
+
+ #[test]
+ fn run_args_forward_explicit_listen_addr() {
+ let config = DaemonConfig {
+ listen_addr: std::net::IpAddr::V4(Ipv4Addr::UNSPECIFIED),
+ ..DaemonConfig::default()
+ };
+ let args = daemon_run_args(&config);
+ assert_eq!(
+ args,
+ vec!["node", "daemon", "run", "--listen-addr", "0.0.0.0"]
+ );
+ }
+
+ #[test]
+ fn run_args_forward_both_overrides() {
+ let config = DaemonConfig {
+ port: Some(8765),
+ listen_addr: std::net::IpAddr::V4(Ipv4Addr::UNSPECIFIED),
+ ..DaemonConfig::default()
+ };
+ let args = daemon_run_args(&config);
+ assert_eq!(
+ args,
+ vec![
+ "node",
+ "daemon",
+ "run",
+ "--port",
+ "8765",
+ "--listen-addr",
+ "0.0.0.0",
+ ]
+ );
+ }
+
+ #[test]
+ fn run_args_forward_explicit_zero_port() {
+ // Explicit `--port 0` is preserved so the user's intent (OS-assigned)
+ // round-trips through the spawn, even though the child's default would
+ // produce the same bind behavior.
+ let config = DaemonConfig {
+ port: Some(0),
+ ..DaemonConfig::default()
+ };
+ let args = daemon_run_args(&config);
+ assert_eq!(args, vec!["node", "daemon", "run", "--port", "0"]);
+ }
+}
diff --git a/ant-core/tests/daemon_integration.rs b/ant-core/tests/daemon_integration.rs
index 0fee3d8..03255f6 100644
--- a/ant-core/tests/daemon_integration.rs
+++ b/ant-core/tests/daemon_integration.rs
@@ -110,6 +110,37 @@ async fn port_and_pid_files_written() {
);
}
+#[tokio::test]
+async fn server_binds_to_pinned_port() {
+ // Reserve a free port by binding to 0, then drop the listener so the
+ // server can claim it. A tiny TOCTOU race is acceptable in tests.
+ let probe = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
+ let pinned_port = probe.local_addr().unwrap().port();
+ drop(probe);
+
+ let dir = tempfile::tempdir().unwrap();
+ let config = DaemonConfig {
+ port: Some(pinned_port),
+ ..test_config(&dir)
+ };
+ let port_file = config.port_file_path.clone();
+ let registry = NodeRegistry::load(&config.registry_path).unwrap();
+ let shutdown = tokio_util::sync::CancellationToken::new();
+
+ let addr = server::start(config, registry, shutdown.clone())
+ .await
+ .unwrap();
+
+ assert_eq!(addr.port(), pinned_port, "server bound to the wrong port");
+
+ let port_contents = std::fs::read_to_string(&port_file).unwrap();
+ let written_port: u16 = port_contents.trim().parse().unwrap();
+ assert_eq!(written_port, pinned_port);
+
+ shutdown.cancel();
+ tokio::time::sleep(std::time::Duration::from_millis(200)).await;
+}
+
#[tokio::test]
async fn console_returns_html() {
let dir = tempfile::tempdir().unwrap();