ant_core/data/network.rs
1//! Network layer wrapping ant-node's P2P node.
2//!
3//! Provides peer discovery, message sending, and DHT operations
4//! for the client library.
5
6use crate::data::error::{Error, Result};
7use ant_protocol::transport::{
8 CoreNodeConfig, IPDiversityConfig, MultiAddr, NodeMode, P2PNode, PeerId, WitnessedCloseGroup,
9};
10use ant_protocol::MAX_WIRE_MESSAGE_SIZE;
11use serde::{Deserialize, Serialize};
12use std::net::SocketAddr;
13use std::sync::Arc;
14
15/// Mirror of saorsa-core's private `AUTO_REBOOTSTRAP_THRESHOLD`
16/// (dht_network_manager.rs): the routing-table size below which the DHT
17/// auto-re-bootstraps. saorsa-core PR #153 makes the real const public;
18/// once a release carries it, consume that instead of this mirror.
19pub const REBOOTSTRAP_THRESHOLD: usize = 3;
20
21/// Live network-participation snapshot.
22///
23/// One implementation of the write-readiness formula for every embedded-client
24/// consumer (antd, ant-gui, ant-ffi, ant-tui) — see [`Network::health`].
25#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
26pub struct NetworkHealth {
27 /// Best-effort write-path floor:
28 /// `max(routing_table_size, connected_peers) >= rebootstrap_threshold`.
29 pub write_ready: bool,
30 /// Identity-verified peer connections currently held by the node.
31 pub connected_peers: u32,
32 /// Entries in the DHT routing table.
33 pub routing_table_size: u32,
34 /// Routing-table size below which the DHT auto-re-bootstraps.
35 pub rebootstrap_threshold: u32,
36}
37
38impl NetworkHealth {
39 /// Build a snapshot from raw peer counts.
40 ///
41 /// `write_ready` is keyed on `max(routing_table_size, connected_peers)`:
42 /// in client mode the DHT routing table can sit below the re-bootstrap
43 /// threshold while plenty of live connections exist and stores succeed
44 /// (observed on a LAN devnet: rt=2, connected=10, paid upload fine), so
45 /// the routing table alone would under-report; the connected count alone
46 /// misses the inverse case (~1 reachable peer, rt=0, stores failing).
47 /// Neither signal guarantees a store will fully succeed (stores proceed
48 /// with as little as one reachable node), but when both are below the
49 /// threshold the node is known-degraded.
50 #[must_use]
51 pub fn from_counts(connected_peers: usize, routing_table_size: usize) -> Self {
52 Self {
53 write_ready: routing_table_size.max(connected_peers) >= REBOOTSTRAP_THRESHOLD,
54 connected_peers: connected_peers.try_into().unwrap_or(u32::MAX),
55 routing_table_size: routing_table_size.try_into().unwrap_or(u32::MAX),
56 rebootstrap_threshold: REBOOTSTRAP_THRESHOLD as u32,
57 }
58 }
59}
60
61/// Network abstraction for the Autonomi client.
62///
63/// Wraps a `P2PNode` providing high-level operations for
64/// peer discovery and message routing.
65pub struct Network {
66 node: Arc<P2PNode>,
67}
68
69impl Network {
70 /// Create a new network connection with the given bootstrap peers.
71 ///
72 /// `allow_loopback` controls the saorsa-transport `local` flag on the
73 /// underlying `CoreNodeConfig`. Set it to `true` only for devnet / local
74 /// testing. Public Autonomi network peers reject the QUIC handshake
75 /// variant produced when `local = true`, so production callers must pass
76 /// `false` (this is what `ant-cli` does by default — see
77 /// `ant-cli/src/main.rs::create_client_node_raw`, which builds a similar
78 /// `CoreNodeConfig` directly, with `ipv6` toggled by the `--ipv4-only`
79 /// flag).
80 ///
81 /// `ipv6` controls whether the node binds a dual-stack IPv6 socket
82 /// (`true`) or an IPv4-only socket (`false`). The default for library
83 /// callers should be `true` to match the CLI default; set it to `false`
84 /// only when running on hosts without a working IPv6 stack, to avoid
85 /// advertising unreachable v6 addresses to the DHT.
86 ///
87 /// # Errors
88 ///
89 /// Returns an error if the P2P node cannot be created or bootstrapping fails.
90 pub async fn new(
91 bootstrap_peers: &[SocketAddr],
92 allow_loopback: bool,
93 ipv6: bool,
94 ) -> Result<Self> {
95 let mut core_config = CoreNodeConfig::builder()
96 .port(0)
97 .ipv6(ipv6)
98 .local(allow_loopback)
99 .mode(NodeMode::Client)
100 .max_message_size(MAX_WIRE_MESSAGE_SIZE)
101 .build()
102 .map_err(|e| Error::Network(format!("Failed to create core config: {e}")))?;
103
104 // Clients never enforce IP-diversity limits: they don't host data and
105 // their routing table exists only to find peers, not to be defended
106 // against Sybil clustering. Strict per-IP / per-subnet caps would
107 // silently drop legitimate testnet peers that share an IP or /24.
108 core_config.diversity_config = Some(IPDiversityConfig::permissive());
109
110 core_config.bootstrap_peers = bootstrap_peers
111 .iter()
112 .map(|addr| MultiAddr::quic(*addr))
113 .collect();
114
115 let node = P2PNode::new(core_config)
116 .await
117 .map_err(|e| Error::Network(format!("Failed to create P2P node: {e}")))?;
118
119 node.start()
120 .await
121 .map_err(|e| Error::Network(format!("Failed to start P2P node: {e}")))?;
122
123 Ok(Self {
124 node: Arc::new(node),
125 })
126 }
127
128 /// Create a network from an existing P2P node.
129 #[must_use]
130 pub fn from_node(node: Arc<P2PNode>) -> Self {
131 Self { node }
132 }
133
134 /// Get a reference to the underlying P2P node.
135 #[must_use]
136 pub fn node(&self) -> &Arc<P2PNode> {
137 &self.node
138 }
139
140 /// Get the local peer ID.
141 #[must_use]
142 pub fn peer_id(&self) -> &PeerId {
143 self.node.peer_id()
144 }
145
146 /// Find the closest peers to a target address.
147 ///
148 /// Returns each peer paired with its known network addresses, enabling
149 /// callers to pass addresses to `send_and_await_chunk_response` for
150 /// faster connection establishment.
151 ///
152 /// # Errors
153 ///
154 /// Returns an error if the DHT lookup fails.
155 pub async fn find_closest_peers(
156 &self,
157 target: &[u8; 32],
158 count: usize,
159 ) -> Result<Vec<(PeerId, Vec<MultiAddr>)>> {
160 let local_peer_id = self.node.peer_id();
161
162 // Request one extra to account for filtering out our own peer ID
163 let closest_nodes = self
164 .node
165 .dht()
166 .find_closest_nodes(target, count + 1)
167 .await
168 .map_err(|e| Error::Network(format!("DHT closest-nodes lookup failed: {e}")))?;
169
170 Ok(closest_nodes
171 .into_iter()
172 .filter(|n| n.peer_id != *local_peer_id)
173 .take(count)
174 .map(|n| {
175 let addrs = n.addresses_by_priority();
176 (n.peer_id, addrs)
177 })
178 .collect())
179 }
180
181 /// Find a witnessed close-group transcript for a target address.
182 ///
183 /// The underlying DHT method returns the initial client K, each responder's
184 /// self-inclusive closest-K node view, and enough trusted node records for
185 /// callers to apply their own quorum and fallback policy.
186 ///
187 /// # Errors
188 ///
189 /// Returns an error if the DHT lookup itself fails. The returned transcript
190 /// may still be inconclusive; callers should evaluate it before payment.
191 pub async fn find_witnessed_close_group(
192 &self,
193 target: &[u8; 32],
194 count: usize,
195 ) -> Result<WitnessedCloseGroup> {
196 self.find_witnessed_close_group_with_view_count(target, count, count)
197 .await
198 }
199
200 /// Find a witnessed close-group transcript with wider responder views.
201 ///
202 /// `count` is the initial responder set size. `view_count` is the number
203 /// of closest nodes each responder view may contribute.
204 ///
205 /// # Errors
206 ///
207 /// Returns an error if the DHT lookup itself fails. The returned transcript
208 /// may still be inconclusive; callers should evaluate it before payment.
209 pub async fn find_witnessed_close_group_with_view_count(
210 &self,
211 target: &[u8; 32],
212 count: usize,
213 view_count: usize,
214 ) -> Result<WitnessedCloseGroup> {
215 self.node
216 .dht()
217 .find_witnessed_close_group_with_view_count(target, count, view_count)
218 .await
219 .map_err(|e| Error::Network(format!("DHT witnessed close-group lookup failed: {e}")))
220 }
221
222 /// Get all currently connected peers.
223 pub async fn connected_peers(&self) -> Vec<PeerId> {
224 self.node.connected_peers().await
225 }
226
227 /// Compute the live network-participation snapshot.
228 ///
229 /// Both node reads are in-memory, so this is cheap enough to call per
230 /// request — no caching or background worker needed. See
231 /// [`NetworkHealth::from_counts`] for the `write_ready` semantics.
232 ///
233 /// Do not substitute `is_bootstrapped()` (sticky true — it stays true
234 /// through a total outage) or saorsa's `health_check()` (an
235 /// over-connection guard, despite the name) for this.
236 pub async fn health(&self) -> NetworkHealth {
237 let connected_peers = self.node.peer_count().await;
238 let routing_table_size = self.node.dht_manager().get_routing_table_size().await;
239 NetworkHealth::from_counts(connected_peers, routing_table_size)
240 }
241}
242
243#[cfg(test)]
244mod tests {
245 use super::*;
246
247 #[test]
248 fn write_ready_false_with_no_peers() {
249 let h = NetworkHealth::from_counts(0, 0);
250 assert!(!h.write_ready);
251 assert_eq!(h.connected_peers, 0);
252 assert_eq!(h.routing_table_size, 0);
253 assert_eq!(h.rebootstrap_threshold, REBOOTSTRAP_THRESHOLD as u32);
254 }
255
256 #[test]
257 fn write_ready_false_below_threshold_on_both_signals() {
258 // The reporter's incident shape (ant-sdk#232): ~1 reachable peer,
259 // empty routing table, stores failing.
260 assert!(!NetworkHealth::from_counts(1, 0).write_ready);
261 assert!(!NetworkHealth::from_counts(2, 2).write_ready);
262 }
263
264 #[test]
265 fn write_ready_true_via_connections_despite_low_routing_table() {
266 // Client-mode under-reporting observed live on a LAN devnet:
267 // rt pinned at 2 with 10 verified connections and stores succeeding.
268 // The max() in the formula exists for exactly this state.
269 assert!(NetworkHealth::from_counts(10, 2).write_ready);
270 }
271
272 #[test]
273 fn write_ready_true_via_routing_table_alone() {
274 assert!(NetworkHealth::from_counts(0, REBOOTSTRAP_THRESHOLD).write_ready);
275 }
276
277 #[test]
278 fn write_ready_true_at_exact_threshold_on_connections() {
279 assert!(NetworkHealth::from_counts(REBOOTSTRAP_THRESHOLD, 0).write_ready);
280 }
281
282 #[test]
283 fn counts_saturate_at_u32_max() {
284 let h = NetworkHealth::from_counts(usize::MAX, usize::MAX);
285 assert_eq!(h.connected_peers, u32::MAX);
286 assert_eq!(h.routing_table_size, u32::MAX);
287 assert!(h.write_ready);
288 }
289}