Skip to main content

chia_query/peer/
pool.rs

1use std::net::SocketAddr;
2use std::sync::atomic::{AtomicU32, AtomicUsize, Ordering};
3use std::sync::Arc;
4use std::time::Duration;
5
6use chia_protocol::{Message, NewPeakWallet, ProtocolMessageTypes};
7use chia_traits::Streamable;
8use futures_util::stream::{FuturesUnordered, StreamExt};
9use tokio::sync::{mpsc, RwLock};
10
11use chia_wallet_sdk::client::Peer;
12use tokio_tungstenite::Connector;
13
14use crate::types::ChiaQueryError;
15use crate::NetworkType;
16
17use super::connect;
18
19// ---------------------------------------------------------------------------
20// Pool entry
21// ---------------------------------------------------------------------------
22
23struct PeerEntry {
24    peer: Peer,
25    address: SocketAddr,
26    /// How this peer was reached. Held so a caller counting independent opinions can tell a
27    /// preferred local node from a discovered one — see [`connect::PeerOrigin`].
28    origin: connect::PeerOrigin,
29}
30
31// ---------------------------------------------------------------------------
32// PeerRequirement
33// ---------------------------------------------------------------------------
34
35/// Whether at least one peer must connect for the pool to be considered usable.
36///
37/// A client that can fall back to the coinset HTTP tier is still useful with zero
38/// peers, so failing construction on peer discovery would deny a keyless reader over a
39/// peer-tier problem it does not need (dig_ecosystem#2210).
40#[derive(Debug, Clone, Copy, PartialEq, Eq)]
41pub enum PeerRequirement {
42    /// Peer discovery failing is fatal.
43    Required,
44    /// An empty pool is acceptable; it refills in the background.
45    Optional,
46}
47
48// ---------------------------------------------------------------------------
49// PeerPool
50// ---------------------------------------------------------------------------
51
52pub struct PeerPool {
53    entries: RwLock<Vec<PeerEntry>>,
54    next_idx: AtomicUsize,
55    max_peers: usize,
56    tls: Connector,
57    network: NetworkType,
58    connect_timeout: Duration,
59    /// Latest peak height observed from any connected peer's NewPeakWallet
60    /// messages.  Updated in the background by receiver handler tasks.
61    peak_height: Arc<AtomicU32>,
62}
63
64impl PeerPool {
65    /// Spin up the pool by connecting to `max_peers` random full-node peers
66    /// concurrently.  Under [`PeerRequirement::Required`] at least one peer must
67    /// succeed, otherwise we return [`ChiaQueryError::PeerDiscoveryFailed`]; under
68    /// [`PeerRequirement::Optional`] an empty pool is returned and refills later.
69    pub async fn new(
70        network: NetworkType,
71        tls: Connector,
72        max_peers: usize,
73        requirement: PeerRequirement,
74        connect_timeout: Duration,
75    ) -> Result<Self, ChiaQueryError> {
76        let peak_height = Arc::new(AtomicU32::new(0));
77
78        // Connect to peers concurrently.
79        let mut futures = FuturesUnordered::new();
80        for _ in 0..max_peers {
81            let t = tls.clone();
82            futures.push(async move {
83                connect::connect_random_peer_excluding(network, &t, connect_timeout, &[]).await
84            });
85        }
86
87        let mut connected = Vec::new();
88        while let Some(result) = futures.next().await {
89            match result {
90                Ok(connection) => connected.push(connection),
91                Err(e) => log::debug!("initial peer connect failed: {e}"),
92            }
93        }
94
95        let pool = Self {
96            entries: RwLock::new(Vec::new()),
97            next_idx: AtomicUsize::new(0),
98            max_peers,
99            tls,
100            network,
101            connect_timeout,
102            peak_height,
103        };
104
105        // Every connection enters through `admit`, including these, so the distinctness invariant
106        // has exactly ONE enforcement site. The initial fill is where duplicates were most likely:
107        // `max_peers` dials race concurrently with no knowledge of each other, so each one may
108        // return the same priority address. A receiver handler is spawned only for a connection
109        // that was actually admitted — spawning one for a discarded duplicate would keep feeding
110        // peak heights from a connection nothing else can see, and this must happen after pool
111        // construction so the `peak_height` Arc exists.
112        for (peer, addr, receiver, origin) in connected {
113            if pool.admit(peer, addr, origin).await {
114                pool.spawn_receiver_handler(receiver);
115            }
116        }
117
118        if !pool.has_peers().await {
119            if requirement == PeerRequirement::Required {
120                return Err(ChiaQueryError::PeerDiscoveryFailed);
121            }
122            log::warn!("no peers connected; serving from the coinset fallback until one does");
123        }
124
125        Ok(pool)
126    }
127
128    /// Latest peak height observed across all connected peers.
129    /// Returns 0 if no peak has been received yet.
130    pub fn peak_height(&self) -> u32 {
131        self.peak_height.load(Ordering::Relaxed)
132    }
133
134    /// Round-robin select a peer from the pool.
135    /// Returns `None` when the pool is empty.
136    pub async fn select_peer(&self) -> Option<(Peer, SocketAddr)> {
137        let entries = self.entries.read().await;
138        if entries.is_empty() {
139            return None;
140        }
141        let idx = self.next_idx.fetch_add(1, Ordering::Relaxed) % entries.len();
142        let entry = &entries[idx];
143        Some((entry.peer.clone(), entry.address))
144    }
145
146    /// Select a peer that could CORROBORATE an answer already given by the peer at `asked`.
147    ///
148    /// A corroborating peer must be two things at once, and neither alone is enough:
149    ///
150    /// - **A different address than `asked`.** Asking the same connection twice returns the same
151    ///   opinion twice, which reads as agreement while being one voice.
152    /// - **[`PeerOrigin::Discovered`](connect::PeerOrigin).** A peer reached from a preferred
153    ///   address — an operator's node, or one on this machine — is an excellent peer to READ from
154    ///   and is not evidence about the chain independent of this host, exactly as
155    ///   [`independent_peer_count`](Self::independent_peer_count) records.
156    ///
157    /// Returns `None` when the pool holds no such peer, which is the honest answer that there is
158    /// nobody to corroborate with — never a substitute peer that would manufacture agreement.
159    pub async fn select_corroborating_peer(&self, asked: SocketAddr) -> Option<(Peer, SocketAddr)> {
160        let entries = self.entries.read().await;
161        let candidates: Vec<&PeerEntry> = entries
162            .iter()
163            .filter(|e| e.address != asked && e.origin == connect::PeerOrigin::Discovered)
164            .collect();
165        if candidates.is_empty() {
166            return None;
167        }
168        let idx = self.next_idx.fetch_add(1, Ordering::Relaxed) % candidates.len();
169        let entry = candidates[idx];
170        Some((entry.peer.clone(), entry.address))
171    }
172
173    /// Every peer that could CORROBORATE an answer already given by the peer at `asked`.
174    ///
175    /// The plural of [`select_corroborating_peer`](Self::select_corroborating_peer), and it holds
176    /// the same two requirements: a different address, and
177    /// [`PeerOrigin::Discovered`](connect::PeerOrigin). A caller corroborating a POSITIVE answer
178    /// wants all of them at once — asking them one at a time lets the first responder settle a
179    /// claim about the chain, which is exactly the power a hostile peer has
180    /// (dig_ecosystem#2462).
181    ///
182    /// Returns an empty vector when the pool holds nobody who qualifies, which is the honest
183    /// answer that there is nobody to corroborate with.
184    pub async fn select_corroborating_peers(&self, asked: SocketAddr) -> Vec<(Peer, SocketAddr)> {
185        self.entries
186            .read()
187            .await
188            .iter()
189            .filter(|e| e.address != asked && e.origin == connect::PeerOrigin::Discovered)
190            .map(|e| (e.peer.clone(), e.address))
191            .collect()
192    }
193
194    /// Remove a peer from the pool and asynchronously connect a replacement.
195    pub async fn eject_peer(&self, addr: SocketAddr) {
196        {
197            let mut entries = self.entries.write().await;
198            entries.retain(|e| e.address != addr);
199        }
200        log::debug!(
201            "peer ejected from pool; will refill on next request (network={:?})",
202            self.network,
203        );
204    }
205
206    /// Whether the pool has at least one usable peer.
207    pub async fn has_peers(&self) -> bool {
208        !self.entries.read().await.is_empty()
209    }
210
211    /// How many peers the pool HOLDS right now.
212    ///
213    /// This is a live count of the connections currently in the pool, not
214    /// [`max_peers`](Self::new)'s target: a pool that is still filling reports what it has, and
215    /// reports the target only once it has reached it. A caller showing this number to a user is
216    /// stating a fact about the machine, so a configured intention must never stand in for it.
217    ///
218    /// A peer is removed by [`eject_peer`](Self::eject_peer), which runs when a request to it
219    /// FAILS. So the count is of peers held and believed usable; a connection that has died
220    /// silently is still counted until something tries to use it. That is the same liveness
221    /// standard [`has_peers`](Self::has_peers) has always answered by, made countable.
222    pub async fn peer_count(&self) -> usize {
223        self.entries.read().await.len()
224    }
225
226    /// How many peers the pool holds that are INDEPENDENT opinions.
227    ///
228    /// [`peer_count`](Self::peer_count) answers "how many connections do I have"; this answers
229    /// "how many of them could corroborate each other". They differ by the peers reached from a
230    /// preferred address — an operator's trusted node or one on this machine — which are excellent
231    /// peers to READ from and are not evidence about the chain independent of this host. A caller
232    /// deciding whether enough separate sources agree MUST use this number, because counting a
233    /// co-resident node as an independent voice is the thing that made a single local process able
234    /// to look like a full peer set (dig_ecosystem#2648).
235    pub async fn independent_peer_count(&self) -> usize {
236        self.entries
237            .read()
238            .await
239            .iter()
240            .filter(|e| e.origin == connect::PeerOrigin::Discovered)
241            .count()
242    }
243
244    /// Admit a connection, or reject it, deciding under the WRITE lock.
245    ///
246    /// Returns whether it was admitted. Rejected because the pool is full, or because its address
247    /// is already held — a pool of N connections to one address reports itself healthy while being
248    /// a single point of both failure and deceit (dig_ecosystem#2648).
249    ///
250    /// **Both checks are made while HOLDING the write lock, and that placement is the whole
251    /// correctness of this.** Dials run concurrently, so any check made before acquiring the lock —
252    /// under the read lock, or by the caller — is a time-of-check/time-of-use gap: two fills of the
253    /// same address each observe it absent, then each pushes, and the duplicate is admitted by
254    /// exactly the code written to prevent it. The check and the push must be one critical section.
255    async fn admit(&self, peer: Peer, address: SocketAddr, origin: connect::PeerOrigin) -> bool {
256        let mut entries = self.entries.write().await;
257
258        if entries.len() >= self.max_peers {
259            log::debug!("peer {address} not admitted: pool is at capacity");
260            return false;
261        }
262        if entries.iter().any(|e| e.address == address) {
263            log::debug!("peer {address} not admitted: already held");
264            return false;
265        }
266
267        entries.push(PeerEntry {
268            peer,
269            address,
270            origin,
271        });
272        log::debug!("peer admitted: {address} ({origin:?})");
273        true
274    }
275
276    /// If the pool is under capacity, try to connect one new peer.
277    /// Also spawns a background task to handle its inbound `NewPeakWallet`
278    /// messages.
279    pub async fn try_refill(&self) {
280        let held: Vec<SocketAddr> = {
281            let entries = self.entries.read().await;
282            if entries.len() >= self.max_peers {
283                return;
284            }
285            entries.iter().map(|e| e.address).collect()
286        };
287
288        // `held` is a hint to the dial, not the guard: it saves dialling an address already in the
289        // pool (the local one is offered on every call), and it may be stale the moment it is read.
290        // `admit` re-decides under the write lock, which is where the invariant actually holds.
291        match connect::connect_random_peer_excluding(
292            self.network,
293            &self.tls,
294            self.connect_timeout,
295            &held,
296        )
297        .await
298        {
299            Ok((peer, addr, receiver, origin)) => {
300                if self.admit(peer, addr, origin).await {
301                    self.spawn_receiver_handler(receiver);
302                    log::debug!("replacement peer connected: {addr}");
303                }
304            }
305            Err(e) => log::warn!("replacement peer connect failed: {e}"),
306        }
307    }
308
309    // -----------------------------------------------------------------------
310    // Receiver helpers (handle NewPeakWallet from peers)
311    // -----------------------------------------------------------------------
312
313    /// Spawn a background task that reads inbound messages from a peer's
314    /// receiver channel and updates the shared peak height.  This mirrors
315    /// the pattern used by chia-block-listener.
316    pub fn spawn_receiver_handler(&self, mut receiver: mpsc::Receiver<Message>) {
317        let peak = Arc::clone(&self.peak_height);
318        tokio::spawn(async move {
319            while let Some(msg) = receiver.recv().await {
320                if msg.msg_type == ProtocolMessageTypes::NewPeakWallet {
321                    if let Ok(new_peak) = NewPeakWallet::from_bytes(&msg.data) {
322                        let prev = peak.fetch_max(new_peak.height, Ordering::Relaxed);
323                        if new_peak.height > prev {
324                            log::debug!("new peak from peer: {}", new_peak.height);
325                        }
326                    }
327                }
328            }
329        });
330    }
331}
332
333/// Construction and admission reachable from OTHER modules' tests.
334///
335/// [`PeerPool::new`] dials the network, so a test of anything built ON the pool — the backend's
336/// absence corroboration, for one — cannot use it. These wrap the private internals rather than
337/// widening them, so production code keeps exactly one admission path.
338#[cfg(test)]
339impl PeerPool {
340    pub(crate) fn for_tests(max_peers: usize) -> Self {
341        Self {
342            entries: RwLock::new(Vec::new()),
343            next_idx: AtomicUsize::new(0),
344            max_peers,
345            tls: connect::create_generated_tls().expect("generate a TLS identity"),
346            network: NetworkType::Mainnet,
347            connect_timeout: Duration::from_millis(1),
348            peak_height: Arc::new(AtomicU32::new(0)),
349        }
350    }
351
352    pub(crate) async fn admit_for_tests(
353        &self,
354        peer: Peer,
355        address: SocketAddr,
356        origin: connect::PeerOrigin,
357    ) -> bool {
358        self.admit(peer, address, origin).await
359    }
360}
361
362#[cfg(test)]
363mod tests {
364    use super::*;
365    use crate::peer::connect::{create_generated_tls, PeerOrigin};
366    use crate::peer::test_support::{address, loopback_peer};
367
368    use super::PeerPool as _Pool;
369    fn empty_pool(max_peers: usize) -> PeerPool {
370        _Pool::for_tests(max_peers)
371    }
372
373    /// **The defect, and the one shape that separates a locked re-check from a TOCTOU dedupe.**
374    ///
375    /// Eight fills of the SAME address are admitted CONCURRENTLY, which is how the pool fills in
376    /// production: `PeerPool::new` races `max_peers` dials with no knowledge of each other, and each
377    /// may return the same priority address. A dedupe that reads the entry list before taking the
378    /// write lock passes a sequential test and fails this one — every task observes the address
379    /// absent, then every task pushes.
380    ///
381    /// `max_peers` is 8, not 1, deliberately: a capacity of one would make the pool reject the
382    /// duplicates for being FULL rather than for being duplicates, and would stay green with the
383    /// distinctness check deleted entirely.
384    #[tokio::test(flavor = "multi_thread", worker_threads = 4)]
385    async fn one_address_cannot_fill_the_pool_however_many_fills_race() {
386        let pool = Arc::new(empty_pool(8));
387        let peer = loopback_peer().await;
388        let occupied = address(1);
389
390        let mut fills = Vec::new();
391        for _ in 0..8 {
392            let pool = Arc::clone(&pool);
393            let peer = peer.clone();
394            fills.push(tokio::spawn(async move {
395                pool.admit(peer, occupied, PeerOrigin::Priority).await
396            }));
397        }
398
399        let admitted = futures_util::future::join_all(fills)
400            .await
401            .into_iter()
402            .filter(|r| *r.as_ref().expect("the admission task must not panic"))
403            .count();
404
405        assert_eq!(
406            admitted, 1,
407            "exactly one fill of an address may be admitted"
408        );
409        assert_eq!(
410            pool.peer_count().await,
411            1,
412            "eight concurrent fills of one address must leave one connection, not eight"
413        );
414    }
415
416    /// The control that keeps the test above honest: concurrency itself must not cost admissions.
417    ///
418    /// Without this, an `admit` that rejected everything after the first — or that lost racing
419    /// pushes — would satisfy the distinctness test while breaking the pool.
420    #[tokio::test(flavor = "multi_thread", worker_threads = 4)]
421    async fn distinct_addresses_all_fill_concurrently() {
422        let pool = Arc::new(empty_pool(8));
423        let peer = loopback_peer().await;
424
425        let mut fills = Vec::new();
426        for octet in 1..=8u8 {
427            let pool = Arc::clone(&pool);
428            let peer = peer.clone();
429            fills.push(tokio::spawn(async move {
430                pool.admit(peer, address(octet), PeerOrigin::Discovered)
431                    .await
432            }));
433        }
434        futures_util::future::join_all(fills).await;
435
436        assert_eq!(
437            pool.peer_count().await,
438            8,
439            "eight distinct addresses must all be admitted"
440        );
441    }
442
443    /// Capacity is enforced in the same critical section, so racing fills cannot overshoot it.
444    #[tokio::test(flavor = "multi_thread", worker_threads = 4)]
445    async fn concurrent_fills_never_exceed_max_peers() {
446        let pool = Arc::new(empty_pool(3));
447        let peer = loopback_peer().await;
448
449        let mut fills = Vec::new();
450        for octet in 1..=10u8 {
451            let pool = Arc::clone(&pool);
452            let peer = peer.clone();
453            fills.push(tokio::spawn(async move {
454                pool.admit(peer, address(octet), PeerOrigin::Discovered)
455                    .await
456            }));
457        }
458        futures_util::future::join_all(fills).await;
459
460        assert_eq!(pool.peer_count().await, 3, "max_peers is a hard ceiling");
461    }
462
463    /// **A preferred peer is not a corroborating one.**
464    ///
465    /// Two `Discovered` peers sit beside one `Priority` peer, so the two counts differ by exactly
466    /// the priority entry. A single-origin fixture cannot show that: all-priority or all-discovered
467    /// both make the two counts move together, which an implementation returning `peer_count` for
468    /// both would satisfy.
469    #[tokio::test]
470    async fn a_preferred_peer_is_held_but_not_counted_as_an_independent_opinion() {
471        let pool = empty_pool(5);
472        let peer = loopback_peer().await;
473
474        assert!(
475            pool.admit(peer.clone(), address(1), PeerOrigin::Priority)
476                .await
477        );
478        assert!(
479            pool.admit(peer.clone(), address(2), PeerOrigin::Discovered)
480                .await
481        );
482        assert!(pool.admit(peer, address(3), PeerOrigin::Discovered).await);
483
484        assert_eq!(pool.peer_count().await, 3, "three connections are held");
485        assert_eq!(
486            pool.independent_peer_count().await,
487            2,
488            "the co-resident peer is held and read from, but is not an independent voice"
489        );
490    }
491
492    /// An ejected address is admissible again — distinctness must not become a permanent ban.
493    #[tokio::test]
494    async fn an_ejected_address_can_be_admitted_again() {
495        let pool = empty_pool(5);
496        let peer = loopback_peer().await;
497        let addr = address(1);
498
499        assert!(pool.admit(peer.clone(), addr, PeerOrigin::Discovered).await);
500        assert!(
501            !pool.admit(peer.clone(), addr, PeerOrigin::Discovered).await,
502            "still held, so still a duplicate"
503        );
504
505        pool.eject_peer(addr).await;
506
507        assert!(
508            pool.admit(peer, addr, PeerOrigin::Discovered).await,
509            "a re-dialled peer must be admissible after ejection"
510        );
511        assert_eq!(pool.peer_count().await, 1);
512    }
513
514    /// `max_peers: 0` attempts no connection at all, so the pool is deterministically
515    /// empty offline — an exact, network-free fixture for the empty-pool branch.
516    async fn pool_with_no_connection_attempts(
517        requirement: PeerRequirement,
518    ) -> Result<PeerPool, ChiaQueryError> {
519        PeerPool::new(
520            NetworkType::Mainnet,
521            create_generated_tls().expect("generate a TLS identity"),
522            0,
523            requirement,
524            Duration::from_millis(1),
525        )
526        .await
527    }
528
529    /// The control: an empty pool is still fatal when nothing can serve in its place.
530    #[tokio::test]
531    async fn empty_pool_is_fatal_when_peers_are_required() {
532        assert!(matches!(
533            pool_with_no_connection_attempts(PeerRequirement::Required).await,
534            Err(ChiaQueryError::PeerDiscoveryFailed)
535        ));
536    }
537
538    /// The fix: with a fallback able to serve, an empty pool must not deny the client.
539    #[tokio::test]
540    async fn empty_pool_is_tolerated_when_peers_are_optional() {
541        let pool = pool_with_no_connection_attempts(PeerRequirement::Optional)
542            .await
543            .expect("an optional peer pool must construct with zero peers");
544        assert!(!pool.has_peers().await);
545    }
546
547    /// **The count is what is HELD, never what was asked for.**
548    ///
549    /// Built by hand rather than through [`PeerPool::new`] so `max_peers` can be a realistic 5
550    /// while the pool provably holds nothing — the one shape that separates a measurement from a
551    /// configured intention. A `peer_count` that returned `max_peers` would satisfy every
552    /// assertion reachable through the offline constructor, whose `max_peers` is necessarily 0,
553    /// and would then report "5 peers" on a machine holding none.
554    #[tokio::test]
555    async fn an_unfilled_pool_counts_what_it_holds_not_the_target_it_was_given() {
556        let pool = empty_pool(5);
557
558        assert_eq!(
559            pool.peer_count().await,
560            0,
561            "held is 0 while the target is 5"
562        );
563        assert!(!pool.has_peers().await);
564    }
565}