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