Skip to main content

chia_query/
router.rs

1//! QueryRouter -- dispatches each request to the peer backend first (with one
2//! retry on a different peer) and falls back to the coinset.org HTTP API if
3//! both peer attempts fail.
4
5use std::collections::HashMap;
6
7use chia_consensus::consensus_constants::ConsensusConstants;
8use chia_consensus::flags::DONT_VALIDATE_SIGNATURE;
9use serde_json::Value;
10
11use crate::coinset::CoinsetClient;
12use crate::peer::{OptAnswer, PeerBackend};
13use crate::types::*;
14
15#[cfg(test)]
16mod absence_tests;
17#[cfg(test)]
18mod presence_tests;
19
20// ---------------------------------------------------------------------------
21// Puzzle condition extraction helper
22// ---------------------------------------------------------------------------
23
24/// Run a puzzle against its solution (from a CoinSpend) and extract the CLVM
25/// output conditions.  Used by `get_puzzle_and_solution_with_conditions`.
26fn run_puzzle_conditions(spend: &CoinSpend, constants: &ConsensusConstants) -> Vec<Condition> {
27    let flags = DONT_VALIDATE_SIGNATURE;
28    let Ok(puzzle_bytes) = crate::peer::translate::parse_hex(&spend.puzzle_reveal) else {
29        return Vec::new();
30    };
31    let Ok(solution_bytes) = crate::peer::translate::parse_hex(&spend.solution) else {
32        return Vec::new();
33    };
34
35    let mut allocator = chia_consensus::allocator::make_allocator(flags);
36
37    let Ok(puzzle_node) = clvmr::serde::node_from_bytes(&mut allocator, &puzzle_bytes) else {
38        return Vec::new();
39    };
40    let Ok(solution_node) = clvmr::serde::node_from_bytes(&mut allocator, &solution_bytes) else {
41        return Vec::new();
42    };
43
44    let dialect = clvmr::chia_dialect::ChiaDialect::new(flags);
45    match clvmr::run_program::run_program(
46        &mut allocator,
47        &dialect,
48        puzzle_node,
49        solution_node,
50        constants.max_block_cost_clvm,
51    ) {
52        Ok(clvmr::reduction::Reduction(_, output)) => {
53            crate::peer::block::parse_conditions_public(&allocator, output)
54        }
55        Err(_) => Vec::new(),
56    }
57}
58
59/// Decide what one peer's uncorroborated absence becomes, given whatever the coinset tier said.
60///
61/// `coinset` is `None` when the coinset tier was not consulted at all because the fallback is
62/// disabled — the distinction matters, since "nobody else was asked" and "somebody else was asked
63/// and agreed" are the two facts this whole change exists to keep apart.
64///
65/// Absence is only ever reported when a SECOND source says it too. Everything else is an error,
66/// and a contradiction is surfaced rather than broken in favour of either source: nothing in two
67/// contradictory answers says which one to believe.
68fn settle_uncorroborated_absence<T>(
69    coinset: Option<Result<Option<T>, ChiaQueryError>>,
70) -> Result<Option<T>, ChiaQueryError> {
71    match coinset {
72        None => Err(ChiaQueryError::UncorroboratedAbsence(
73            "one peer reported absence, no second peer was available, and the coinset fallback is \
74             disabled"
75                .into(),
76        )),
77        Some(Ok(None)) => Ok(None),
78        Some(Ok(Some(_))) => Err(ChiaQueryError::SourcesDisagree(
79            "a peer reports absent, the coinset API reports present".into(),
80        )),
81        Some(Err(e)) => Err(ChiaQueryError::UncorroboratedAbsence(format!(
82            "one peer reported absence and the coinset API could not corroborate it: {e}"
83        ))),
84    }
85}
86
87/// What a positive answer that only ONE source will vouch for becomes.
88///
89/// The mirror of [`settle_uncorroborated_absence`], and the more dangerous of the two: an absence
90/// nobody can confirm leaves a caller polling, while a PRESENCE nobody can confirm makes it stop
91/// and record a height. `coinset` is `Some` only when the fallback tier was consulted — `None`
92/// means it is disabled, and a fact nobody could be brought to agree with is never returned as one
93/// (dig_ecosystem#2462).
94fn settle_uncorroborated_presence<T: ChainClaim>(
95    found: T,
96    coinset: Option<Result<Option<T>, ChiaQueryError>>,
97) -> Result<Option<T>, ChiaQueryError> {
98    match coinset {
99        None => Err(ChiaQueryError::UncorroboratedPresence(
100            "one peer produced a record, no second peer was available, and the coinset fallback \
101             is disabled"
102                .into(),
103        )),
104        Some(Ok(Some(other))) if other.chain_claim() == found.chain_claim() => Ok(Some(found)),
105        Some(Ok(Some(other))) => Err(ChiaQueryError::SourcesDisagree(format!(
106            "a peer claims `{}`, the coinset API claims `{}`",
107            found.chain_claim(),
108            other.chain_claim()
109        ))),
110        Some(Ok(None)) => Err(ChiaQueryError::SourcesDisagree(
111            "a peer reports present, the coinset API reports absent".into(),
112        )),
113        Some(Err(e)) => Err(ChiaQueryError::UncorroboratedPresence(format!(
114            "one peer produced a record and the coinset API could not corroborate it: {e}"
115        ))),
116    }
117}
118
119pub struct QueryRouter {
120    pub(crate) peer: PeerBackend,
121    pub(crate) coinset: CoinsetClient,
122    pub(crate) coinset_fallback_enabled: bool,
123}
124
125// ---------------------------------------------------------------------------
126// Internal helpers
127// ---------------------------------------------------------------------------
128
129impl QueryRouter {
130    /// Try `peer_fn` twice (each call will select a different peer because the
131    /// first failure ejects the peer).  If both fail, fall back to `coinset_fn`.
132    async fn peer_then_coinset<T>(
133        &self,
134        peer_fn: impl std::future::Future<Output = Result<T, ChiaQueryError>>,
135        peer_retry: impl std::future::Future<Output = Result<T, ChiaQueryError>>,
136        coinset_fn: impl std::future::Future<Output = Result<T, ChiaQueryError>>,
137    ) -> Result<T, ChiaQueryError> {
138        // First peer attempt
139        match peer_fn.await {
140            Ok(v) => return Ok(v),
141            Err(e) => log::debug!("peer attempt 1 failed: {e}"),
142        }
143
144        // Retry on a different peer
145        match peer_retry.await {
146            Ok(v) => Ok(v),
147            Err(peer_err) => {
148                if !self.coinset_fallback_enabled {
149                    return Err(peer_err);
150                }
151                // Fall back to coinset
152                coinset_fn
153                    .await
154                    .map_err(|ce| ChiaQueryError::AllSourcesFailed {
155                        peer_error: Box::new(peer_err),
156                        coinset_error: Some(Box::new(ce)),
157                    })
158            }
159        }
160    }
161
162    /// Absence-aware variant of [`peer_then_coinset`](Self::peer_then_coinset).
163    ///
164    /// `Ok(None)` from this router means **corroborated absence**: two independent sources were
165    /// asked and both said the thing does not exist. Absence that only one source will vouch for
166    /// is [`UncorroboratedAbsence`](ChiaQueryError::UncorroboratedAbsence) — an error, because a
167    /// caller reading `None` as "the chain provably does not have this" would otherwise be told a
168    /// falsehood by one anonymous peer's empty list (dig_ecosystem#2456).
169    ///
170    /// The peer tier grades its own answer (see
171    /// [`PeerBackend::read_opt_corroborated`](crate::peer::PeerBackend)); this method decides what
172    /// an ungraded absence becomes once the coinset tier is available to be a second voice:
173    ///
174    /// | peer tier | coinset | result |
175    /// |---|---|---|
176    /// | found | not asked | `Ok(Some)` — presence is self-verifying |
177    /// | both peers absent | not asked | `Ok(None)` |
178    /// | one peer absent | absent | `Ok(None)` — two independent sources agree |
179    /// | one peer absent | found | [`SourcesDisagree`](ChiaQueryError::SourcesDisagree) |
180    /// | one peer absent | unreachable or disabled | [`UncorroboratedAbsence`](ChiaQueryError::UncorroboratedAbsence) |
181    /// | peer read failed | any | the retry, then the coinset fallback, as before |
182    ///
183    /// **The stated limit.** When no peer answers at all, the coinset tier is the only source
184    /// there is, and its absence is returned as `Ok(None)` on its own — the behaviour a
185    /// coinset-only client has always had. That is one source, so it is one source's word; what
186    /// this method removes is absence resting on an *anonymous, unauthenticated* peer, not the
187    /// weaker claim that a single named HTTPS endpoint is infallible.
188    async fn peer_then_coinset_opt<T: ChainClaim>(
189        &self,
190        peer_fn: impl std::future::Future<Output = Result<OptAnswer<T>, ChiaQueryError>>,
191        peer_retry: impl std::future::Future<Output = Result<OptAnswer<T>, ChiaQueryError>>,
192        coinset_fn: impl std::future::Future<Output = Result<Option<T>, ChiaQueryError>>,
193    ) -> Result<Option<T>, ChiaQueryError> {
194        let first = match peer_fn.await {
195            Ok(answer) => Some(answer),
196            Err(e) => {
197                log::debug!("peer opt attempt 1 failed: {e}");
198                None
199            }
200        };
201
202        if let Some(answer) = first {
203            return self.settle_peer_answer(answer, coinset_fn).await;
204        }
205
206        match peer_retry.await {
207            Ok(answer) => self.settle_peer_answer(answer, coinset_fn).await,
208            Err(peer_err) => {
209                if !self.coinset_fallback_enabled {
210                    return Err(peer_err);
211                }
212                coinset_fn
213                    .await
214                    .map_err(|ce| ChiaQueryError::AllSourcesFailed {
215                        peer_error: Box::new(peer_err),
216                        coinset_error: Some(Box::new(ce)),
217                    })
218            }
219        }
220    }
221
222    /// Turn the peer tier's graded answer into the router's contract, consulting coinset as a
223    /// second voice when — and only when — the peer tier could not find one itself.
224    async fn settle_peer_answer<T: ChainClaim>(
225        &self,
226        answer: OptAnswer<T>,
227        coinset_fn: impl std::future::Future<Output = Result<Option<T>, ChiaQueryError>>,
228    ) -> Result<Option<T>, ChiaQueryError> {
229        match answer {
230            OptAnswer::Found(v) => Ok(Some(v)),
231            OptAnswer::CorroboratedAbsent => Ok(None),
232            OptAnswer::UncorroboratedFound(v) => {
233                let coinset = if self.coinset_fallback_enabled {
234                    Some(coinset_fn.await)
235                } else {
236                    None
237                };
238                settle_uncorroborated_presence(v, coinset)
239            }
240            OptAnswer::UncorroboratedAbsent => {
241                let coinset = if self.coinset_fallback_enabled {
242                    Some(coinset_fn.await)
243                } else {
244                    None
245                };
246                settle_uncorroborated_absence(coinset)
247            }
248        }
249    }
250
251    /// For endpoints that have no peer protocol equivalent.
252    fn require_coinset(&self, endpoint: &str) -> Result<(), ChiaQueryError> {
253        if !self.coinset_fallback_enabled {
254            Err(ChiaQueryError::UnsupportedWithoutCoinset(endpoint.into()))
255        } else {
256            Ok(())
257        }
258    }
259}
260
261// ---------------------------------------------------------------------------
262// Blocks (all coinset-only)
263// ---------------------------------------------------------------------------
264
265impl QueryRouter {
266    /// Peer-backed: fetches the full block by header_hash (via coinset to
267    /// resolve the height), then parses additions/removals from the CLVM
268    /// generator.  Falls back to the coinset endpoint on failure.
269    pub async fn get_additions_and_removals(
270        &self,
271        header_hash: &str,
272    ) -> Result<AdditionsAndRemovals, ChiaQueryError> {
273        // The peer protocol needs a height.  Resolve it from the block record.
274        if let Ok(record) = self.get_block_record(header_hash).await {
275            // Try parsing via peer + CLVM.
276            match self
277                .peer
278                .try_get_additions_and_removals_from_block(record.height)
279                .await
280            {
281                Ok(r) => return Ok(r),
282                Err(e) => log::debug!("peer additions_and_removals failed: {e}"),
283            }
284        }
285        // Fallback to coinset.
286        if self.coinset_fallback_enabled {
287            self.coinset.get_additions_and_removals(header_hash).await
288        } else {
289            Err(ChiaQueryError::UnsupportedWithoutCoinset(
290                "get_additions_and_removals".into(),
291            ))
292        }
293    }
294
295    pub async fn get_block(&self, header_hash: &str) -> Result<FullBlock, ChiaQueryError> {
296        // Resolve height from block record, try peer first.
297        if let Ok(record) = self.get_block_record(header_hash).await {
298            match self.peer.try_get_block_by_height(record.height).await {
299                Ok(b) => return Ok(b),
300                Err(e) => log::debug!("peer get_block failed: {e}"),
301            }
302        }
303        if self.coinset_fallback_enabled {
304            self.coinset.get_block(header_hash).await
305        } else {
306            Err(ChiaQueryError::UnsupportedWithoutCoinset(
307                "get_block".into(),
308            ))
309        }
310    }
311
312    /// Fetch a full block by height.  Peer-backed via `RequestBlock`.
313    pub async fn get_block_by_height(&self, height: u32) -> Result<FullBlock, ChiaQueryError> {
314        self.peer_then_coinset(
315            self.peer.try_get_block_by_height(height),
316            self.peer.try_get_block_by_height(height),
317            async {
318                // Coinset has no direct by-height endpoint for full blocks, so
319                // resolve the header_hash first.
320                let record = self.coinset.get_block_record_by_height(height).await?;
321                self.coinset.get_block(&record.header_hash).await
322            },
323        )
324        .await
325    }
326
327    pub async fn get_block_count_metrics(&self) -> Result<BlockCountMetrics, ChiaQueryError> {
328        self.require_coinset("get_block_count_metrics")?;
329        self.coinset.get_block_count_metrics().await
330    }
331
332    pub async fn get_block_record(&self, header_hash: &str) -> Result<BlockRecord, ChiaQueryError> {
333        self.require_coinset("get_block_record")?;
334        self.coinset.get_block_record(header_hash).await
335    }
336
337    /// Peer-backed via `RequestBlockHeader` / `RespondBlockHeader` (pattern
338    /// from chia-block-listener).
339    pub async fn get_block_record_by_height(
340        &self,
341        height: u32,
342    ) -> Result<BlockRecord, ChiaQueryError> {
343        self.peer_then_coinset(
344            self.peer.try_get_block_record_by_height(height),
345            self.peer.try_get_block_record_by_height(height),
346            self.coinset.get_block_record_by_height(height),
347        )
348        .await
349    }
350
351    pub async fn get_block_records(
352        &self,
353        start: u32,
354        end: u32,
355    ) -> Result<Vec<BlockRecord>, ChiaQueryError> {
356        self.peer_then_coinset(
357            self.peer.try_get_block_records(start, end),
358            self.peer.try_get_block_records(start, end),
359            self.coinset.get_block_records(start, end),
360        )
361        .await
362    }
363
364    /// Peer-backed: fetches the full block, then runs the CLVM generator to
365    /// extract every coin spend with its puzzle_reveal and solution.
366    pub async fn get_block_spends(
367        &self,
368        header_hash: &str,
369    ) -> Result<Vec<CoinSpend>, ChiaQueryError> {
370        // Resolve height from block record.
371        if let Ok(record) = self.get_block_record(header_hash).await {
372            match self
373                .peer
374                .try_get_block_spends_by_height(record.height)
375                .await
376            {
377                Ok(r) => return Ok(r),
378                Err(e) => log::debug!("peer block_spends failed: {e}"),
379            }
380        }
381        if self.coinset_fallback_enabled {
382            self.coinset.get_block_spends(header_hash).await
383        } else {
384            Err(ChiaQueryError::UnsupportedWithoutCoinset(
385                "get_block_spends".into(),
386            ))
387        }
388    }
389
390    /// Peer-backed: fetches full block, runs CLVM generator, then runs each
391    /// puzzle(solution) to extract parsed conditions.
392    pub async fn get_block_spends_with_conditions(
393        &self,
394        header_hash: &str,
395    ) -> Result<Vec<CoinSpendWithConditions>, ChiaQueryError> {
396        if let Ok(record) = self.get_block_record(header_hash).await {
397            match self
398                .peer
399                .try_get_block_spends_with_conditions(record.height)
400                .await
401            {
402                Ok(r) => return Ok(r),
403                Err(e) => log::debug!("peer block_spends_with_conditions failed: {e}"),
404            }
405        }
406        if self.coinset_fallback_enabled {
407            self.coinset
408                .get_block_spends_with_conditions(header_hash)
409                .await
410        } else {
411            Err(ChiaQueryError::UnsupportedWithoutCoinset(
412                "get_block_spends_with_conditions".into(),
413            ))
414        }
415    }
416
417    pub async fn get_blocks(
418        &self,
419        start: u32,
420        end: u32,
421        exclude_header_hash: bool,
422        exclude_reorged: bool,
423    ) -> Result<Vec<FullBlock>, ChiaQueryError> {
424        self.peer_then_coinset(
425            self.peer.try_get_blocks_range(start, end),
426            self.peer.try_get_blocks_range(start, end),
427            self.coinset
428                .get_blocks(start, end, exclude_header_hash, exclude_reorged),
429        )
430        .await
431    }
432
433    pub async fn get_unfinished_block_headers(
434        &self,
435    ) -> Result<Vec<UnfinishedBlockHeader>, ChiaQueryError> {
436        self.require_coinset("get_unfinished_block_headers")?;
437        self.coinset.get_unfinished_block_headers().await
438    }
439}
440
441// ---------------------------------------------------------------------------
442// Coins (peer-backed with coinset fallback)
443// ---------------------------------------------------------------------------
444
445impl QueryRouter {
446    pub async fn get_coin_record_by_name(&self, name: &str) -> Result<CoinRecord, ChiaQueryError> {
447        self.peer_then_coinset(
448            self.peer.try_get_coin_record_by_name(name),
449            self.peer.try_get_coin_record_by_name(name),
450            self.coinset.get_coin_record_by_name(name),
451        )
452        .await
453    }
454
455    /// Absence-aware [`get_coin_record_by_name`](Self::get_coin_record_by_name): a PROVABLE absence
456    /// is `Ok(None)`, every transport/rejection/parse failure is `Err`. A successful peer or coinset
457    /// response that reports no such coin is the authoritative absence; only when the peer read
458    /// itself fails does the router fall back to coinset (SPEC §3).
459    pub async fn get_coin_record_by_name_opt(
460        &self,
461        name: &str,
462    ) -> Result<Option<CoinRecord>, ChiaQueryError> {
463        self.peer_then_coinset_opt(
464            self.peer.try_get_coin_record_by_name_opt(name),
465            self.peer.try_get_coin_record_by_name_opt(name),
466            self.coinset.get_coin_record_by_name_opt(name),
467        )
468        .await
469    }
470
471    /// Absence-aware read of the spend that spent `coin_id`: `Ok(None)` when the coin is provably
472    /// unspent/unknown, `Err` when the read could not be completed.
473    pub async fn get_coin_spend_opt(
474        &self,
475        coin_id: &str,
476    ) -> Result<Option<CoinSpend>, ChiaQueryError> {
477        self.peer_then_coinset_opt(
478            self.peer.try_get_coin_spend_opt(coin_id),
479            self.peer.try_get_coin_spend_opt(coin_id),
480            self.coinset.get_puzzle_and_solution_opt(coin_id, None),
481        )
482        .await
483    }
484
485    /// The current peak height, or `Ok(None)` when no source exposes a peak; `Err` on failure.
486    pub async fn peak_height_opt(&self) -> Result<Option<u32>, ChiaQueryError> {
487        let state = self.get_blockchain_state().await?;
488        Ok(state.peak.map(|p| p.height))
489    }
490
491    /// The Unix timestamp of the block at `height`: `Ok(None)` when no such block exists or the
492    /// block carries no timestamp; `Err` on failure.
493    pub async fn block_timestamp_opt(&self, height: u32) -> Result<Option<u64>, ChiaQueryError> {
494        let record = self.get_block_record_by_height_opt(height).await?;
495        Ok(record.and_then(|r| r.timestamp))
496    }
497
498    /// Absence-aware block-record read used by [`block_timestamp_opt`](Self::block_timestamp_opt).
499    async fn get_block_record_by_height_opt(
500        &self,
501        height: u32,
502    ) -> Result<Option<BlockRecord>, ChiaQueryError> {
503        // A successful peer read is authoritative; only a peer FAILURE falls through to coinset,
504        // whose null block_record is provable absence.
505        if let Ok(record) = self.peer.try_get_block_record_by_height(height).await {
506            return Ok(Some(record));
507        }
508        match self.peer.try_get_block_record_by_height(height).await {
509            Ok(record) => Ok(Some(record)),
510            Err(peer_err) => {
511                if self.coinset_fallback_enabled {
512                    self.coinset
513                        .get_block_record_by_height_opt(height)
514                        .await
515                        .map_err(|ce| ChiaQueryError::AllSourcesFailed {
516                            peer_error: Box::new(peer_err),
517                            coinset_error: Some(Box::new(ce)),
518                        })
519                } else {
520                    Err(peer_err)
521                }
522            }
523        }
524    }
525
526    pub async fn get_coin_records_by_hint(
527        &self,
528        hint: &str,
529        start_height: Option<u32>,
530        end_height: Option<u32>,
531        include_spent_coins: bool,
532    ) -> Result<Vec<CoinRecord>, ChiaQueryError> {
533        self.peer_then_coinset(
534            self.peer.try_get_coin_records_by_hint(
535                hint,
536                start_height,
537                end_height,
538                include_spent_coins,
539            ),
540            self.peer.try_get_coin_records_by_hint(
541                hint,
542                start_height,
543                end_height,
544                include_spent_coins,
545            ),
546            self.coinset.get_coin_records_by_hint(
547                hint,
548                start_height,
549                end_height,
550                include_spent_coins,
551            ),
552        )
553        .await
554    }
555
556    pub async fn get_coin_records_by_hints(
557        &self,
558        hints: &[String],
559        start_height: Option<u32>,
560        end_height: Option<u32>,
561        include_spent_coins: bool,
562    ) -> Result<Vec<CoinRecord>, ChiaQueryError> {
563        self.peer_then_coinset(
564            self.peer.try_get_coin_records_by_hints(
565                hints,
566                start_height,
567                end_height,
568                include_spent_coins,
569            ),
570            self.peer.try_get_coin_records_by_hints(
571                hints,
572                start_height,
573                end_height,
574                include_spent_coins,
575            ),
576            self.coinset.get_coin_records_by_hints(
577                hints,
578                start_height,
579                end_height,
580                include_spent_coins,
581            ),
582        )
583        .await
584    }
585
586    pub async fn get_coin_records_by_names(
587        &self,
588        names: &[String],
589        start_height: Option<u32>,
590        end_height: Option<u32>,
591        include_spent_coins: bool,
592    ) -> Result<Vec<CoinRecord>, ChiaQueryError> {
593        self.peer_then_coinset(
594            self.peer.try_get_coin_records_by_names(names),
595            self.peer.try_get_coin_records_by_names(names),
596            self.coinset.get_coin_records_by_names(
597                names,
598                start_height,
599                end_height,
600                include_spent_coins,
601            ),
602        )
603        .await
604    }
605
606    /// Peer-backed via `RequestChildren` / `RespondChildren` which returns
607    /// child coin states for a given parent coin ID.  Falls back to coinset
608    /// for batched queries or when peers fail.
609    pub async fn get_coin_records_by_parent_ids(
610        &self,
611        parent_ids: &[String],
612        start_height: Option<u32>,
613        end_height: Option<u32>,
614        include_spent_coins: bool,
615    ) -> Result<Vec<CoinRecord>, ChiaQueryError> {
616        // Try peer: query each parent ID via RequestChildren, combine results.
617        let peer_attempt = async {
618            let mut all_records = Vec::new();
619            for parent_id in parent_ids {
620                let children = self.peer.try_get_children(parent_id).await?;
621                all_records.extend(children);
622            }
623            // Apply client-side height and spent filters.
624            all_records.retain(|r| {
625                let height_ok = match (start_height, end_height) {
626                    (Some(s), Some(e)) => {
627                        r.confirmed_block_index >= s && r.confirmed_block_index <= e
628                    }
629                    (Some(s), None) => r.confirmed_block_index >= s,
630                    (None, Some(e)) => r.confirmed_block_index <= e,
631                    (None, None) => true,
632                };
633                let spent_ok = include_spent_coins || !r.spent;
634                height_ok && spent_ok
635            });
636            Ok(all_records)
637        };
638
639        match peer_attempt.await {
640            Ok(r) => Ok(r),
641            Err(peer_err) => {
642                if self.coinset_fallback_enabled {
643                    self.coinset
644                        .get_coin_records_by_parent_ids(
645                            parent_ids,
646                            start_height,
647                            end_height,
648                            include_spent_coins,
649                        )
650                        .await
651                        .map_err(|ce| ChiaQueryError::AllSourcesFailed {
652                            peer_error: Box::new(peer_err),
653                            coinset_error: Some(Box::new(ce)),
654                        })
655                } else {
656                    Err(peer_err)
657                }
658            }
659        }
660    }
661
662    pub async fn get_coin_records_by_puzzle_hash(
663        &self,
664        puzzle_hash: &str,
665        start_height: Option<u32>,
666        end_height: Option<u32>,
667        include_spent_coins: bool,
668    ) -> Result<Vec<CoinRecord>, ChiaQueryError> {
669        self.peer_then_coinset(
670            self.peer.try_get_coin_records_by_puzzle_hash(
671                puzzle_hash,
672                start_height,
673                end_height,
674                include_spent_coins,
675            ),
676            self.peer.try_get_coin_records_by_puzzle_hash(
677                puzzle_hash,
678                start_height,
679                end_height,
680                include_spent_coins,
681            ),
682            self.coinset.get_coin_records_by_puzzle_hash(
683                puzzle_hash,
684                start_height,
685                end_height,
686                include_spent_coins,
687            ),
688        )
689        .await
690    }
691
692    pub async fn get_coin_records_by_puzzle_hashes(
693        &self,
694        puzzle_hashes: &[String],
695        start_height: Option<u32>,
696        end_height: Option<u32>,
697        include_spent_coins: bool,
698    ) -> Result<Vec<CoinRecord>, ChiaQueryError> {
699        self.peer_then_coinset(
700            self.peer.try_get_coin_records_by_puzzle_hashes(
701                puzzle_hashes,
702                start_height,
703                end_height,
704                include_spent_coins,
705            ),
706            self.peer.try_get_coin_records_by_puzzle_hashes(
707                puzzle_hashes,
708                start_height,
709                end_height,
710                include_spent_coins,
711            ),
712            self.coinset.get_coin_records_by_puzzle_hashes(
713                puzzle_hashes,
714                start_height,
715                end_height,
716                include_spent_coins,
717            ),
718        )
719        .await
720    }
721
722    /// No peer equivalent -- always coinset.
723    pub async fn get_memos_by_coin_name(&self, name: &str) -> Result<Value, ChiaQueryError> {
724        self.require_coinset("get_memos_by_coin_name")?;
725        self.coinset.get_memos_by_coin_name(name).await
726    }
727
728    pub async fn get_puzzle_and_solution(
729        &self,
730        coin_id: &str,
731        height: Option<u32>,
732    ) -> Result<CoinSpend, ChiaQueryError> {
733        if let Some(h) = height {
734            self.peer_then_coinset(
735                self.peer.try_get_puzzle_and_solution(coin_id, h),
736                self.peer.try_get_puzzle_and_solution(coin_id, h),
737                self.coinset.get_puzzle_and_solution(coin_id, height),
738            )
739            .await
740        } else {
741            // No height provided -- peer can resolve it via coin state.
742            self.peer_then_coinset(
743                self.peer.try_get_puzzle_and_solution_auto(coin_id),
744                self.peer.try_get_puzzle_and_solution_auto(coin_id),
745                self.coinset.get_puzzle_and_solution(coin_id, None),
746            )
747            .await
748        }
749    }
750
751    /// Peer-backed: get puzzle & solution, then run puzzle(solution) to extract
752    /// parsed conditions.
753    pub async fn get_puzzle_and_solution_with_conditions(
754        &self,
755        coin_id: &str,
756        height: Option<u32>,
757    ) -> Result<CoinSpendWithConditions, ChiaQueryError> {
758        // Try to get the spend via peer first.
759        let spend = match self.get_puzzle_and_solution(coin_id, height).await {
760            Ok(s) => s,
761            Err(_) => {
762                if self.coinset_fallback_enabled {
763                    return self
764                        .coinset
765                        .get_puzzle_and_solution_with_conditions(coin_id, height)
766                        .await;
767                }
768                return Err(ChiaQueryError::PeerRejection(
769                    "could not retrieve puzzle and solution".into(),
770                ));
771            }
772        };
773
774        // Run puzzle(solution) to extract conditions.
775        let conditions = run_puzzle_conditions(&spend, self.peer.constants());
776        Ok(CoinSpendWithConditions {
777            coin_spend: spend,
778            conditions,
779        })
780    }
781
782    pub async fn push_tx(&self, bundle: &SpendBundle) -> Result<TxStatus, ChiaQueryError> {
783        self.peer_then_coinset(
784            self.peer.try_push_tx(bundle),
785            self.peer.try_push_tx(bundle),
786            self.coinset.push_tx(bundle),
787        )
788        .await
789    }
790}
791
792// ---------------------------------------------------------------------------
793// Fees (peer-backed with coinset fallback)
794// ---------------------------------------------------------------------------
795
796impl QueryRouter {
797    pub async fn get_fee_estimate(
798        &self,
799        spend_bundle: Option<&SpendBundle>,
800        target_times: Option<&[u64]>,
801        spend_count: Option<u64>,
802    ) -> Result<FeeEstimate, ChiaQueryError> {
803        let times = target_times.unwrap_or(&[60, 120, 300]);
804        self.peer_then_coinset(
805            self.peer.try_get_fee_estimate(times),
806            self.peer.try_get_fee_estimate(times),
807            self.coinset
808                .get_fee_estimate(spend_bundle, target_times, spend_count),
809        )
810        .await
811    }
812}
813
814// ---------------------------------------------------------------------------
815// Full node / network (all coinset-only)
816// ---------------------------------------------------------------------------
817
818impl QueryRouter {
819    /// Peer-backed: derived from the chia consensus constants for the
820    /// configured network.
821    pub async fn get_aggsig_additional_data(&self) -> Result<String, ChiaQueryError> {
822        Ok(self.peer.aggsig_additional_data())
823    }
824
825    /// Peer-backed: derived from the chia consensus constants for the
826    /// configured network.
827    pub async fn get_network_info(&self) -> Result<NetworkInfo, ChiaQueryError> {
828        Ok(self.peer.network_info())
829    }
830
831    /// Peer-backed partially: peak height is tracked from `NewPeakWallet`
832    /// messages received from peers.  Full state comes from coinset.
833    pub async fn get_blockchain_state(&self) -> Result<BlockchainState, ChiaQueryError> {
834        // Try coinset first for full state.
835        if self.coinset_fallback_enabled {
836            if let Ok(state) = self.coinset.get_blockchain_state().await {
837                return Ok(state);
838            }
839        }
840        // Fallback: return a minimal state from the peer-tracked peak.
841        let peak = self.peer.peak_height();
842        if peak == 0 {
843            return Err(ChiaQueryError::PeerConnection(
844                "no peak observed from peers yet".into(),
845            ));
846        }
847        Ok(BlockchainState {
848            peak: Some(BlockRecord {
849                height: peak,
850                ..Default::default()
851            }),
852            sync: Some(SyncState {
853                synced: true,
854                sync_mode: false,
855                sync_progress_height: peak,
856                sync_tip_height: peak,
857            }),
858            ..Default::default()
859        })
860    }
861
862    pub async fn get_network_space(
863        &self,
864        newer_block_header_hash: &str,
865        older_block_header_hash: &str,
866    ) -> Result<u64, ChiaQueryError> {
867        self.require_coinset("get_network_space")?;
868        self.coinset
869            .get_network_space(newer_block_header_hash, older_block_header_hash)
870            .await
871    }
872}
873
874// ---------------------------------------------------------------------------
875// Mempool (all coinset-only)
876// ---------------------------------------------------------------------------
877
878impl QueryRouter {
879    pub async fn get_all_mempool_items(
880        &self,
881    ) -> Result<HashMap<String, MempoolItem>, ChiaQueryError> {
882        self.require_coinset("get_all_mempool_items")?;
883        self.coinset.get_all_mempool_items().await
884    }
885
886    pub async fn get_all_mempool_tx_ids(&self) -> Result<Vec<String>, ChiaQueryError> {
887        self.require_coinset("get_all_mempool_tx_ids")?;
888        self.coinset.get_all_mempool_tx_ids().await
889    }
890
891    pub async fn get_mempool_item_by_tx_id(
892        &self,
893        tx_id: &str,
894    ) -> Result<MempoolItem, ChiaQueryError> {
895        self.require_coinset("get_mempool_item_by_tx_id")?;
896        self.coinset.get_mempool_item_by_tx_id(tx_id).await
897    }
898
899    pub async fn get_mempool_items_by_coin_name(
900        &self,
901        coin_name: &str,
902        include_spent_coins: Option<bool>,
903    ) -> Result<Vec<MempoolItem>, ChiaQueryError> {
904        self.require_coinset("get_mempool_items_by_coin_name")?;
905        self.coinset
906            .get_mempool_items_by_coin_name(coin_name, include_spent_coins)
907            .await
908    }
909}