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