Skip to main content

chia_query/peer/
mod.rs

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