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
6#[cfg(feature = "native")]
7use crate::data::error::Error;
8use crate::data::error::Result;
9use ant_protocol::transport::{DHTNode, MultiAddr, PeerId, WitnessedCloseGroup};
10#[cfg(feature = "native")]
11use ant_protocol::{
12    transport::{CoreNodeConfig, IPDiversityConfig, NodeMode, P2PNode},
13    MAX_WIRE_MESSAGE_SIZE,
14};
15use serde::{Deserialize, Serialize};
16#[cfg(feature = "native")]
17use std::net::SocketAddr;
18#[cfg(feature = "native")]
19use std::sync::Arc;
20
21/// Mirror of saorsa-core's private `AUTO_REBOOTSTRAP_THRESHOLD`
22/// (dht_network_manager.rs): the routing-table size below which the DHT
23/// auto-re-bootstraps. saorsa-core PR #153 makes the real const public;
24/// once a release carries it, consume that instead of this mirror.
25pub const REBOOTSTRAP_THRESHOLD: usize = 3;
26
27/// Live network-participation snapshot.
28///
29/// One implementation of the write-readiness formula for every embedded-client
30/// consumer (antd, ant-gui, ant-ffi, ant-tui) — see [`Network::health`].
31#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
32pub struct NetworkHealth {
33    /// Best-effort write-path floor:
34    /// `max(routing_table_size, connected_peers) >= rebootstrap_threshold`.
35    pub write_ready: bool,
36    /// Identity-verified peer connections currently held by the node.
37    pub connected_peers: u32,
38    /// Entries in the DHT routing table.
39    pub routing_table_size: u32,
40    /// Routing-table size below which the DHT auto-re-bootstraps.
41    pub rebootstrap_threshold: u32,
42}
43
44impl NetworkHealth {
45    /// Build a snapshot from raw peer counts.
46    ///
47    /// `write_ready` is keyed on `max(routing_table_size, connected_peers)`:
48    /// in client mode the DHT routing table can sit below the re-bootstrap
49    /// threshold while plenty of live connections exist and stores succeed
50    /// (observed on a LAN devnet: rt=2, connected=10, paid upload fine), so
51    /// the routing table alone would under-report; the connected count alone
52    /// misses the inverse case (~1 reachable peer, rt=0, stores failing).
53    /// Neither signal guarantees a store will fully succeed (stores proceed
54    /// with as little as one reachable node), but when both are below the
55    /// threshold the node is known-degraded.
56    #[must_use]
57    pub fn from_counts(connected_peers: usize, routing_table_size: usize) -> Self {
58        Self {
59            write_ready: routing_table_size.max(connected_peers) >= REBOOTSTRAP_THRESHOLD,
60            connected_peers: connected_peers.try_into().unwrap_or(u32::MAX),
61            routing_table_size: routing_table_size.try_into().unwrap_or(u32::MAX),
62            rebootstrap_threshold: REBOOTSTRAP_THRESHOLD as u32,
63        }
64    }
65}
66
67/// Read-only DHT context captured for one diagnostics-enabled closest-peer
68/// selection. None of these fields influence selection or dialing.
69#[cfg(feature = "native")]
70pub(crate) struct ClosestPeerDiagnostics {
71    pub peer_id: PeerId,
72    pub addresses: Vec<MultiAddr>,
73    pub address_types: Vec<String>,
74    /// This process's monotonic age since its last successful DHT interaction.
75    pub local_last_seen_age_ms: Option<u64>,
76    /// Publisher-clock-derived age of the latest address-set publication.
77    pub publisher_address_set_age_ms: Option<u64>,
78    pub publisher_address_set_unix_ns: Option<u64>,
79}
80
81/// Network abstraction for the Autonomi client.
82///
83/// Wraps a `P2PNode` providing high-level operations for
84/// peer discovery and message routing.
85#[derive(Clone)]
86pub struct Network {
87    #[cfg(feature = "native")]
88    node: Arc<P2PNode>,
89    #[cfg(not(feature = "native"))]
90    backend: std::rc::Rc<dyn BrowserNetwork>,
91}
92
93/// Peer identities and the addresses that can reach them.
94pub type PeerAddresses = Vec<(PeerId, Vec<MultiAddr>)>;
95
96/// Bounded, latest-value hints for an immutable read in progress. Hints only
97/// start authenticated GETs; they never establish closeness, absence or payment
98/// authority. The receiver may cancel discovery after verifying content.
99#[derive(Clone)]
100pub struct ReadProgress {
101    target: [u8; 32],
102    local: PeerId,
103    sender: tokio::sync::watch::Sender<PeerAddresses>,
104}
105
106impl ReadProgress {
107    pub(crate) fn new(
108        target: [u8; 32],
109        local: PeerId,
110        sender: tokio::sync::watch::Sender<PeerAddresses>,
111    ) -> Self {
112        Self {
113            target,
114            local,
115            sender,
116        }
117    }
118
119    /// Offer authenticated discovery hints, retaining only the nearest bounded
120    /// set. Sending never waits for a slow read consumer.
121    pub fn offer(&self, peers: PeerAddresses) {
122        if self.sender.is_closed() {
123            return;
124        }
125        self.sender.send_modify(|current| {
126            for peer in peers {
127                if peer.0 == self.local || peer.1.is_empty() {
128                    continue;
129                }
130                if let Some(existing) = current.iter_mut().find(|entry| entry.0 == peer.0) {
131                    *existing = peer;
132                } else {
133                    current.push(peer);
134                }
135            }
136            current.sort_by_key(|peer| {
137                ant_protocol::transport::xor_distance(peer.0.as_bytes(), &self.target)
138            });
139            current.truncate(crate::client_engine::read::MAX_GET_FALLBACK_PEERS);
140        });
141    }
142}
143
144/// Browser transport boundary for the shared client. Implementations perform
145/// authenticated RPC and discovery; client policy stays in `Client`.
146#[cfg(not(feature = "native"))]
147pub trait BrowserNetwork {
148    /// Local identity used when excluding the client from remote candidates.
149    fn peer_id(&self) -> &PeerId;
150    /// Closest authenticated peers, ordered by XOR distance.
151    fn find_closest_peers<'a>(
152        &'a self,
153        target: &'a [u8; 32],
154        count: usize,
155    ) -> futures::future::LocalBoxFuture<'a, Result<PeerAddresses>>;
156    /// Read-only discovery may report verified candidates before completing.
157    /// Existing adapters remain compatible and supply their final result only.
158    fn find_read_peers<'a>(
159        &'a self,
160        target: &'a [u8; 32],
161        count: usize,
162        _progress: ReadProgress,
163    ) -> futures::future::LocalBoxFuture<'a, Result<PeerAddresses>> {
164        self.find_closest_peers(target, count)
165    }
166    /// Authenticated responder views for witnessed quote admission.
167    fn find_witnessed_close_group<'a>(
168        &'a self,
169        target: &'a [u8; 32],
170        count: usize,
171        view_count: usize,
172    ) -> futures::future::LocalBoxFuture<'a, Result<WitnessedCloseGroup>>;
173    /// Known records used as fallback candidates after a lookup failure.
174    fn known_peers(&self) -> Vec<DHTNode>;
175    /// Authenticated live peers eligible for an opportunistic immutable read.
176    /// Adapters without connection telemetry retain discovery-first behavior.
177    fn connected_read_peers(&self) -> Vec<PeerId> {
178        Vec::new()
179    }
180    /// Execute one authenticated request, preserving its request identifier.
181    fn request<'a>(
182        &'a self,
183        peer: &'a PeerId,
184        addrs: &'a [MultiAddr],
185        request: ant_protocol::ChunkMessage,
186        timeout: std::time::Duration,
187    ) -> futures::future::LocalBoxFuture<'a, Result<ant_protocol::ChunkMessage>>;
188}
189
190impl Network {
191    /// Create a new network connection with the given bootstrap peers.
192    ///
193    /// `allow_loopback` controls the saorsa-transport `local` flag on the
194    /// underlying `CoreNodeConfig`. Set it to `true` only for devnet / local
195    /// testing. Public Autonomi network peers reject the QUIC handshake
196    /// variant produced when `local = true`, so production callers must pass
197    /// `false` (this is what `ant-cli` does by default — see
198    /// `ant-cli/src/main.rs::create_client_node_raw`, which builds a similar
199    /// `CoreNodeConfig` directly, with `ipv6` toggled by the `--ipv4-only`
200    /// flag).
201    ///
202    /// `ipv6` controls whether the node binds a dual-stack IPv6 socket
203    /// (`true`) or an IPv4-only socket (`false`). The default for library
204    /// callers should be `true` to match the CLI default; set it to `false`
205    /// only when running on hosts without a working IPv6 stack, to avoid
206    /// advertising unreachable v6 addresses to the DHT.
207    ///
208    /// # Errors
209    ///
210    /// Returns an error if the P2P node cannot be created or bootstrapping fails.
211    #[cfg(feature = "native")]
212    pub async fn new(
213        bootstrap_peers: &[SocketAddr],
214        allow_loopback: bool,
215        ipv6: bool,
216    ) -> Result<Self> {
217        let seeds: Vec<_> = bootstrap_peers
218            .iter()
219            .copied()
220            .map(MultiAddr::quic)
221            .collect();
222        Self::new_multiaddrs(&seeds, allow_loopback, ipv6).await
223    }
224
225    /// Connect using QUIC multiaddresses, preserving optional peer identity pins.
226    #[cfg(feature = "native")]
227    pub async fn new_multiaddrs(
228        bootstrap_peers: &[MultiAddr],
229        allow_loopback: bool,
230        ipv6: bool,
231    ) -> Result<Self> {
232        let seeds = bootstrap_peers
233            .iter()
234            .map(|addr| crate::network_defaults::parse_quic_seed(&addr.to_string()))
235            .collect::<std::result::Result<Vec<_>, _>>()
236            .map_err(|e| Error::Network(e.to_string()))?;
237        let mut core_config = CoreNodeConfig::builder()
238            .port(0)
239            .ipv6(ipv6)
240            .local(allow_loopback)
241            .mode(NodeMode::Client)
242            .max_message_size(MAX_WIRE_MESSAGE_SIZE)
243            .build()
244            .map_err(|e| Error::Network(format!("Failed to create core config: {e}")))?;
245
246        // Clients never enforce IP-diversity limits: they don't host data and
247        // their routing table exists only to find peers, not to be defended
248        // against Sybil clustering. Strict per-IP / per-subnet caps would
249        // silently drop legitimate testnet peers that share an IP or /24.
250        core_config.diversity_config = Some(IPDiversityConfig::permissive());
251
252        core_config.bootstrap_peers = seeds;
253
254        let node = P2PNode::new(core_config)
255            .await
256            .map_err(|e| Error::Network(format!("Failed to create P2P node: {e}")))?;
257
258        node.start()
259            .await
260            .map_err(|e| Error::Network(format!("Failed to start P2P node: {e}")))?;
261
262        Ok(Self {
263            node: Arc::new(node),
264        })
265    }
266
267    /// Create a network from an existing P2P node.
268    #[must_use]
269    #[cfg(feature = "native")]
270    pub fn from_node(node: Arc<P2PNode>) -> Self {
271        Self { node }
272    }
273
274    /// Get a reference to the underlying P2P node.
275    #[must_use]
276    #[cfg(feature = "native")]
277    pub fn node(&self) -> &Arc<P2PNode> {
278        &self.node
279    }
280
281    /// Get the local peer ID.
282    #[must_use]
283    #[cfg(feature = "native")]
284    pub fn peer_id(&self) -> &PeerId {
285        self.node.peer_id()
286    }
287
288    /// Find the closest peers to a target address.
289    ///
290    /// Returns each peer paired with its known network addresses, enabling
291    /// callers to pass addresses to `send_and_await_chunk_response` for
292    /// faster connection establishment.
293    ///
294    /// # Errors
295    ///
296    /// Returns an error if the DHT lookup fails.
297    #[cfg(feature = "native")]
298    pub async fn find_closest_peers(
299        &self,
300        target: &[u8; 32],
301        count: usize,
302    ) -> Result<Vec<(PeerId, Vec<MultiAddr>)>> {
303        let local_peer_id = self.node.peer_id();
304
305        // Request one extra to account for filtering out our own peer ID
306        let closest_nodes = self
307            .node
308            .dht()
309            .find_closest_nodes(target, count + 1)
310            .await
311            .map_err(|e| Error::Network(format!("DHT closest-nodes lookup failed: {e}")))?;
312
313        Ok(closest_nodes
314            .into_iter()
315            .filter(|n| n.peer_id != *local_peer_id)
316            .take(count)
317            .map(|n| {
318                let addrs = n.addresses_by_priority();
319                (n.peer_id, addrs)
320            })
321            .collect())
322    }
323
324    /// Find the same peers, in the same order, while capturing read-only DHT
325    /// context for the explicitly enabled download diagnostics sidecar.
326    #[cfg(feature = "native")]
327    pub(crate) async fn find_closest_peers_with_diagnostics(
328        &self,
329        target: &[u8; 32],
330        count: usize,
331    ) -> Result<Vec<ClosestPeerDiagnostics>> {
332        let local_peer_id = self.node.peer_id();
333        let closest_nodes = self
334            .node
335            .dht()
336            .find_closest_nodes(target, count + 1)
337            .await
338            .map_err(|e| Error::Network(format!("DHT closest-nodes lookup failed: {e}")))?;
339        let now_ns = std::time::SystemTime::now()
340            .duration_since(std::time::UNIX_EPOCH)
341            .unwrap_or_default()
342            .as_nanos();
343        let now_ns = u64::try_from(now_ns).unwrap_or(u64::MAX);
344
345        let mut result = Vec::with_capacity(count);
346        for node in closest_nodes
347            .into_iter()
348            .filter(|node| node.peer_id != *local_peer_id)
349            .take(count)
350        {
351            let publisher_address_set_unix_ns = node.publisher_address_set_unix_ns();
352            // A publisher clock may be ahead of ours. In that case, retain the
353            // raw timestamp but do not misreport its age as zero.
354            let publisher_address_set_age_ms = publisher_address_set_unix_ns
355                .and_then(|published| now_ns.checked_sub(published))
356                .map(|age_ns| age_ns / 1_000_000);
357            let local_last_seen_age_ms = self
358                .node
359                .peer_last_seen_elapsed(&node.peer_id)
360                .await
361                .map(|age| u64::try_from(age.as_millis()).unwrap_or(u64::MAX));
362            let address_context = node.address_and_type_labels_by_priority();
363            let (addresses, address_types) = address_context
364                .into_iter()
365                .map(|(address, label)| (address, label.to_string()))
366                .unzip();
367            result.push(ClosestPeerDiagnostics {
368                peer_id: node.peer_id,
369                addresses,
370                address_types,
371                local_last_seen_age_ms,
372                publisher_address_set_age_ms,
373                publisher_address_set_unix_ns,
374            });
375        }
376        Ok(result)
377    }
378
379    /// Find a witnessed close-group transcript for a target address.
380    ///
381    /// The underlying DHT method returns the initial client K, each responder's
382    /// self-inclusive closest-K node view, and enough trusted node records for
383    /// callers to apply their own quorum and fallback policy.
384    ///
385    /// # Errors
386    ///
387    /// Returns an error if the DHT lookup itself fails. The returned transcript
388    /// may still be inconclusive; callers should evaluate it before payment.
389    pub async fn find_witnessed_close_group(
390        &self,
391        target: &[u8; 32],
392        count: usize,
393    ) -> Result<WitnessedCloseGroup> {
394        self.find_witnessed_close_group_with_view_count(target, count, count)
395            .await
396    }
397
398    /// Find a witnessed close-group transcript with wider responder views.
399    ///
400    /// `count` is the initial responder set size. `view_count` is the number
401    /// of closest nodes each responder view may contribute.
402    ///
403    /// # Errors
404    ///
405    /// Returns an error if the DHT lookup itself fails. The returned transcript
406    /// may still be inconclusive; callers should evaluate it before payment.
407    #[cfg(feature = "native")]
408    pub async fn find_witnessed_close_group_with_view_count(
409        &self,
410        target: &[u8; 32],
411        count: usize,
412        view_count: usize,
413    ) -> Result<WitnessedCloseGroup> {
414        self.node
415            .dht()
416            .find_witnessed_close_group_with_view_count(target, count, view_count)
417            .await
418            .map_err(|e| Error::Network(format!("DHT witnessed close-group lookup failed: {e}")))
419    }
420
421    /// Get all currently connected peers.
422    #[cfg(feature = "native")]
423    pub async fn connected_peers(&self) -> Vec<PeerId> {
424        self.node.connected_peers().await
425    }
426
427    /// Compute the live network-participation snapshot.
428    ///
429    /// Both node reads are in-memory, so this is cheap enough to call per
430    /// request — no caching or background worker needed. See
431    /// [`NetworkHealth::from_counts`] for the `write_ready` semantics.
432    ///
433    /// Do not substitute `is_bootstrapped()` (sticky true — it stays true
434    /// through a total outage) or saorsa's `health_check()` (an
435    /// over-connection guard, despite the name) for this.
436    #[cfg(feature = "native")]
437    pub async fn health(&self) -> NetworkHealth {
438        let connected_peers = self.node.peer_count().await;
439        let routing_table_size = self.node.dht_manager().get_routing_table_size().await;
440        NetworkHealth::from_counts(connected_peers, routing_table_size)
441    }
442}
443
444impl Network {
445    /// Construct the same client network facade with a browser transport.
446    #[cfg(not(feature = "native"))]
447    pub fn from_browser(backend: std::rc::Rc<dyn BrowserNetwork>) -> Self {
448        Self { backend }
449    }
450
451    /// Local client identity.
452    #[cfg(not(feature = "native"))]
453    pub fn peer_id(&self) -> &PeerId {
454        self.backend.peer_id()
455    }
456
457    /// Find closest authenticated peers through the browser adapter.
458    #[cfg(not(feature = "native"))]
459    pub async fn find_closest_peers(
460        &self,
461        target: &[u8; 32],
462        count: usize,
463    ) -> Result<Vec<(PeerId, Vec<MultiAddr>)>> {
464        self.backend.find_closest_peers(target, count).await
465    }
466
467    /// Collect authenticated close-group responder views.
468    #[cfg(not(feature = "native"))]
469    pub async fn find_witnessed_close_group_with_view_count(
470        &self,
471        target: &[u8; 32],
472        count: usize,
473        view_count: usize,
474    ) -> Result<WitnessedCloseGroup> {
475        self.backend
476            .find_witnessed_close_group(target, count, view_count)
477            .await
478    }
479
480    /// Currently known peers on the browser session.
481    #[cfg(not(feature = "native"))]
482    pub async fn connected_peers(&self) -> Vec<PeerId> {
483        self.backend
484            .known_peers()
485            .into_iter()
486            .map(|node| node.peer_id)
487            .collect()
488    }
489
490    /// Peer records available for fallback retrieval.
491    pub async fn known_peers(&self) -> Vec<DHTNode> {
492        #[cfg(feature = "native")]
493        {
494            self.node.dht().routing_table_peers().await
495        }
496        #[cfg(not(feature = "native"))]
497        {
498            self.backend.known_peers()
499        }
500    }
501
502    /// Seed early reads from the same local phonebook on both platforms.
503    pub(crate) async fn seed_read_candidates(&self, progress: &ReadProgress) {
504        progress.offer(
505            self.known_peers()
506                .await
507                .into_iter()
508                .map(|node| {
509                    let addrs = node.addresses_by_priority();
510                    (node.peer_id, addrs)
511                })
512                .collect(),
513        );
514    }
515
516    #[cfg(not(feature = "native"))]
517    pub(crate) async fn find_read_peers(
518        &self,
519        target: &[u8; 32],
520        count: usize,
521        progress: ReadProgress,
522    ) -> Result<PeerAddresses> {
523        self.backend.find_read_peers(target, count, progress).await
524    }
525}
526
527/// Execute a request through the platform adapter and apply the shared response handler.
528#[allow(clippy::too_many_arguments)]
529pub(crate) async fn send_and_await_chunk_response<T, E: From<crate::data::error::Error>>(
530    network: &Network,
531    target_peer: &PeerId,
532    message_bytes: Vec<u8>,
533    request_id: u64,
534    timeout: std::time::Duration,
535    peer_addrs: &[MultiAddr],
536    response_handler: impl Fn(ant_protocol::ChunkMessageBody) -> Option<std::result::Result<T, E>>,
537    send_error: impl FnOnce(String) -> E,
538    timeout_error: impl FnOnce() -> E,
539) -> std::result::Result<T, E> {
540    #[cfg(feature = "native")]
541    {
542        ant_protocol::send_and_await_chunk_response(
543            network.node(),
544            target_peer,
545            message_bytes,
546            request_id,
547            timeout,
548            peer_addrs,
549            response_handler,
550            send_error,
551            timeout_error,
552        )
553        .await
554    }
555    #[cfg(not(feature = "native"))]
556    {
557        let response = match ant_protocol::ChunkMessage::decode(&message_bytes) {
558            Ok(request) => {
559                network
560                    .backend
561                    .request(target_peer, peer_addrs, request, timeout)
562                    .await
563            }
564            Err(error) => Err(crate::data::error::Error::Protocol(error.to_string())),
565        };
566        let response = match response {
567            Ok(response) => response,
568            // Preserve browser phase diagnostics and timeout classification. Queue
569            // and send expiry must not be reported as a ten-second store wait.
570            Err(error @ crate::data::error::Error::Timeout(_)) => return Err(error.into()),
571            Err(error) => return Err(send_error(error.to_string())),
572        };
573        if response.request_id != request_id {
574            return Err(timeout_error());
575        }
576        response_handler(response.body).unwrap_or_else(|| Err(timeout_error()))
577    }
578}
579
580#[cfg(test)]
581mod tests {
582    use super::*;
583
584    #[test]
585    fn write_ready_false_with_no_peers() {
586        let h = NetworkHealth::from_counts(0, 0);
587        assert!(!h.write_ready);
588        assert_eq!(h.connected_peers, 0);
589        assert_eq!(h.routing_table_size, 0);
590        assert_eq!(h.rebootstrap_threshold, REBOOTSTRAP_THRESHOLD as u32);
591    }
592
593    #[test]
594    fn write_ready_false_below_threshold_on_both_signals() {
595        // The reporter's incident shape (ant-sdk#232): ~1 reachable peer,
596        // empty routing table, stores failing.
597        assert!(!NetworkHealth::from_counts(1, 0).write_ready);
598        assert!(!NetworkHealth::from_counts(2, 2).write_ready);
599    }
600
601    #[test]
602    fn write_ready_true_via_connections_despite_low_routing_table() {
603        // Client-mode under-reporting observed live on a LAN devnet:
604        // rt pinned at 2 with 10 verified connections and stores succeeding.
605        // The max() in the formula exists for exactly this state.
606        assert!(NetworkHealth::from_counts(10, 2).write_ready);
607    }
608
609    #[test]
610    fn write_ready_true_via_routing_table_alone() {
611        assert!(NetworkHealth::from_counts(0, REBOOTSTRAP_THRESHOLD).write_ready);
612    }
613
614    #[test]
615    fn write_ready_true_at_exact_threshold_on_connections() {
616        assert!(NetworkHealth::from_counts(REBOOTSTRAP_THRESHOLD, 0).write_ready);
617    }
618
619    #[test]
620    fn counts_saturate_at_u32_max() {
621        let h = NetworkHealth::from_counts(usize::MAX, usize::MAX);
622        assert_eq!(h.connected_peers, u32::MAX);
623        assert_eq!(h.routing_table_size, u32::MAX);
624        assert!(h.write_ready);
625    }
626}