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