Skip to main content

chia_query/peer/
mod.rs

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