Skip to main content

chia_query/peer/
mod.rs

1pub mod block;
2pub mod connect;
3pub mod pool;
4pub mod translate;
5
6use std::net::SocketAddr;
7use std::time::Duration;
8
9use chia_consensus::consensus_constants::ConsensusConstants;
10use chia_protocol::{
11    Bytes32, CoinStateFilters, FullBlock as ProtoFullBlock, RejectAdditionsRequest, RejectBlock,
12    RejectHeaderRequest, RejectRemovalsRequest, RequestAdditions, RequestBlock, RequestBlockHeader,
13    RequestFeeEstimates, RequestRemovals, RespondAdditions, RespondBlock, RespondBlockHeader,
14    RespondFeeEstimates, RespondRemovals, SpendBundle as ProtoBundle,
15};
16use chia_wallet_sdk::client::Peer;
17use chia_wallet_sdk::types::{MAINNET_CONSTANTS, TESTNET11_CONSTANTS};
18use tokio_tungstenite::Connector;
19
20use crate::types::*;
21use crate::NetworkType;
22use pool::PeerPool;
23pub use pool::PeerRequirement;
24
25// ---------------------------------------------------------------------------
26// PeerBackend
27// ---------------------------------------------------------------------------
28
29pub struct PeerBackend {
30    pool: PeerPool,
31    network: NetworkType,
32    request_timeout: Duration,
33}
34
35impl PeerBackend {
36    pub async fn new(
37        network: crate::NetworkType,
38        tls: Connector,
39        max_peers: usize,
40        requirement: PeerRequirement,
41        connect_timeout: Duration,
42        request_timeout: Duration,
43    ) -> Result<Self, ChiaQueryError> {
44        let pool = PeerPool::new(network, tls, max_peers, requirement, connect_timeout).await?;
45        Ok(Self {
46            pool,
47            network,
48            request_timeout,
49        })
50    }
51
52    /// Get the consensus constants for the configured network.
53    pub fn constants(&self) -> &ConsensusConstants {
54        match self.network {
55            NetworkType::Mainnet => &MAINNET_CONSTANTS,
56            NetworkType::Testnet11 => &TESTNET11_CONSTANTS,
57        }
58    }
59
60    /// Genesis challenge for the configured network.  Used as the header_hash
61    /// when querying coin state from height 0 (required by the peer protocol
62    /// -- `Bytes32::default()` causes rejection).
63    fn genesis_challenge(&self) -> Bytes32 {
64        self.constants().genesis_challenge
65    }
66
67    pub async fn has_peers(&self) -> bool {
68        self.pool.has_peers().await
69    }
70
71    // -----------------------------------------------------------------------
72    // Select a peer (round-robin) then attempt to refill if pool is short.
73    // -----------------------------------------------------------------------
74
75    async fn pick(&self) -> Result<(Peer, SocketAddr), ChiaQueryError> {
76        // Attempt a background refill if under capacity.
77        self.pool.try_refill().await;
78
79        self.pool
80            .select_peer()
81            .await
82            .ok_or_else(|| ChiaQueryError::PeerConnection("no peers available".into()))
83    }
84
85    // =======================================================================
86    // Public try_* methods -- each selects a peer, makes the request, and
87    // ejects the peer on failure.
88    // =======================================================================
89
90    pub async fn try_get_coin_record_by_name(
91        &self,
92        name: &str,
93    ) -> Result<CoinRecord, ChiaQueryError> {
94        let (peer, addr) = self.pick().await?;
95        let res = self.do_get_coin_record_by_name(&peer, name).await;
96        if res.is_err() {
97            self.pool.eject_peer(addr).await;
98        }
99        res
100    }
101
102    /// Absence-aware sibling of [`try_get_coin_record_by_name`](Self::try_get_coin_record_by_name).
103    ///
104    /// A successful `RespondCoinState` with an EMPTY coin-state list is PROVABLE absence -> `Ok(None)`;
105    /// a rejected/timed-out request is a failure -> `Err`. This split is what lets the aggregating
106    /// provider report a genuinely-absent coin as `Ok(None)` rather than a spurious error (SPEC §3).
107    pub async fn try_get_coin_record_by_name_opt(
108        &self,
109        name: &str,
110    ) -> Result<Option<CoinRecord>, ChiaQueryError> {
111        let (peer, addr) = self.pick().await?;
112        let res = self.do_get_coin_record_by_name_opt(&peer, name).await;
113        if res.is_err() {
114            self.pool.eject_peer(addr).await;
115        }
116        res
117    }
118
119    /// Absence-aware read of the spend that spent `coin_id`.
120    ///
121    /// Returns `Ok(None)` when the coin is provably unknown (no coin-state) or unspent (no spent
122    /// height) — both genuine "there is no such spend" answers — and `Err` only when the peer read
123    /// itself fails.
124    pub async fn try_get_coin_spend_opt(
125        &self,
126        coin_id: &str,
127    ) -> Result<Option<CoinSpend>, ChiaQueryError> {
128        let (peer, addr) = self.pick().await?;
129        let res = self.do_get_coin_spend_opt(&peer, coin_id).await;
130        if res.is_err() {
131            self.pool.eject_peer(addr).await;
132        }
133        res
134    }
135
136    pub async fn try_get_coin_records_by_puzzle_hash(
137        &self,
138        puzzle_hash: &str,
139        start_height: Option<u32>,
140        end_height: Option<u32>,
141        include_spent: bool,
142    ) -> Result<Vec<CoinRecord>, ChiaQueryError> {
143        let (peer, addr) = self.pick().await?;
144        let res = self
145            .do_puzzle_hash_query(
146                &peer,
147                &[puzzle_hash],
148                start_height,
149                end_height,
150                include_spent,
151                false,
152            )
153            .await;
154        if res.is_err() {
155            self.pool.eject_peer(addr).await;
156        }
157        res
158    }
159
160    pub async fn try_get_coin_records_by_puzzle_hashes(
161        &self,
162        puzzle_hashes: &[String],
163        start_height: Option<u32>,
164        end_height: Option<u32>,
165        include_spent: bool,
166    ) -> Result<Vec<CoinRecord>, ChiaQueryError> {
167        let hashes: Vec<&str> = puzzle_hashes.iter().map(String::as_str).collect();
168        let (peer, addr) = self.pick().await?;
169        let res = self
170            .do_puzzle_hash_query(
171                &peer,
172                &hashes,
173                start_height,
174                end_height,
175                include_spent,
176                false,
177            )
178            .await;
179        if res.is_err() {
180            self.pool.eject_peer(addr).await;
181        }
182        res
183    }
184
185    pub async fn try_get_coin_records_by_hint(
186        &self,
187        hint: &str,
188        start_height: Option<u32>,
189        end_height: Option<u32>,
190        include_spent: bool,
191    ) -> Result<Vec<CoinRecord>, ChiaQueryError> {
192        let (peer, addr) = self.pick().await?;
193        let res = self
194            .do_puzzle_hash_query(
195                &peer,
196                &[hint],
197                start_height,
198                end_height,
199                include_spent,
200                true,
201            )
202            .await;
203        if res.is_err() {
204            self.pool.eject_peer(addr).await;
205        }
206        res
207    }
208
209    pub async fn try_get_coin_records_by_hints(
210        &self,
211        hints: &[String],
212        start_height: Option<u32>,
213        end_height: Option<u32>,
214        include_spent: bool,
215    ) -> Result<Vec<CoinRecord>, ChiaQueryError> {
216        let hs: Vec<&str> = hints.iter().map(String::as_str).collect();
217        let (peer, addr) = self.pick().await?;
218        let res = self
219            .do_puzzle_hash_query(&peer, &hs, start_height, end_height, include_spent, true)
220            .await;
221        if res.is_err() {
222            self.pool.eject_peer(addr).await;
223        }
224        res
225    }
226
227    pub async fn try_get_coin_records_by_names(
228        &self,
229        names: &[String],
230    ) -> Result<Vec<CoinRecord>, ChiaQueryError> {
231        let (peer, addr) = self.pick().await?;
232        let res = self.do_coin_ids_query(&peer, names).await;
233        if res.is_err() {
234            self.pool.eject_peer(addr).await;
235        }
236        res
237    }
238
239    pub async fn try_get_puzzle_and_solution(
240        &self,
241        coin_id: &str,
242        height: u32,
243    ) -> Result<CoinSpend, ChiaQueryError> {
244        let (peer, addr) = self.pick().await?;
245        let res = self
246            .do_get_puzzle_and_solution(&peer, coin_id, height)
247            .await;
248        if res.is_err() {
249            self.pool.eject_peer(addr).await;
250        }
251        res
252    }
253
254    pub async fn try_get_fee_estimate(
255        &self,
256        target_times: &[u64],
257    ) -> Result<FeeEstimate, ChiaQueryError> {
258        let (peer, addr) = self.pick().await?;
259        let res = self.do_get_fee_estimate(&peer, target_times).await;
260        if res.is_err() {
261            self.pool.eject_peer(addr).await;
262        }
263        res
264    }
265
266    pub async fn try_push_tx(&self, bundle: &SpendBundle) -> Result<TxStatus, ChiaQueryError> {
267        let (peer, addr) = self.pick().await?;
268        let res = self.do_push_tx(&peer, bundle).await;
269        if res.is_err() {
270            self.pool.eject_peer(addr).await;
271        }
272        res
273    }
274
275    // -- block record by height (RequestBlockHeader) -------------------------
276
277    pub async fn try_get_block_record_by_height(
278        &self,
279        height: u32,
280    ) -> Result<BlockRecord, ChiaQueryError> {
281        let (peer, addr) = self.pick().await?;
282        let res = self.do_get_block_record_by_height(&peer, height).await;
283        if res.is_err() {
284            self.pool.eject_peer(addr).await;
285        }
286        res
287    }
288
289    // -- additions and removals (RequestAdditions + RequestRemovals) ---------
290    // Available for callers who have both height and header_hash.  The
291    // coinset.org API only requires header_hash, so the router cannot
292    // automatically peer-back this endpoint without a height lookup first.
293
294    #[allow(dead_code)]
295    pub async fn try_get_additions_and_removals(
296        &self,
297        height: u32,
298        header_hash: &str,
299    ) -> Result<AdditionsAndRemovals, ChiaQueryError> {
300        let (peer, addr) = self.pick().await?;
301        let res = self
302            .do_get_additions_and_removals(&peer, height, header_hash)
303            .await;
304        if res.is_err() {
305            self.pool.eject_peer(addr).await;
306        }
307        res
308    }
309
310    // -- children (for parent_id queries) -----------------------------------
311
312    pub async fn try_get_children(
313        &self,
314        parent_id: &str,
315    ) -> Result<Vec<CoinRecord>, ChiaQueryError> {
316        let (peer, addr) = self.pick().await?;
317        let res = self.do_get_children(&peer, parent_id).await;
318        if res.is_err() {
319            self.pool.eject_peer(addr).await;
320        }
321        res
322    }
323
324    // -- get full block by height (RequestBlock) ------------------------------
325
326    pub async fn try_get_block_by_height(
327        &self,
328        height: u32,
329    ) -> Result<serde_json::Value, ChiaQueryError> {
330        let (peer, addr) = self.pick().await?;
331        let res = self.do_get_block_by_height(&peer, height).await;
332        if res.is_err() {
333            self.pool.eject_peer(addr).await;
334        }
335        res
336    }
337
338    // -- additions and removals from a full block (CLVM parsing) -------------
339
340    pub async fn try_get_additions_and_removals_from_block(
341        &self,
342        height: u32,
343    ) -> Result<AdditionsAndRemovals, ChiaQueryError> {
344        let (peer, addr) = self.pick().await?;
345        let res = self
346            .do_get_additions_and_removals_from_block(&peer, height)
347            .await;
348        if res.is_err() {
349            self.pool.eject_peer(addr).await;
350        }
351        res
352    }
353
354    // -- block spends with puzzle_reveal + solution (CLVM parsing) -----------
355
356    pub async fn try_get_block_spends_by_height(
357        &self,
358        height: u32,
359    ) -> Result<Vec<CoinSpend>, ChiaQueryError> {
360        let (peer, addr) = self.pick().await?;
361        let res = self.do_get_block_spends(&peer, height).await;
362        if res.is_err() {
363            self.pool.eject_peer(addr).await;
364        }
365        res
366    }
367
368    // -- block spends WITH parsed conditions --------------------------------
369
370    pub async fn try_get_block_spends_with_conditions(
371        &self,
372        height: u32,
373    ) -> Result<Vec<CoinSpendWithConditions>, ChiaQueryError> {
374        let (peer, addr) = self.pick().await?;
375        let proto_block = self.fetch_full_block(&peer, height).await;
376        if proto_block.is_err() {
377            self.pool.eject_peer(addr).await;
378        }
379        let proto_block = proto_block?;
380        block::block_spends_with_conditions(&proto_block, self.constants())
381    }
382
383    // -- puzzle and solution (resolve height from coin state if needed) ------
384
385    pub async fn try_get_puzzle_and_solution_auto(
386        &self,
387        coin_id: &str,
388    ) -> Result<CoinSpend, ChiaQueryError> {
389        // First find the coin's spent_height via request_coin_state.
390        let (peer, addr) = self.pick().await?;
391        let id = translate::parse_bytes32(coin_id)?;
392
393        let state_resp = tokio::time::timeout(self.request_timeout, {
394            peer.request_coin_state(vec![id], None, self.genesis_challenge(), false)
395        })
396        .await
397        .map_err(|_| ChiaQueryError::PeerConnection("request timed out".into()))?
398        .map_err(|e| ChiaQueryError::PeerConnection(e.to_string()))?
399        .map_err(|_| ChiaQueryError::PeerRejection("coin state rejected".into()))?;
400
401        let cs = state_resp
402            .coin_states
403            .first()
404            .ok_or_else(|| ChiaQueryError::PeerRejection("coin not found".into()))?;
405        let spent_height = cs
406            .spent_height
407            .ok_or_else(|| ChiaQueryError::PeerRejection("coin is not spent".into()))?;
408
409        let res = self
410            .do_get_puzzle_and_solution(&peer, coin_id, spent_height)
411            .await;
412        if res.is_err() {
413            self.pool.eject_peer(addr).await;
414        }
415        res
416    }
417
418    // -- block records range ------------------------------------------------
419
420    pub async fn try_get_block_records(
421        &self,
422        start: u32,
423        end: u32,
424    ) -> Result<Vec<BlockRecord>, ChiaQueryError> {
425        let mut records = Vec::with_capacity((end - start) as usize);
426        for height in start..end {
427            records.push(self.try_get_block_record_by_height(height).await?);
428        }
429        Ok(records)
430    }
431
432    // -- blocks range -------------------------------------------------------
433
434    pub async fn try_get_blocks_range(
435        &self,
436        start: u32,
437        end: u32,
438    ) -> Result<Vec<serde_json::Value>, ChiaQueryError> {
439        let mut blocks = Vec::with_capacity((end - start) as usize);
440        for height in start..end {
441            blocks.push(self.try_get_block_by_height(height).await?);
442        }
443        Ok(blocks)
444    }
445
446    // -- network info (hardcoded from chia constants) ------------------------
447
448    pub fn network_info(&self) -> NetworkInfo {
449        let c = self.constants();
450        NetworkInfo {
451            network_name: self.network.network_id().to_string(),
452            network_prefix: match self.network {
453                NetworkType::Mainnet => "xch".to_string(),
454                NetworkType::Testnet11 => "txch".to_string(),
455            },
456            genesis_challenge: format!("0x{}", hex::encode(c.genesis_challenge)),
457        }
458    }
459
460    // -- aggsig additional data (from consensus constants) -------------------
461
462    pub fn aggsig_additional_data(&self) -> String {
463        format!(
464            "0x{}",
465            hex::encode(self.constants().agg_sig_me_additional_data)
466        )
467    }
468
469    // -- peak height (from tracked NewPeakWallet messages) ------------------
470
471    pub fn peak_height(&self) -> u32 {
472        self.pool.peak_height()
473    }
474
475    // =======================================================================
476    // Internal implementation helpers
477    // =======================================================================
478
479    async fn do_get_coin_record_by_name(
480        &self,
481        peer: &Peer,
482        name: &str,
483    ) -> Result<CoinRecord, ChiaQueryError> {
484        let coin_id = translate::parse_bytes32(name)?;
485
486        let response = tokio::time::timeout(self.request_timeout, {
487            peer.request_coin_state(vec![coin_id], None, self.genesis_challenge(), false)
488        })
489        .await
490        .map_err(|_| ChiaQueryError::PeerConnection("request timed out".into()))?
491        .map_err(|e| ChiaQueryError::PeerConnection(e.to_string()))?
492        .map_err(|_| ChiaQueryError::PeerRejection("coin state request rejected".into()))?;
493
494        response
495            .coin_states
496            .first()
497            .map(translate::coin_state_to_record)
498            .ok_or_else(|| ChiaQueryError::PeerRejection("coin not found".into()))
499    }
500
501    /// Absence-aware coin-record read: a successful response with no coin-state is `Ok(None)`; a
502    /// rejected/timed-out request is `Err`.
503    async fn do_get_coin_record_by_name_opt(
504        &self,
505        peer: &Peer,
506        name: &str,
507    ) -> Result<Option<CoinRecord>, ChiaQueryError> {
508        let coin_id = translate::parse_bytes32(name)?;
509
510        let response = tokio::time::timeout(self.request_timeout, {
511            peer.request_coin_state(vec![coin_id], None, self.genesis_challenge(), false)
512        })
513        .await
514        .map_err(|_| ChiaQueryError::PeerConnection("request timed out".into()))?
515        .map_err(|e| ChiaQueryError::PeerConnection(e.to_string()))?
516        .map_err(|_| ChiaQueryError::PeerRejection("coin state request rejected".into()))?;
517
518        // An empty coin-state list from a SUCCESSFUL response is provable absence.
519        Ok(response
520            .coin_states
521            .first()
522            .map(translate::coin_state_to_record))
523    }
524
525    /// Absence-aware read of the spend that spent `coin_id`: `Ok(None)` when the coin is unknown or
526    /// unspent, `Err` when the peer read fails.
527    async fn do_get_coin_spend_opt(
528        &self,
529        peer: &Peer,
530        coin_id: &str,
531    ) -> Result<Option<CoinSpend>, ChiaQueryError> {
532        let id = translate::parse_bytes32(coin_id)?;
533
534        let state_resp = tokio::time::timeout(self.request_timeout, {
535            peer.request_coin_state(vec![id], None, self.genesis_challenge(), false)
536        })
537        .await
538        .map_err(|_| ChiaQueryError::PeerConnection("request timed out".into()))?
539        .map_err(|e| ChiaQueryError::PeerConnection(e.to_string()))?
540        .map_err(|_| ChiaQueryError::PeerRejection("coin state rejected".into()))?;
541
542        // Unknown coin or unspent coin => there is genuinely no spend => Ok(None).
543        let Some(cs) = state_resp.coin_states.first() else {
544            return Ok(None);
545        };
546        let Some(spent_height) = cs.spent_height else {
547            return Ok(None);
548        };
549
550        let spend = self
551            .do_get_puzzle_and_solution(peer, coin_id, spent_height)
552            .await?;
553
554        // Substitute the GENUINE spent coin from the coin-state lookup for the name-only placeholder
555        // that `do_get_puzzle_and_solution` builds (the peer `PuzzleSolutionResponse` omits the full
556        // coin). The singleton-lineage walk binds each fetched spend to the requested coin id
557        // (`spend.coin.coin_id() == current`, chia-query#7); a placeholder coin hashes to the wrong
558        // id and fails that binding closed, making peer-sourced lineage resolution impossible. The
559        // real coin is already in hand here, so return it and let the binding authenticate the hop.
560        let spend = CoinSpend {
561            coin: Coin::from_protocol(&cs.coin),
562            ..spend
563        };
564        Ok(Some(spend))
565    }
566
567    async fn do_puzzle_hash_query(
568        &self,
569        peer: &Peer,
570        hashes: &[&str],
571        start_height: Option<u32>,
572        end_height: Option<u32>,
573        include_spent: bool,
574        include_hinted: bool,
575    ) -> Result<Vec<CoinRecord>, ChiaQueryError> {
576        let puzzle_hashes: Vec<Bytes32> = hashes
577            .iter()
578            .map(|h| translate::parse_bytes32(h))
579            .collect::<Result<_, _>>()?;
580
581        let filters = CoinStateFilters {
582            include_spent,
583            include_unspent: true,
584            include_hinted,
585            min_amount: 0,
586        };
587
588        let mut all_states = Vec::new();
589        // The peer protocol requires the header_hash to correspond to
590        // previous_height.  We only know the genesis header hash, so we always
591        // start from the beginning and apply start_height as a client-side
592        // filter.  For callers that provide a start_height, this is slower but
593        // correct.
594        let mut prev_height: Option<u32> = None;
595        let mut prev_header = self.genesis_challenge();
596
597        loop {
598            let response = tokio::time::timeout(self.request_timeout, {
599                peer.request_puzzle_state(
600                    puzzle_hashes.clone(),
601                    prev_height,
602                    prev_header,
603                    filters.clone(),
604                    false,
605                )
606            })
607            .await
608            .map_err(|_| ChiaQueryError::PeerConnection("request timed out".into()))?
609            .map_err(|e| ChiaQueryError::PeerConnection(e.to_string()))?
610            .map_err(|_| ChiaQueryError::PeerRejection("puzzle state request rejected".into()))?;
611
612            all_states.extend(response.coin_states.iter().cloned());
613
614            if response.is_finished {
615                break;
616            }
617            prev_height = Some(response.height);
618            prev_header = response.header_hash;
619        }
620
621        // Client-side height filters.
622        let records: Vec<CoinRecord> = all_states
623            .iter()
624            .filter(|cs| {
625                let h = cs.created_height.unwrap_or(0);
626                let above_start = start_height.is_none_or(|s| h >= s);
627                let below_end = end_height.is_none_or(|e| h <= e);
628                above_start && below_end
629            })
630            .map(translate::coin_state_to_record)
631            .collect();
632
633        Ok(records)
634    }
635
636    async fn do_coin_ids_query(
637        &self,
638        peer: &Peer,
639        names: &[String],
640    ) -> Result<Vec<CoinRecord>, ChiaQueryError> {
641        let ids: Vec<Bytes32> = names
642            .iter()
643            .map(|n| translate::parse_bytes32(n))
644            .collect::<Result<_, _>>()?;
645
646        let response = tokio::time::timeout(self.request_timeout, {
647            peer.request_coin_state(ids, None, self.genesis_challenge(), false)
648        })
649        .await
650        .map_err(|_| ChiaQueryError::PeerConnection("request timed out".into()))?
651        .map_err(|e| ChiaQueryError::PeerConnection(e.to_string()))?
652        .map_err(|_| ChiaQueryError::PeerRejection("coin state request rejected".into()))?;
653
654        Ok(translate::coin_states_to_records(&response.coin_states))
655    }
656
657    async fn do_get_puzzle_and_solution(
658        &self,
659        peer: &Peer,
660        coin_id: &str,
661        height: u32,
662    ) -> Result<CoinSpend, ChiaQueryError> {
663        let id = translate::parse_bytes32(coin_id)?;
664
665        let response = tokio::time::timeout(self.request_timeout, {
666            peer.request_puzzle_and_solution(id, height)
667        })
668        .await
669        .map_err(|_| ChiaQueryError::PeerConnection("request timed out".into()))?
670        .map_err(|e| ChiaQueryError::PeerConnection(e.to_string()))?
671        .map_err(|_| ChiaQueryError::PeerRejection("puzzle solution rejected".into()))?;
672
673        Ok(translate::make_coin_spend(
674            // We need the coin for the CoinSpend.  The peer response
675            // (PuzzleSolutionResponse) has coin_name but not the full coin.
676            // We'll build a partial coin using the name as parent_coin_info
677            // placeholder -- the puzzle_reveal and solution are the important
678            // parts.  Callers who need the full coin can query separately.
679            &chia_protocol::Coin {
680                parent_coin_info: response.coin_name,
681                puzzle_hash: Bytes32::default(),
682                amount: 0,
683            },
684            &response.puzzle,
685            &response.solution,
686        ))
687    }
688
689    async fn do_get_fee_estimate(
690        &self,
691        peer: &Peer,
692        target_times: &[u64],
693    ) -> Result<FeeEstimate, ChiaQueryError> {
694        let request = RequestFeeEstimates {
695            time_targets: target_times.to_vec(),
696        };
697
698        let response: RespondFeeEstimates =
699            tokio::time::timeout(self.request_timeout, peer.request_infallible(request))
700                .await
701                .map_err(|_| ChiaQueryError::PeerConnection("request timed out".into()))?
702                .map_err(|e| ChiaQueryError::PeerConnection(e.to_string()))?;
703
704        let estimates: Vec<f64> = response
705            .estimates
706            .estimates
707            .iter()
708            .map(|e| e.estimated_fee_rate.mojos_per_clvm_cost as f64)
709            .collect();
710
711        Ok(translate::make_fee_estimate(
712            estimates,
713            target_times.to_vec(),
714        ))
715    }
716
717    async fn do_push_tx(
718        &self,
719        peer: &Peer,
720        bundle: &SpendBundle,
721    ) -> Result<TxStatus, ChiaQueryError> {
722        let proto = to_protocol_spend_bundle(bundle)?;
723
724        let ack = tokio::time::timeout(self.request_timeout, peer.send_transaction(proto))
725            .await
726            .map_err(|_| ChiaQueryError::PeerConnection("request timed out".into()))?
727            .map_err(|e| ChiaQueryError::PeerConnection(e.to_string()))?;
728
729        Ok(translate::ack_to_tx_status(ack.status))
730    }
731
732    // -- full block by height (RequestBlock / RespondBlock) -------------------
733
734    async fn do_get_block_by_height(
735        &self,
736        peer: &Peer,
737        height: u32,
738    ) -> Result<serde_json::Value, ChiaQueryError> {
739        let proto_block = self.fetch_full_block(peer, height).await?;
740        serde_json::to_value(&proto_block)
741            .map_err(|e| ChiaQueryError::PeerConnection(format!("serialize block: {e}")))
742    }
743
744    // -- additions and removals via CLVM (from chia-block-listener pattern) --
745
746    async fn do_get_additions_and_removals_from_block(
747        &self,
748        peer: &Peer,
749        height: u32,
750    ) -> Result<AdditionsAndRemovals, ChiaQueryError> {
751        let proto_block = self.fetch_full_block(peer, height).await?;
752        block::block_additions_and_removals(&proto_block, height, self.constants())
753    }
754
755    // -- block spends via CLVM (puzzle_reveal + solution) --------------------
756
757    async fn do_get_block_spends(
758        &self,
759        peer: &Peer,
760        height: u32,
761    ) -> Result<Vec<CoinSpend>, ChiaQueryError> {
762        let proto_block = self.fetch_full_block(peer, height).await?;
763        block::block_spends(&proto_block, self.constants())
764    }
765
766    // -- shared: fetch a FullBlock from a peer by height ---------------------
767
768    async fn fetch_full_block(
769        &self,
770        peer: &Peer,
771        height: u32,
772    ) -> Result<ProtoFullBlock, ChiaQueryError> {
773        let response = tokio::time::timeout(self.request_timeout, {
774            peer.request_fallible::<RespondBlock, RejectBlock, _>(RequestBlock {
775                height,
776                include_transaction_block: true,
777            })
778        })
779        .await
780        .map_err(|_| ChiaQueryError::PeerConnection("block request timed out".into()))?
781        .map_err(|e| ChiaQueryError::PeerConnection(e.to_string()))?
782        .map_err(|_| ChiaQueryError::PeerRejection("block request rejected".into()))?;
783
784        Ok(response.block)
785    }
786
787    // -- block record by height (from chia-block-listener pattern) -----------
788
789    async fn do_get_block_record_by_height(
790        &self,
791        peer: &Peer,
792        height: u32,
793    ) -> Result<BlockRecord, ChiaQueryError> {
794        let response = tokio::time::timeout(self.request_timeout, {
795            peer.request_fallible::<RespondBlockHeader, RejectHeaderRequest, _>(
796                RequestBlockHeader { height },
797            )
798        })
799        .await
800        .map_err(|_| ChiaQueryError::PeerConnection("request timed out".into()))?
801        .map_err(|e| ChiaQueryError::PeerConnection(e.to_string()))?
802        .map_err(|_| ChiaQueryError::PeerRejection("header request rejected".into()))?;
803
804        Ok(translate::header_block_to_block_record(
805            &response.header_block,
806        ))
807    }
808
809    // -- additions and removals (from chia-block-listener pattern) -----------
810
811    async fn do_get_additions_and_removals(
812        &self,
813        peer: &Peer,
814        height: u32,
815        header_hash_hex: &str,
816    ) -> Result<AdditionsAndRemovals, ChiaQueryError> {
817        let header_hash = translate::parse_bytes32(header_hash_hex)?;
818
819        // Request additions and removals in parallel.
820        let (adds_result, rems_result) = tokio::join!(
821            tokio::time::timeout(self.request_timeout, {
822                peer.request_fallible::<RespondAdditions, RejectAdditionsRequest, _>(
823                    RequestAdditions {
824                        height,
825                        header_hash: Some(header_hash),
826                        puzzle_hashes: None,
827                    },
828                )
829            }),
830            tokio::time::timeout(self.request_timeout, {
831                peer.request_fallible::<RespondRemovals, RejectRemovalsRequest, _>(
832                    RequestRemovals {
833                        height,
834                        header_hash,
835                        coin_names: None,
836                    },
837                )
838            }),
839        );
840
841        let adds = adds_result
842            .map_err(|_| ChiaQueryError::PeerConnection("additions request timed out".into()))?
843            .map_err(|e| ChiaQueryError::PeerConnection(e.to_string()))?
844            .map_err(|_| ChiaQueryError::PeerRejection("additions rejected".into()))?;
845
846        let rems = rems_result
847            .map_err(|_| ChiaQueryError::PeerConnection("removals request timed out".into()))?
848            .map_err(|e| ChiaQueryError::PeerConnection(e.to_string()))?
849            .map_err(|_| ChiaQueryError::PeerRejection("removals rejected".into()))?;
850
851        Ok(translate::additions_removals_to_response(
852            &adds, &rems, height,
853        ))
854    }
855
856    // -- children (RequestChildren is already on Peer) ----------------------
857
858    async fn do_get_children(
859        &self,
860        peer: &Peer,
861        parent_id: &str,
862    ) -> Result<Vec<CoinRecord>, ChiaQueryError> {
863        let coin_name = translate::parse_bytes32(parent_id)?;
864
865        let response = tokio::time::timeout(self.request_timeout, peer.request_children(coin_name))
866            .await
867            .map_err(|_| ChiaQueryError::PeerConnection("request timed out".into()))?
868            .map_err(|e| ChiaQueryError::PeerConnection(e.to_string()))?;
869
870        Ok(translate::coin_states_to_records(&response.coin_states))
871    }
872}
873
874// ---------------------------------------------------------------------------
875// SpendBundle conversion
876// ---------------------------------------------------------------------------
877
878fn to_protocol_spend_bundle(bundle: &SpendBundle) -> Result<ProtoBundle, ChiaQueryError> {
879    let coin_spends: Vec<chia_protocol::CoinSpend> = bundle
880        .coin_spends
881        .iter()
882        .map(|cs| {
883            Ok(chia_protocol::CoinSpend {
884                coin: chia_protocol::Coin {
885                    parent_coin_info: translate::parse_bytes32(&cs.coin.parent_coin_info)?,
886                    puzzle_hash: translate::parse_bytes32(&cs.coin.puzzle_hash)?,
887                    amount: cs.coin.amount,
888                },
889                puzzle_reveal: chia_protocol::Program::from(chia_protocol::Bytes::from(
890                    translate::parse_hex(&cs.puzzle_reveal)?,
891                )),
892                solution: chia_protocol::Program::from(chia_protocol::Bytes::from(
893                    translate::parse_hex(&cs.solution)?,
894                )),
895            })
896        })
897        .collect::<Result<_, ChiaQueryError>>()?;
898
899    let sig_bytes = translate::parse_hex(&bundle.aggregated_signature)?;
900    let sig_arr: [u8; 96] = sig_bytes
901        .try_into()
902        .map_err(|_| ChiaQueryError::InvalidRequest("signature must be 96 bytes".into()))?;
903    let aggregated_signature = chia_bls::Signature::from_bytes(&sig_arr)
904        .map_err(|e| ChiaQueryError::InvalidRequest(format!("bad BLS signature: {e}")))?;
905
906    Ok(ProtoBundle {
907        coin_spends,
908        aggregated_signature,
909    })
910}