Skip to main content

chia_query/peer/
mod.rs

1pub mod block;
2pub mod connect;
3pub mod frames;
4pub mod light_client;
5pub mod ordering;
6pub mod plurality;
7pub mod pool;
8pub mod translate;
9
10#[cfg(test)]
11mod corroboration_tests;
12#[cfg(test)]
13pub(crate) mod test_support;
14
15use std::net::SocketAddr;
16use std::time::Duration;
17
18use chia_consensus::consensus_constants::ConsensusConstants;
19use chia_protocol::{
20    Bytes32, CoinStateFilters, FullBlock as ProtoFullBlock, RejectAdditionsRequest, RejectBlock,
21    RejectHeaderRequest, RejectRemovalsRequest, RequestAdditions, RequestBlock, RequestBlockHeader,
22    RequestFeeEstimates, RequestRemovals, RespondAdditions, RespondBlock, RespondBlockHeader,
23    RespondFeeEstimates, RespondRemovals, SpendBundle as ProtoBundle,
24};
25use chia_wallet_sdk::client::Peer;
26use chia_wallet_sdk::types::{MAINNET_CONSTANTS, TESTNET11_CONSTANTS};
27use tokio_tungstenite::Connector;
28
29use crate::types::*;
30
31use crate::NetworkType;
32pub use light_client::{ChiaLightClient, LightClientProvider, SubmitOutcome};
33use plurality::CORROBORATION_FLOOR;
34pub use pool::PeerRequirement;
35use pool::{CorroborationReadiness, PeerPool};
36
37// ---------------------------------------------------------------------------
38// OptAnswer
39// ---------------------------------------------------------------------------
40
41/// What the peer tier was able to establish about a thing that may or may not exist.
42///
43/// [`Option`] cannot express this, and that is precisely how dig_ecosystem#2456 stayed invisible:
44/// `None` was read as *"the chain does not have this"* while it meant *"one anonymous peer sent an
45/// empty list"*. The two are different facts and a caller needs to tell them apart, so the peer
46/// tier reports which one it has and lets the router decide what to do about it.
47#[derive(Debug, Clone, PartialEq, Eq)]
48pub enum OptAnswer<T> {
49    /// The thing exists, here it is, and an independent peer agreed on what it says about the
50    /// chain.
51    ///
52    /// A record is checkable against its own fields only as far as its IDENTITY: a coin id is
53    /// `SHA256(parent_coin_info ‖ puzzle_hash ‖ amount)` and covers nothing else. `created_height`
54    /// and `spent_height` — the entire reason such a read is made — are copied from what the peer
55    /// sent, so a positive answer is corroborated exactly like an absence is
56    /// (dig_ecosystem#2462).
57    Found(T),
58    /// The thing exists on ONE peer's word, and no independent peer said the same.
59    ///
60    /// The record is still carried, because it is a real answer and the router may yet find a
61    /// second voice for it; what it is not is evidence about the chain. A consumer handed this
62    /// directly MUST NOT record a height from it.
63    UncorroboratedFound(T),
64    /// Two independent peers, at different addresses, both report it absent.
65    CorroboratedAbsent,
66    /// One peer reports it absent and no second independent peer could say so too.
67    ///
68    /// The peer tier has no basis to call this absence. Whether it can become one depends on
69    /// sources the peer tier does not own, so it hands the undecided fact up rather than deciding
70    /// it (see [`QueryRouter`](crate::router::QueryRouter), which may corroborate against coinset).
71    UncorroboratedAbsent,
72}
73
74/// Whether a round of `agreed` agreeing answers, taken from a pool in state `readiness`, may be
75/// reported as CORROBORATED.
76///
77/// Both halves are required and neither implies the other:
78///
79/// - **The pool must have been [`Armed`](CorroborationReadiness::Armed)** — it held at least
80///   [`CORROBORATION_FLOOR`] independent peers besides the one that answered. This is a fact about
81///   the pool's membership, checked before the round, so an answer can never be reported as
82///   corroborated by a pool that never had the voices to corroborate it.
83/// - **At least [`CORROBORATION_FLOOR`] peers must have AGREED** — a fact about the round itself.
84///   A pool that held enough peers and then had them time out has corroborated nothing, and
85///   membership alone cannot see that.
86///
87/// The membership half is not implied by the agreement half even though readiness now counts
88/// exactly the peers that will be asked. Readiness is a SNAPSHOT taken before the round, but on a
89/// shared [`QueryRouter`] multiple concurrent callers may each call [`PeerPool::maintain`] during
90/// the same question, so a round can be answered by more peers than the readiness snapshot held.
91/// Requiring the pool to have been armed BEFORE the round is what stops a pool that could not have
92/// corroborated anything from being rescued by a peer that arrived mid-question.
93///
94/// The floor is on agreement, never on peers asked: reading "I asked and heard no contradiction"
95/// as agreement is how silence becomes a second opinion.
96fn corroborated(readiness: CorroborationReadiness, agreed: usize) -> bool {
97    matches!(readiness, CorroborationReadiness::Armed { .. }) && agreed >= CORROBORATION_FLOOR
98}
99
100// ---------------------------------------------------------------------------
101// PeerBackend
102// ---------------------------------------------------------------------------
103
104/// A backend over an EMPTY pool, dialling nothing.
105///
106/// Exists so a test of something built on the backend — the router's settlement of a graded
107/// answer, which needs a `QueryRouter` and therefore a `PeerBackend` — can be written without a
108/// network. Every read through it fails for want of a peer, which is correct: such a test must
109/// supply the answer it is settling, never obtain one.
110#[cfg(test)]
111impl PeerBackend {
112    pub(crate) fn for_tests() -> Self {
113        Self::for_tests_with_capacity(0)
114    }
115
116    /// A backend over an empty pool that will ADMIT up to `max_peers`, so a test can populate it
117    /// with loopback peers at chosen addresses and origins.
118    pub(crate) fn for_tests_with_capacity(max_peers: usize) -> Self {
119        Self {
120            pool: pool::PeerPool::for_tests(max_peers),
121            network: NetworkType::Mainnet,
122            request_timeout: Duration::from_millis(1),
123        }
124    }
125
126    /// The pool underneath, so a test can admit peers into the backend it is exercising.
127    pub(crate) fn pool_for_tests(&self) -> &pool::PeerPool {
128        &self.pool
129    }
130}
131
132pub struct PeerBackend {
133    pool: PeerPool,
134    network: NetworkType,
135    request_timeout: Duration,
136}
137
138impl PeerBackend {
139    pub async fn new(
140        network: crate::NetworkType,
141        tls: Connector,
142        max_peers: usize,
143        requirement: PeerRequirement,
144        connect_timeout: Duration,
145        request_timeout: Duration,
146    ) -> Result<Self, ChiaQueryError> {
147        let pool = PeerPool::new(network, tls, max_peers, requirement, connect_timeout).await?;
148        Ok(Self {
149            pool,
150            network,
151            request_timeout,
152        })
153    }
154
155    /// Get the consensus constants for the configured network.
156    pub fn constants(&self) -> &ConsensusConstants {
157        match self.network {
158            NetworkType::Mainnet => &MAINNET_CONSTANTS,
159            NetworkType::Testnet11 => &TESTNET11_CONSTANTS,
160        }
161    }
162
163    /// Genesis challenge for the configured network.  Used as the header_hash
164    /// when querying coin state from height 0 (required by the peer protocol
165    /// -- `Bytes32::default()` causes rejection).
166    fn genesis_challenge(&self) -> Bytes32 {
167        self.constants().genesis_challenge
168    }
169
170    pub async fn has_peers(&self) -> bool {
171        self.pool.has_peers().await
172    }
173
174    /// How many peers this backend HOLDS right now — see [`PeerPool::peer_count`].
175    pub async fn peer_count(&self) -> usize {
176        self.pool.peer_count().await
177    }
178
179    /// How many held peers are INDEPENDENT opinions — see [`PeerPool::independent_peer_count`].
180    pub async fn independent_peer_count(&self) -> usize {
181        self.pool.independent_peer_count().await
182    }
183
184    /// Whether a corroborated read of an answer given by `asked` may be attempted at all — see
185    /// [`PeerPool::corroboration_readiness`]. It REFUSES rather than degrading.
186    pub async fn corroboration_readiness(&self, asked: SocketAddr) -> pool::CorroborationReadiness {
187        self.pool.corroboration_readiness(asked).await
188    }
189
190    /// Subscribe to the frames arriving on this backend's pooled sessions.
191    ///
192    /// Falling further behind than `capacity` ENDS the subscription rather than skipping a frame —
193    /// see [`frames::FrameSubscription`].
194    pub async fn subscribe_frames(&self, capacity: usize) -> frames::FrameSubscription {
195        self.pool.subscribe_frames(capacity).await
196    }
197
198    // -----------------------------------------------------------------------
199    // Select a peer (round-robin) then attempt to refill if pool is short.
200    // -----------------------------------------------------------------------
201
202    async fn pick(&self) -> Result<(Peer, SocketAddr), ChiaQueryError> {
203        // One maintenance pass per request: rotate out a peer that has outlived
204        // [`plurality::PEER_LIFETIME`], then refill if under capacity.
205        //
206        // Driving cycling from the request path rather than a timer task is deliberate — it is the
207        // only place the pool is reliably reached, and a cycling policy that nothing calls is
208        // indistinguishable from no cycling at all (NC-12).
209        self.pool.maintain().await;
210
211        self.pool
212            .select_peer()
213            .await
214            .ok_or_else(|| ChiaQueryError::PeerConnection("no peers available".into()))
215    }
216
217    // =======================================================================
218    // Public try_* methods -- each selects a peer, makes the request, and
219    // ejects the peer on failure.
220    // =======================================================================
221
222    pub async fn try_get_coin_record_by_name(
223        &self,
224        name: &str,
225    ) -> Result<CoinRecord, ChiaQueryError> {
226        let (peer, addr) = self.pick().await?;
227        let res = self.do_get_coin_record_by_name(&peer, name).await;
228        if res.is_err() {
229            self.pool.eject_peer(addr).await;
230        }
231        res
232    }
233
234    /// Absence-aware sibling of [`try_get_coin_record_by_name`](Self::try_get_coin_record_by_name).
235    ///
236    /// A rejected/timed-out request is a failure -> `Err`. An EMPTY coin-state list from a
237    /// successful `RespondCoinState` is one peer's WORD that the coin does not exist, which is not
238    /// the same thing as it not existing, so the answer is graded by [`OptAnswer`] rather than
239    /// flattened into `Ok(None)` -- see
240    /// [`read_opt_corroborated`](Self::read_opt_corroborated) (SPEC §3).
241    pub async fn try_get_coin_record_by_name_opt(
242        &self,
243        name: &str,
244    ) -> Result<OptAnswer<CoinRecord>, ChiaQueryError> {
245        self.read_opt_corroborated(|peer| async move {
246            self.do_get_coin_record_by_name_opt(&peer, name).await
247        })
248        .await
249    }
250
251    /// Read something that may be absent, and CORROBORATE whichever answer comes back.
252    ///
253    /// `read` is run against one selected peer, and that peer's answer is then put to independent
254    /// peers — peers at DIFFERENT addresses that were
255    /// [discovered](pool::PeerPool::select_corroborating_peers) rather than preferred. Neither
256    /// direction is taken on one peer's word:
257    ///
258    /// Both directions are graded against [`CORROBORATION_FLOOR`], and a contradiction outranks
259    /// any amount of agreement:
260    ///
261    /// - **Present** — the record's chain claim (see [`ChainClaim`]) is put to every independent
262    ///   peer at once. [`CORROBORATION_FLOOR`] peers agreeing makes it
263    ///   [`Found`](OptAnswer::Found); one that says anything else — a different height, or nothing
264    ///   at all — fails the read with [`SourcesDisagree`](ChiaQueryError::SourcesDisagree); too few
265    ///   agreeing voices leaves it [`UncorroboratedFound`](OptAnswer::UncorroboratedFound).
266    /// - **Absent** — every independent peer is asked the same question. [`CORROBORATION_FLOOR`]
267    ///   agreeing absences make it [`CorroboratedAbsent`](OptAnswer::CorroboratedAbsent); a peer
268    ///   that produces the thing is [`SourcesDisagree`](ChiaQueryError::SourcesDisagree); too few
269    ///   agreeing voices leaves it [`UncorroboratedAbsent`](OptAnswer::UncorroboratedAbsent).
270    ///
271    /// **One agreeing peer is not corroboration.** An `Uncorroborated*` answer is not a failure —
272    /// it is the honest report that the peer tier could not establish the fact, and the router
273    /// settles it against another tier or surfaces it as
274    /// [`UncorroboratedPresence`](ChiaQueryError::UncorroboratedPresence). What it must never do
275    /// is report a one-voice answer as corroborated.
276    ///
277    /// **Why presence asks everyone and absence asks one.** A hostile peer that answers an absence
278    /// wrongly is refuted by any honest peer, so a single corroborator is a sufficient confidence
279    /// floor. A hostile peer that answers a PRESENCE wrongly is claiming a height, and letting the
280    /// first responder settle that would let whichever peer is fastest decide whether money is
281    /// treated as confirmed — so every independent peer is queried CONCURRENTLY and any
282    /// contradiction beats any agreement (NC-12, dig_ecosystem#2462). The cost of that is bounded:
283    /// a hostile corroborator can make a read fail, which the caller retries, but it can never
284    /// make a read return a fact.
285    ///
286    /// Neither direction is an N-of-N barrier — a peer that fails to answer is ejected and does
287    /// not hold the read up — because requiring the whole pool would let one dead peer stall every
288    /// query.
289    async fn read_opt_corroborated<T, F, Fut>(
290        &self,
291        read: F,
292    ) -> Result<OptAnswer<T>, ChiaQueryError>
293    where
294        T: ChainClaim,
295        F: Fn(Peer) -> Fut,
296        Fut: std::future::Future<Output = Result<Option<T>, ChiaQueryError>>,
297    {
298        let (peer, addr) = self.pick().await?;
299        let first = match read(peer).await {
300            Ok(v) => v,
301            Err(e) => {
302                self.pool.eject_peer(addr).await;
303                return Err(e);
304            }
305        };
306
307        match first {
308            Some(found) => self.corroborate_presence(found, addr, &read).await,
309            None => self.corroborate_absence(addr, &read).await,
310        }
311    }
312
313    /// Put a positive answer to every independent peer at once, and grade the agreement.
314    async fn corroborate_presence<T, F, Fut>(
315        &self,
316        found: T,
317        addr: SocketAddr,
318        read: &F,
319    ) -> Result<OptAnswer<T>, ChiaQueryError>
320    where
321        T: ChainClaim,
322        F: Fn(Peer) -> Fut,
323        Fut: std::future::Future<Output = Result<Option<T>, ChiaQueryError>>,
324    {
325        // Read the pool's arming BEFORE the round, and treat it as a ceiling on the verdict
326        // rather than a gate on asking. Asking cannot make an answer worse — a contradiction is
327        // decisive however few peers are held — but reporting corroboration from a pool that never
328        // held enough independent voices is exactly the degradation the floor exists to prevent.
329        let readiness = self.pool.corroboration_readiness(addr).await;
330
331        let corroborators = self.pool.select_corroborating_peers(addr).await;
332        if corroborators.is_empty() {
333            log::debug!("presence reported by {addr} has no independent corroborator available");
334            return Ok(OptAnswer::UncorroboratedFound(found));
335        }
336
337        // Every corroborator is asked concurrently and EVERY answer is collected before anything
338        // is decided. Grading as the answers arrive would hand the outcome to whichever peer is
339        // fastest, which is the property a hostile peer controls.
340        let answers =
341            futures_util::future::join_all(corroborators.into_iter().map(|(peer, peer_addr)| {
342                let answer = read(peer);
343                async move { (peer_addr, answer.await) }
344            }))
345            .await;
346
347        let claim = found.chain_claim();
348        let mut agreed = 0usize;
349        let mut disagreement: Option<String> = None;
350        let mut failed: Vec<SocketAddr> = Vec::new();
351
352        for (peer_addr, answer) in answers {
353            match answer {
354                Ok(Some(other)) if other.chain_claim() == claim => agreed += 1,
355                Ok(Some(other)) => {
356                    disagreement.get_or_insert_with(|| {
357                        format!(
358                            "peer {addr} claims `{claim}`, peer {peer_addr} claims `{}`",
359                            other.chain_claim()
360                        )
361                    });
362                }
363                Ok(None) => {
364                    disagreement.get_or_insert_with(|| {
365                        format!("peer {addr} reports present, peer {peer_addr} reports absent")
366                    });
367                }
368                Err(e) => {
369                    log::debug!("corroborator {peer_addr} failed: {e}");
370                    failed.push(peer_addr);
371                }
372            }
373        }
374
375        // Ejection happens whatever the verdict: a peer that failed a read is ejected everywhere
376        // else in this backend, and a disagreement is not a reason to keep a broken connection.
377        for peer_addr in failed {
378            self.pool.eject_peer(peer_addr).await;
379        }
380
381        // A contradiction outranks any amount of agreement. Nothing in the answers says which set
382        // to believe, so counting votes would invent a fact — and would let an attacker holding
383        // two pool slots manufacture one.
384        if let Some(detail) = disagreement {
385            return Err(ChiaQueryError::SourcesDisagree(detail));
386        }
387
388        if !corroborated(readiness, agreed) {
389            log::debug!(
390                "presence reported by {addr} drew {agreed} agreeing voices with the pool \
391                 {readiness:?}; below the floor of {CORROBORATION_FLOOR}"
392            );
393            return Ok(OptAnswer::UncorroboratedFound(found));
394        }
395        Ok(OptAnswer::Found(found))
396    }
397
398    /// Put an absence to EVERY independent peer at once, and grade the agreement.
399    ///
400    /// Asking all of them together, rather than one at a time, is the same requirement presence
401    /// has: a single corroborator lets whichever peer is asked settle a claim about the chain, and
402    /// which peer that is, is not a property the reader controls.
403    async fn corroborate_absence<T, F, Fut>(
404        &self,
405        addr: SocketAddr,
406        read: &F,
407    ) -> Result<OptAnswer<T>, ChiaQueryError>
408    where
409        T: ChainClaim,
410        F: Fn(Peer) -> Fut,
411        Fut: std::future::Future<Output = Result<Option<T>, ChiaQueryError>>,
412    {
413        let readiness = self.pool.corroboration_readiness(addr).await;
414
415        let corroborators = self.pool.select_corroborating_peers(addr).await;
416        if corroborators.is_empty() {
417            log::debug!("absence reported by {addr} has no independent corroborator available");
418            return Ok(OptAnswer::UncorroboratedAbsent);
419        }
420
421        let answers =
422            futures_util::future::join_all(corroborators.into_iter().map(|(peer, peer_addr)| {
423                let answer = read(peer);
424                async move { (peer_addr, answer.await) }
425            }))
426            .await;
427
428        let mut agreed = 0usize;
429        let mut disagreement: Option<String> = None;
430        let mut failed: Vec<SocketAddr> = Vec::new();
431
432        for (peer_addr, answer) in answers {
433            match answer {
434                Ok(None) => agreed += 1,
435                Ok(Some(_)) => {
436                    disagreement.get_or_insert_with(|| {
437                        format!("peer {addr} reports absent, peer {peer_addr} reports present")
438                    });
439                }
440                Err(e) => {
441                    log::debug!("corroborator {peer_addr} failed: {e}");
442                    failed.push(peer_addr);
443                }
444            }
445        }
446
447        // A peer that fails a read is ejected everywhere else in this backend, whatever the
448        // verdict turns out to be.
449        for peer_addr in failed {
450            self.pool.eject_peer(peer_addr).await;
451        }
452
453        if let Some(detail) = disagreement {
454            return Err(ChiaQueryError::SourcesDisagree(detail));
455        }
456
457        if !corroborated(readiness, agreed) {
458            log::debug!(
459                "absence reported by {addr} drew {agreed} agreeing voices with the pool \
460                 {readiness:?}; below the floor of {CORROBORATION_FLOOR}"
461            );
462            return Ok(OptAnswer::UncorroboratedAbsent);
463        }
464        Ok(OptAnswer::CorroboratedAbsent)
465    }
466
467    /// Absence-aware read of the spend that spent `coin_id`.
468    ///
469    /// A coin that is unknown (no coin-state) and one that is unspent (no spent height) are both
470    /// "there is no such spend", and both rest on a peer's word alone, so both are graded by
471    /// [`OptAnswer`] through [`read_opt_corroborated`](Self::read_opt_corroborated). `Err` only
472    /// when the peer read itself fails.
473    pub async fn try_get_coin_spend_opt(
474        &self,
475        coin_id: &str,
476    ) -> Result<OptAnswer<CoinSpend>, ChiaQueryError> {
477        self.read_opt_corroborated(|peer| async move {
478            self.do_get_coin_spend_opt(&peer, coin_id).await
479        })
480        .await
481    }
482
483    pub async fn try_get_coin_records_by_puzzle_hash(
484        &self,
485        puzzle_hash: &str,
486        start_height: Option<u32>,
487        end_height: Option<u32>,
488        include_spent: bool,
489    ) -> Result<Vec<CoinRecord>, ChiaQueryError> {
490        let (peer, addr) = self.pick().await?;
491        let res = self
492            .do_puzzle_hash_query(
493                &peer,
494                &[puzzle_hash],
495                start_height,
496                end_height,
497                include_spent,
498                false,
499            )
500            .await;
501        if res.is_err() {
502            self.pool.eject_peer(addr).await;
503        }
504        res
505    }
506
507    pub async fn try_get_coin_records_by_puzzle_hashes(
508        &self,
509        puzzle_hashes: &[String],
510        start_height: Option<u32>,
511        end_height: Option<u32>,
512        include_spent: bool,
513    ) -> Result<Vec<CoinRecord>, ChiaQueryError> {
514        let hashes: Vec<&str> = puzzle_hashes.iter().map(String::as_str).collect();
515        let (peer, addr) = self.pick().await?;
516        let res = self
517            .do_puzzle_hash_query(
518                &peer,
519                &hashes,
520                start_height,
521                end_height,
522                include_spent,
523                false,
524            )
525            .await;
526        if res.is_err() {
527            self.pool.eject_peer(addr).await;
528        }
529        res
530    }
531
532    pub async fn try_get_coin_records_by_hint(
533        &self,
534        hint: &str,
535        start_height: Option<u32>,
536        end_height: Option<u32>,
537        include_spent: bool,
538    ) -> Result<Vec<CoinRecord>, ChiaQueryError> {
539        let (peer, addr) = self.pick().await?;
540        let res = self
541            .do_puzzle_hash_query(
542                &peer,
543                &[hint],
544                start_height,
545                end_height,
546                include_spent,
547                true,
548            )
549            .await;
550        if res.is_err() {
551            self.pool.eject_peer(addr).await;
552        }
553        res
554    }
555
556    pub async fn try_get_coin_records_by_hints(
557        &self,
558        hints: &[String],
559        start_height: Option<u32>,
560        end_height: Option<u32>,
561        include_spent: bool,
562    ) -> Result<Vec<CoinRecord>, ChiaQueryError> {
563        let hs: Vec<&str> = hints.iter().map(String::as_str).collect();
564        let (peer, addr) = self.pick().await?;
565        let res = self
566            .do_puzzle_hash_query(&peer, &hs, start_height, end_height, include_spent, true)
567            .await;
568        if res.is_err() {
569            self.pool.eject_peer(addr).await;
570        }
571        res
572    }
573
574    pub async fn try_get_coin_records_by_names(
575        &self,
576        names: &[String],
577    ) -> Result<Vec<CoinRecord>, ChiaQueryError> {
578        let (peer, addr) = self.pick().await?;
579        let res = self.do_coin_ids_query(&peer, names).await;
580        if res.is_err() {
581            self.pool.eject_peer(addr).await;
582        }
583        res
584    }
585
586    pub async fn try_get_puzzle_and_solution(
587        &self,
588        coin_id: &str,
589        height: u32,
590    ) -> Result<CoinSpend, ChiaQueryError> {
591        let (peer, addr) = self.pick().await?;
592        let res = self
593            .do_get_puzzle_and_solution(&peer, coin_id, height)
594            .await;
595        if res.is_err() {
596            self.pool.eject_peer(addr).await;
597        }
598        res
599    }
600
601    pub async fn try_get_fee_estimate(
602        &self,
603        target_times: &[u64],
604    ) -> Result<FeeEstimate, ChiaQueryError> {
605        let (peer, addr) = self.pick().await?;
606        let res = self.do_get_fee_estimate(&peer, target_times).await;
607        if res.is_err() {
608            self.pool.eject_peer(addr).await;
609        }
610        res
611    }
612
613    pub async fn try_push_tx(&self, bundle: &SpendBundle) -> Result<TxStatus, ChiaQueryError> {
614        let (peer, addr) = self.pick().await?;
615        let res = self.do_push_tx(&peer, bundle).await;
616        if res.is_err() {
617            self.pool.eject_peer(addr).await;
618        }
619        res
620    }
621
622    // -- block record by height (RequestBlockHeader) -------------------------
623
624    pub async fn try_get_block_record_by_height(
625        &self,
626        height: u32,
627    ) -> Result<BlockRecord, ChiaQueryError> {
628        let (peer, addr) = self.pick().await?;
629        let res = self.do_get_block_record_by_height(&peer, height).await;
630        if res.is_err() {
631            self.pool.eject_peer(addr).await;
632        }
633        res
634    }
635
636    // -- additions and removals (RequestAdditions + RequestRemovals) ---------
637    // Available for callers who have both height and header_hash.  The
638    // coinset.org API only requires header_hash, so the router cannot
639    // automatically peer-back this endpoint without a height lookup first.
640
641    #[allow(dead_code)]
642    pub async fn try_get_additions_and_removals(
643        &self,
644        height: u32,
645        header_hash: &str,
646    ) -> Result<AdditionsAndRemovals, ChiaQueryError> {
647        let (peer, addr) = self.pick().await?;
648        let res = self
649            .do_get_additions_and_removals(&peer, height, header_hash)
650            .await;
651        if res.is_err() {
652            self.pool.eject_peer(addr).await;
653        }
654        res
655    }
656
657    // -- children (for parent_id queries) -----------------------------------
658
659    pub async fn try_get_children(
660        &self,
661        parent_id: &str,
662    ) -> Result<Vec<CoinRecord>, ChiaQueryError> {
663        let (peer, addr) = self.pick().await?;
664        let res = self.do_get_children(&peer, parent_id).await;
665        if res.is_err() {
666            self.pool.eject_peer(addr).await;
667        }
668        res
669    }
670
671    // -- get full block by height (RequestBlock) ------------------------------
672
673    pub async fn try_get_block_by_height(
674        &self,
675        height: u32,
676    ) -> Result<serde_json::Value, ChiaQueryError> {
677        let (peer, addr) = self.pick().await?;
678        let res = self.do_get_block_by_height(&peer, height).await;
679        if res.is_err() {
680            self.pool.eject_peer(addr).await;
681        }
682        res
683    }
684
685    // -- additions and removals from a full block (CLVM parsing) -------------
686
687    pub async fn try_get_additions_and_removals_from_block(
688        &self,
689        height: u32,
690    ) -> Result<AdditionsAndRemovals, ChiaQueryError> {
691        let (peer, addr) = self.pick().await?;
692        let res = self
693            .do_get_additions_and_removals_from_block(&peer, height)
694            .await;
695        if res.is_err() {
696            self.pool.eject_peer(addr).await;
697        }
698        res
699    }
700
701    // -- block spends with puzzle_reveal + solution (CLVM parsing) -----------
702
703    pub async fn try_get_block_spends_by_height(
704        &self,
705        height: u32,
706    ) -> Result<Vec<CoinSpend>, ChiaQueryError> {
707        let (peer, addr) = self.pick().await?;
708        let res = self.do_get_block_spends(&peer, height).await;
709        if res.is_err() {
710            self.pool.eject_peer(addr).await;
711        }
712        res
713    }
714
715    // -- block spends WITH parsed conditions --------------------------------
716
717    pub async fn try_get_block_spends_with_conditions(
718        &self,
719        height: u32,
720    ) -> Result<Vec<CoinSpendWithConditions>, ChiaQueryError> {
721        let (peer, addr) = self.pick().await?;
722        let proto_block = self.fetch_full_block(&peer, height).await;
723        if proto_block.is_err() {
724            self.pool.eject_peer(addr).await;
725        }
726        let proto_block = proto_block?;
727        block::block_spends_with_conditions(&proto_block, self.constants())
728    }
729
730    // -- puzzle and solution (resolve height from coin state if needed) ------
731
732    pub async fn try_get_puzzle_and_solution_auto(
733        &self,
734        coin_id: &str,
735    ) -> Result<CoinSpend, ChiaQueryError> {
736        // First find the coin's spent_height via request_coin_state.
737        let (peer, addr) = self.pick().await?;
738        let id = translate::parse_bytes32(coin_id)?;
739
740        let state_resp = tokio::time::timeout(self.request_timeout, {
741            peer.request_coin_state(vec![id], None, self.genesis_challenge(), false)
742        })
743        .await
744        .map_err(|_| ChiaQueryError::PeerConnection("request timed out".into()))?
745        .map_err(|e| ChiaQueryError::PeerConnection(e.to_string()))?
746        .map_err(|_| ChiaQueryError::PeerRejection("coin state rejected".into()))?;
747
748        let cs = state_resp
749            .coin_states
750            .first()
751            .ok_or_else(|| ChiaQueryError::PeerRejection("coin not found".into()))?;
752        let spent_height = cs
753            .spent_height
754            .ok_or_else(|| ChiaQueryError::PeerRejection("coin is not spent".into()))?;
755
756        let res = self
757            .do_get_puzzle_and_solution(&peer, coin_id, spent_height)
758            .await;
759        if res.is_err() {
760            self.pool.eject_peer(addr).await;
761        }
762        res
763    }
764
765    // -- block records range ------------------------------------------------
766
767    pub async fn try_get_block_records(
768        &self,
769        start: u32,
770        end: u32,
771    ) -> Result<Vec<BlockRecord>, ChiaQueryError> {
772        let mut records = Vec::with_capacity((end - start) as usize);
773        for height in start..end {
774            records.push(self.try_get_block_record_by_height(height).await?);
775        }
776        Ok(records)
777    }
778
779    // -- blocks range -------------------------------------------------------
780
781    pub async fn try_get_blocks_range(
782        &self,
783        start: u32,
784        end: u32,
785    ) -> Result<Vec<serde_json::Value>, ChiaQueryError> {
786        let mut blocks = Vec::with_capacity((end - start) as usize);
787        for height in start..end {
788            blocks.push(self.try_get_block_by_height(height).await?);
789        }
790        Ok(blocks)
791    }
792
793    // -- network info (hardcoded from chia constants) ------------------------
794
795    pub fn network_info(&self) -> NetworkInfo {
796        let c = self.constants();
797        NetworkInfo {
798            network_name: self.network.network_id().to_string(),
799            network_prefix: match self.network {
800                NetworkType::Mainnet => "xch".to_string(),
801                NetworkType::Testnet11 => "txch".to_string(),
802            },
803            genesis_challenge: format!("0x{}", hex::encode(c.genesis_challenge)),
804        }
805    }
806
807    // -- aggsig additional data (from consensus constants) -------------------
808
809    pub fn aggsig_additional_data(&self) -> String {
810        format!(
811            "0x{}",
812            hex::encode(self.constants().agg_sig_me_additional_data)
813        )
814    }
815
816    // -- peak height (from tracked NewPeakWallet messages) ------------------
817
818    pub fn peak_height(&self) -> u32 {
819        self.pool.peak_height()
820    }
821
822    // =======================================================================
823    // Internal implementation helpers
824    // =======================================================================
825
826    async fn do_get_coin_record_by_name(
827        &self,
828        peer: &Peer,
829        name: &str,
830    ) -> Result<CoinRecord, ChiaQueryError> {
831        let coin_id = translate::parse_bytes32(name)?;
832
833        let response = tokio::time::timeout(self.request_timeout, {
834            peer.request_coin_state(vec![coin_id], None, self.genesis_challenge(), false)
835        })
836        .await
837        .map_err(|_| ChiaQueryError::PeerConnection("request timed out".into()))?
838        .map_err(|e| ChiaQueryError::PeerConnection(e.to_string()))?
839        .map_err(|_| ChiaQueryError::PeerRejection("coin state request rejected".into()))?;
840
841        response
842            .coin_states
843            .first()
844            .map(translate::coin_state_to_record)
845            .ok_or_else(|| ChiaQueryError::PeerRejection("coin not found".into()))
846    }
847
848    /// Absence-aware coin-record read: a successful response with no coin-state is `Ok(None)`; a
849    /// rejected/timed-out request is `Err`.
850    async fn do_get_coin_record_by_name_opt(
851        &self,
852        peer: &Peer,
853        name: &str,
854    ) -> Result<Option<CoinRecord>, ChiaQueryError> {
855        let coin_id = translate::parse_bytes32(name)?;
856
857        let response = tokio::time::timeout(self.request_timeout, {
858            peer.request_coin_state(vec![coin_id], None, self.genesis_challenge(), false)
859        })
860        .await
861        .map_err(|_| ChiaQueryError::PeerConnection("request timed out".into()))?
862        .map_err(|e| ChiaQueryError::PeerConnection(e.to_string()))?
863        .map_err(|_| ChiaQueryError::PeerRejection("coin state request rejected".into()))?;
864
865        // An empty coin-state list from a SUCCESSFUL response is this peer's word that the coin
866        // does not exist. It carries no proof -- a peer a block behind, mid-reorg, pruning, or
867        // lying produces the identical bytes -- so it is reported as this ONE peer's answer and
868        // corroborated a layer up (dig_ecosystem#2456).
869        Ok(response
870            .coin_states
871            .first()
872            .map(translate::coin_state_to_record))
873    }
874
875    /// Absence-aware read of the spend that spent `coin_id`: `Ok(None)` when the coin is unknown or
876    /// unspent, `Err` when the peer read fails.
877    async fn do_get_coin_spend_opt(
878        &self,
879        peer: &Peer,
880        coin_id: &str,
881    ) -> Result<Option<CoinSpend>, ChiaQueryError> {
882        let id = translate::parse_bytes32(coin_id)?;
883
884        let state_resp = tokio::time::timeout(self.request_timeout, {
885            peer.request_coin_state(vec![id], None, self.genesis_challenge(), false)
886        })
887        .await
888        .map_err(|_| ChiaQueryError::PeerConnection("request timed out".into()))?
889        .map_err(|e| ChiaQueryError::PeerConnection(e.to_string()))?
890        .map_err(|_| ChiaQueryError::PeerRejection("coin state rejected".into()))?;
891
892        // Unknown coin or unspent coin => there is genuinely no spend => Ok(None).
893        let Some(cs) = state_resp.coin_states.first() else {
894            return Ok(None);
895        };
896        let Some(spent_height) = cs.spent_height else {
897            return Ok(None);
898        };
899
900        let spend = self
901            .do_get_puzzle_and_solution(peer, coin_id, spent_height)
902            .await?;
903
904        // Substitute the GENUINE spent coin from the coin-state lookup for the name-only placeholder
905        // that `do_get_puzzle_and_solution` builds (the peer `PuzzleSolutionResponse` omits the full
906        // coin). The singleton-lineage walk binds each fetched spend to the requested coin id
907        // (`spend.coin.coin_id() == current`, chia-query#7); a placeholder coin hashes to the wrong
908        // id and fails that binding closed, making peer-sourced lineage resolution impossible. The
909        // real coin is already in hand here, so return it and let the binding authenticate the hop.
910        let spend = CoinSpend {
911            coin: Coin::from_protocol(&cs.coin),
912            ..spend
913        };
914        Ok(Some(spend))
915    }
916
917    async fn do_puzzle_hash_query(
918        &self,
919        peer: &Peer,
920        hashes: &[&str],
921        start_height: Option<u32>,
922        end_height: Option<u32>,
923        include_spent: bool,
924        include_hinted: bool,
925    ) -> Result<Vec<CoinRecord>, ChiaQueryError> {
926        let puzzle_hashes: Vec<Bytes32> = hashes
927            .iter()
928            .map(|h| translate::parse_bytes32(h))
929            .collect::<Result<_, _>>()?;
930
931        let filters = CoinStateFilters {
932            include_spent,
933            include_unspent: true,
934            include_hinted,
935            min_amount: 0,
936        };
937
938        let mut all_states = Vec::new();
939        // The peer protocol requires the header_hash to correspond to
940        // previous_height.  We only know the genesis header hash, so we always
941        // start from the beginning and apply start_height as a client-side
942        // filter.  For callers that provide a start_height, this is slower but
943        // correct.
944        let mut prev_height: Option<u32> = None;
945        let mut prev_header = self.genesis_challenge();
946
947        loop {
948            let response = tokio::time::timeout(self.request_timeout, {
949                peer.request_puzzle_state(
950                    puzzle_hashes.clone(),
951                    prev_height,
952                    prev_header,
953                    filters.clone(),
954                    false,
955                )
956            })
957            .await
958            .map_err(|_| ChiaQueryError::PeerConnection("request timed out".into()))?
959            .map_err(|e| ChiaQueryError::PeerConnection(e.to_string()))?
960            .map_err(|_| ChiaQueryError::PeerRejection("puzzle state request rejected".into()))?;
961
962            all_states.extend(response.coin_states.iter().cloned());
963
964            if response.is_finished {
965                break;
966            }
967            prev_height = Some(response.height);
968            prev_header = response.header_hash;
969        }
970
971        // Client-side height filters.
972        let records: Vec<CoinRecord> = all_states
973            .iter()
974            .filter(|cs| {
975                let h = cs.created_height.unwrap_or(0);
976                let above_start = start_height.is_none_or(|s| h >= s);
977                let below_end = end_height.is_none_or(|e| h <= e);
978                above_start && below_end
979            })
980            .map(translate::coin_state_to_record)
981            .collect();
982
983        Ok(records)
984    }
985
986    async fn do_coin_ids_query(
987        &self,
988        peer: &Peer,
989        names: &[String],
990    ) -> Result<Vec<CoinRecord>, ChiaQueryError> {
991        let ids: Vec<Bytes32> = names
992            .iter()
993            .map(|n| translate::parse_bytes32(n))
994            .collect::<Result<_, _>>()?;
995
996        let response = tokio::time::timeout(self.request_timeout, {
997            peer.request_coin_state(ids, None, self.genesis_challenge(), false)
998        })
999        .await
1000        .map_err(|_| ChiaQueryError::PeerConnection("request timed out".into()))?
1001        .map_err(|e| ChiaQueryError::PeerConnection(e.to_string()))?
1002        .map_err(|_| ChiaQueryError::PeerRejection("coin state request rejected".into()))?;
1003
1004        Ok(translate::coin_states_to_records(&response.coin_states))
1005    }
1006
1007    async fn do_get_puzzle_and_solution(
1008        &self,
1009        peer: &Peer,
1010        coin_id: &str,
1011        height: u32,
1012    ) -> Result<CoinSpend, ChiaQueryError> {
1013        let id = translate::parse_bytes32(coin_id)?;
1014
1015        let response = tokio::time::timeout(self.request_timeout, {
1016            peer.request_puzzle_and_solution(id, height)
1017        })
1018        .await
1019        .map_err(|_| ChiaQueryError::PeerConnection("request timed out".into()))?
1020        .map_err(|e| ChiaQueryError::PeerConnection(e.to_string()))?
1021        .map_err(|_| ChiaQueryError::PeerRejection("puzzle solution rejected".into()))?;
1022
1023        Ok(translate::make_coin_spend(
1024            // We need the coin for the CoinSpend.  The peer response
1025            // (PuzzleSolutionResponse) has coin_name but not the full coin.
1026            // We'll build a partial coin using the name as parent_coin_info
1027            // placeholder -- the puzzle_reveal and solution are the important
1028            // parts.  Callers who need the full coin can query separately.
1029            &chia_protocol::Coin {
1030                parent_coin_info: response.coin_name,
1031                puzzle_hash: Bytes32::default(),
1032                amount: 0,
1033            },
1034            &response.puzzle,
1035            &response.solution,
1036        ))
1037    }
1038
1039    async fn do_get_fee_estimate(
1040        &self,
1041        peer: &Peer,
1042        target_times: &[u64],
1043    ) -> Result<FeeEstimate, ChiaQueryError> {
1044        let request = RequestFeeEstimates {
1045            time_targets: target_times.to_vec(),
1046        };
1047
1048        let response: RespondFeeEstimates =
1049            tokio::time::timeout(self.request_timeout, peer.request_infallible(request))
1050                .await
1051                .map_err(|_| ChiaQueryError::PeerConnection("request timed out".into()))?
1052                .map_err(|e| ChiaQueryError::PeerConnection(e.to_string()))?;
1053
1054        let estimates: Vec<f64> = response
1055            .estimates
1056            .estimates
1057            .iter()
1058            .map(|e| e.estimated_fee_rate.mojos_per_clvm_cost as f64)
1059            .collect();
1060
1061        Ok(translate::make_fee_estimate(
1062            estimates,
1063            target_times.to_vec(),
1064        ))
1065    }
1066
1067    async fn do_push_tx(
1068        &self,
1069        peer: &Peer,
1070        bundle: &SpendBundle,
1071    ) -> Result<TxStatus, ChiaQueryError> {
1072        let proto = to_protocol_spend_bundle(bundle)?;
1073
1074        let ack = tokio::time::timeout(self.request_timeout, peer.send_transaction(proto))
1075            .await
1076            .map_err(|_| ChiaQueryError::PeerConnection("request timed out".into()))?
1077            .map_err(|e| ChiaQueryError::PeerConnection(e.to_string()))?;
1078
1079        Ok(translate::ack_to_tx_status(ack.status))
1080    }
1081
1082    // -- full block by height (RequestBlock / RespondBlock) -------------------
1083
1084    async fn do_get_block_by_height(
1085        &self,
1086        peer: &Peer,
1087        height: u32,
1088    ) -> Result<serde_json::Value, ChiaQueryError> {
1089        let proto_block = self.fetch_full_block(peer, height).await?;
1090        serde_json::to_value(&proto_block)
1091            .map_err(|e| ChiaQueryError::PeerConnection(format!("serialize block: {e}")))
1092    }
1093
1094    // -- additions and removals via CLVM (from chia-block-listener pattern) --
1095
1096    async fn do_get_additions_and_removals_from_block(
1097        &self,
1098        peer: &Peer,
1099        height: u32,
1100    ) -> Result<AdditionsAndRemovals, ChiaQueryError> {
1101        let proto_block = self.fetch_full_block(peer, height).await?;
1102        block::block_additions_and_removals(&proto_block, height, self.constants())
1103    }
1104
1105    // -- block spends via CLVM (puzzle_reveal + solution) --------------------
1106
1107    async fn do_get_block_spends(
1108        &self,
1109        peer: &Peer,
1110        height: u32,
1111    ) -> Result<Vec<CoinSpend>, ChiaQueryError> {
1112        let proto_block = self.fetch_full_block(peer, height).await?;
1113        block::block_spends(&proto_block, self.constants())
1114    }
1115
1116    // -- shared: fetch a FullBlock from a peer by height ---------------------
1117
1118    async fn fetch_full_block(
1119        &self,
1120        peer: &Peer,
1121        height: u32,
1122    ) -> Result<ProtoFullBlock, ChiaQueryError> {
1123        let response = tokio::time::timeout(self.request_timeout, {
1124            peer.request_fallible::<RespondBlock, RejectBlock, _>(RequestBlock {
1125                height,
1126                include_transaction_block: true,
1127            })
1128        })
1129        .await
1130        .map_err(|_| ChiaQueryError::PeerConnection("block request timed out".into()))?
1131        .map_err(|e| ChiaQueryError::PeerConnection(e.to_string()))?
1132        .map_err(|_| ChiaQueryError::PeerRejection("block request rejected".into()))?;
1133
1134        Ok(response.block)
1135    }
1136
1137    // -- block record by height (from chia-block-listener pattern) -----------
1138
1139    async fn do_get_block_record_by_height(
1140        &self,
1141        peer: &Peer,
1142        height: u32,
1143    ) -> Result<BlockRecord, ChiaQueryError> {
1144        let response = tokio::time::timeout(self.request_timeout, {
1145            peer.request_fallible::<RespondBlockHeader, RejectHeaderRequest, _>(
1146                RequestBlockHeader { height },
1147            )
1148        })
1149        .await
1150        .map_err(|_| ChiaQueryError::PeerConnection("request timed out".into()))?
1151        .map_err(|e| ChiaQueryError::PeerConnection(e.to_string()))?
1152        .map_err(|_| ChiaQueryError::PeerRejection("header request rejected".into()))?;
1153
1154        Ok(translate::header_block_to_block_record(
1155            &response.header_block,
1156        ))
1157    }
1158
1159    // -- additions and removals (from chia-block-listener pattern) -----------
1160
1161    async fn do_get_additions_and_removals(
1162        &self,
1163        peer: &Peer,
1164        height: u32,
1165        header_hash_hex: &str,
1166    ) -> Result<AdditionsAndRemovals, ChiaQueryError> {
1167        let header_hash = translate::parse_bytes32(header_hash_hex)?;
1168
1169        // Request additions and removals in parallel.
1170        let (adds_result, rems_result) = tokio::join!(
1171            tokio::time::timeout(self.request_timeout, {
1172                peer.request_fallible::<RespondAdditions, RejectAdditionsRequest, _>(
1173                    RequestAdditions {
1174                        height,
1175                        header_hash: Some(header_hash),
1176                        puzzle_hashes: None,
1177                    },
1178                )
1179            }),
1180            tokio::time::timeout(self.request_timeout, {
1181                peer.request_fallible::<RespondRemovals, RejectRemovalsRequest, _>(
1182                    RequestRemovals {
1183                        height,
1184                        header_hash,
1185                        coin_names: None,
1186                    },
1187                )
1188            }),
1189        );
1190
1191        let adds = adds_result
1192            .map_err(|_| ChiaQueryError::PeerConnection("additions request timed out".into()))?
1193            .map_err(|e| ChiaQueryError::PeerConnection(e.to_string()))?
1194            .map_err(|_| ChiaQueryError::PeerRejection("additions rejected".into()))?;
1195
1196        let rems = rems_result
1197            .map_err(|_| ChiaQueryError::PeerConnection("removals request timed out".into()))?
1198            .map_err(|e| ChiaQueryError::PeerConnection(e.to_string()))?
1199            .map_err(|_| ChiaQueryError::PeerRejection("removals rejected".into()))?;
1200
1201        Ok(translate::additions_removals_to_response(
1202            &adds, &rems, height,
1203        ))
1204    }
1205
1206    // -- children (RequestChildren is already on Peer) ----------------------
1207
1208    async fn do_get_children(
1209        &self,
1210        peer: &Peer,
1211        parent_id: &str,
1212    ) -> Result<Vec<CoinRecord>, ChiaQueryError> {
1213        let coin_name = translate::parse_bytes32(parent_id)?;
1214
1215        let response = tokio::time::timeout(self.request_timeout, peer.request_children(coin_name))
1216            .await
1217            .map_err(|_| ChiaQueryError::PeerConnection("request timed out".into()))?
1218            .map_err(|e| ChiaQueryError::PeerConnection(e.to_string()))?;
1219
1220        Ok(translate::coin_states_to_records(&response.coin_states))
1221    }
1222}
1223
1224// ---------------------------------------------------------------------------
1225// SpendBundle conversion
1226// ---------------------------------------------------------------------------
1227
1228fn to_protocol_spend_bundle(bundle: &SpendBundle) -> Result<ProtoBundle, ChiaQueryError> {
1229    let coin_spends: Vec<chia_protocol::CoinSpend> = bundle
1230        .coin_spends
1231        .iter()
1232        .map(|cs| {
1233            Ok(chia_protocol::CoinSpend {
1234                coin: chia_protocol::Coin {
1235                    parent_coin_info: translate::parse_bytes32(&cs.coin.parent_coin_info)?,
1236                    puzzle_hash: translate::parse_bytes32(&cs.coin.puzzle_hash)?,
1237                    amount: cs.coin.amount,
1238                },
1239                puzzle_reveal: chia_protocol::Program::from(chia_protocol::Bytes::from(
1240                    translate::parse_hex(&cs.puzzle_reveal)?,
1241                )),
1242                solution: chia_protocol::Program::from(chia_protocol::Bytes::from(
1243                    translate::parse_hex(&cs.solution)?,
1244                )),
1245            })
1246        })
1247        .collect::<Result<_, ChiaQueryError>>()?;
1248
1249    let sig_bytes = translate::parse_hex(&bundle.aggregated_signature)?;
1250    let sig_arr: [u8; 96] = sig_bytes
1251        .try_into()
1252        .map_err(|_| ChiaQueryError::InvalidRequest("signature must be 96 bytes".into()))?;
1253    let aggregated_signature = chia_bls::Signature::from_bytes(&sig_arr)
1254        .map_err(|e| ChiaQueryError::InvalidRequest(format!("bad BLS signature: {e}")))?;
1255
1256    Ok(ProtoBundle {
1257        coin_spends,
1258        aggregated_signature,
1259    })
1260}
1261
1262#[cfg(test)]
1263mod grading_tests {
1264    use super::*;
1265
1266    /// **A round that agrees cannot rescue a pool that was never armed.**
1267    ///
1268    /// This is the state the two conjuncts of [`corroborated`] exist to separate, and it is
1269    /// reachable in production for one reason: readiness is a snapshot taken BEFORE the round,
1270    /// while background refills can add peers during it — so more peers can agree than were held
1271    /// when the question was asked.
1272    ///
1273    /// No pool-level fixture can reach it, because a pool asks exactly the peers it counted. So it
1274    /// is asserted here, on the grading function itself. Without it, deleting the membership
1275    /// conjunct from [`corroborated`] leaves the whole suite green.
1276    #[test]
1277    fn agreement_alone_does_not_corroborate_when_the_pool_was_not_armed() {
1278        let unarmed = CorroborationReadiness::Insufficient {
1279            corroborators: 1,
1280            required: CORROBORATION_FLOOR,
1281        };
1282
1283        assert!(
1284            !corroborated(unarmed, CORROBORATION_FLOOR),
1285            "a pool that held too few independent peers has corroborated nothing, however many \
1286             voices answered"
1287        );
1288    }
1289
1290    /// The control from the other side: armed AND agreed is the only state that corroborates.
1291    ///
1292    /// Paired with the test above, this pins both conjuncts — a `corroborated` that always
1293    /// returned `false` would satisfy that one on its own.
1294    #[test]
1295    fn an_armed_pool_with_a_floor_of_agreement_corroborates() {
1296        let armed = CorroborationReadiness::Armed {
1297            corroborators: CORROBORATION_FLOOR,
1298        };
1299
1300        assert!(corroborated(armed, CORROBORATION_FLOOR));
1301        assert!(
1302            !corroborated(armed, CORROBORATION_FLOOR - 1),
1303            "an armed pool whose corroborators timed out has corroborated nothing"
1304        );
1305    }
1306}