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