Skip to main content

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}