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        // NOT corroborated, and deliberately narrower than the rest of this crate: a successful
504        // peer read is taken on ONE peer's word here, and only a peer FAILURE falls through to
505        // coinset, whose null block_record is provable absence. Corroboration currently covers the
506        // two absence-aware coin reads only (`read_opt_corroborated`); this endpoint, the
507        // puzzle-hash / hint / names reads and `try_get_puzzle_and_solution` are still
508        // single-peer and UNGRADED (dig_ecosystem#2761).
509        if let Ok(record) = self.peer.try_get_block_record_by_height(height).await {
510            return Ok(Some(record));
511        }
512        match self.peer.try_get_block_record_by_height(height).await {
513            Ok(record) => Ok(Some(record)),
514            Err(peer_err) => {
515                if self.coinset_fallback_enabled {
516                    self.coinset
517                        .get_block_record_by_height_opt(height)
518                        .await
519                        .map_err(|ce| ChiaQueryError::AllSourcesFailed {
520                            peer_error: Box::new(peer_err),
521                            coinset_error: Some(Box::new(ce)),
522                        })
523                } else {
524                    Err(peer_err)
525                }
526            }
527        }
528    }
529
530    pub async fn get_coin_records_by_hint(
531        &self,
532        hint: &str,
533        start_height: Option<u32>,
534        end_height: Option<u32>,
535        include_spent_coins: bool,
536    ) -> Result<Vec<CoinRecord>, ChiaQueryError> {
537        self.peer_then_coinset(
538            self.peer.try_get_coin_records_by_hint(
539                hint,
540                start_height,
541                end_height,
542                include_spent_coins,
543            ),
544            self.peer.try_get_coin_records_by_hint(
545                hint,
546                start_height,
547                end_height,
548                include_spent_coins,
549            ),
550            self.coinset.get_coin_records_by_hint(
551                hint,
552                start_height,
553                end_height,
554                include_spent_coins,
555            ),
556        )
557        .await
558    }
559
560    pub async fn get_coin_records_by_hints(
561        &self,
562        hints: &[String],
563        start_height: Option<u32>,
564        end_height: Option<u32>,
565        include_spent_coins: bool,
566    ) -> Result<Vec<CoinRecord>, ChiaQueryError> {
567        self.peer_then_coinset(
568            self.peer.try_get_coin_records_by_hints(
569                hints,
570                start_height,
571                end_height,
572                include_spent_coins,
573            ),
574            self.peer.try_get_coin_records_by_hints(
575                hints,
576                start_height,
577                end_height,
578                include_spent_coins,
579            ),
580            self.coinset.get_coin_records_by_hints(
581                hints,
582                start_height,
583                end_height,
584                include_spent_coins,
585            ),
586        )
587        .await
588    }
589
590    pub async fn get_coin_records_by_names(
591        &self,
592        names: &[String],
593        start_height: Option<u32>,
594        end_height: Option<u32>,
595        include_spent_coins: bool,
596    ) -> Result<Vec<CoinRecord>, ChiaQueryError> {
597        self.peer_then_coinset(
598            self.peer.try_get_coin_records_by_names(names),
599            self.peer.try_get_coin_records_by_names(names),
600            self.coinset.get_coin_records_by_names(
601                names,
602                start_height,
603                end_height,
604                include_spent_coins,
605            ),
606        )
607        .await
608    }
609
610    /// Peer-backed via `RequestChildren` / `RespondChildren` which returns
611    /// child coin states for a given parent coin ID.  Falls back to coinset
612    /// for batched queries or when peers fail.
613    pub async fn get_coin_records_by_parent_ids(
614        &self,
615        parent_ids: &[String],
616        start_height: Option<u32>,
617        end_height: Option<u32>,
618        include_spent_coins: bool,
619    ) -> Result<Vec<CoinRecord>, ChiaQueryError> {
620        // Try peer: query each parent ID via RequestChildren, combine results.
621        let peer_attempt = async {
622            let mut all_records = Vec::new();
623            for parent_id in parent_ids {
624                let children = self.peer.try_get_children(parent_id).await?;
625                all_records.extend(children);
626            }
627            // Apply client-side height and spent filters.
628            all_records.retain(|r| {
629                let height_ok = match (start_height, end_height) {
630                    (Some(s), Some(e)) => {
631                        r.confirmed_block_index >= s && r.confirmed_block_index <= e
632                    }
633                    (Some(s), None) => r.confirmed_block_index >= s,
634                    (None, Some(e)) => r.confirmed_block_index <= e,
635                    (None, None) => true,
636                };
637                let spent_ok = include_spent_coins || !r.spent;
638                height_ok && spent_ok
639            });
640            Ok(all_records)
641        };
642
643        match peer_attempt.await {
644            Ok(r) => Ok(r),
645            Err(peer_err) => {
646                if self.coinset_fallback_enabled {
647                    self.coinset
648                        .get_coin_records_by_parent_ids(
649                            parent_ids,
650                            start_height,
651                            end_height,
652                            include_spent_coins,
653                        )
654                        .await
655                        .map_err(|ce| ChiaQueryError::AllSourcesFailed {
656                            peer_error: Box::new(peer_err),
657                            coinset_error: Some(Box::new(ce)),
658                        })
659                } else {
660                    Err(peer_err)
661                }
662            }
663        }
664    }
665
666    pub async fn get_coin_records_by_puzzle_hash(
667        &self,
668        puzzle_hash: &str,
669        start_height: Option<u32>,
670        end_height: Option<u32>,
671        include_spent_coins: bool,
672    ) -> Result<Vec<CoinRecord>, ChiaQueryError> {
673        self.peer_then_coinset(
674            self.peer.try_get_coin_records_by_puzzle_hash(
675                puzzle_hash,
676                start_height,
677                end_height,
678                include_spent_coins,
679            ),
680            self.peer.try_get_coin_records_by_puzzle_hash(
681                puzzle_hash,
682                start_height,
683                end_height,
684                include_spent_coins,
685            ),
686            self.coinset.get_coin_records_by_puzzle_hash(
687                puzzle_hash,
688                start_height,
689                end_height,
690                include_spent_coins,
691            ),
692        )
693        .await
694    }
695
696    pub async fn get_coin_records_by_puzzle_hashes(
697        &self,
698        puzzle_hashes: &[String],
699        start_height: Option<u32>,
700        end_height: Option<u32>,
701        include_spent_coins: bool,
702    ) -> Result<Vec<CoinRecord>, ChiaQueryError> {
703        self.peer_then_coinset(
704            self.peer.try_get_coin_records_by_puzzle_hashes(
705                puzzle_hashes,
706                start_height,
707                end_height,
708                include_spent_coins,
709            ),
710            self.peer.try_get_coin_records_by_puzzle_hashes(
711                puzzle_hashes,
712                start_height,
713                end_height,
714                include_spent_coins,
715            ),
716            self.coinset.get_coin_records_by_puzzle_hashes(
717                puzzle_hashes,
718                start_height,
719                end_height,
720                include_spent_coins,
721            ),
722        )
723        .await
724    }
725
726    /// No peer equivalent -- always coinset.
727    pub async fn get_memos_by_coin_name(&self, name: &str) -> Result<Value, ChiaQueryError> {
728        self.require_coinset("get_memos_by_coin_name")?;
729        self.coinset.get_memos_by_coin_name(name).await
730    }
731
732    pub async fn get_puzzle_and_solution(
733        &self,
734        coin_id: &str,
735        height: Option<u32>,
736    ) -> Result<CoinSpend, ChiaQueryError> {
737        if let Some(h) = height {
738            self.peer_then_coinset(
739                self.peer.try_get_puzzle_and_solution(coin_id, h),
740                self.peer.try_get_puzzle_and_solution(coin_id, h),
741                self.coinset.get_puzzle_and_solution(coin_id, height),
742            )
743            .await
744        } else {
745            // No height provided -- peer can resolve it via coin state.
746            self.peer_then_coinset(
747                self.peer.try_get_puzzle_and_solution_auto(coin_id),
748                self.peer.try_get_puzzle_and_solution_auto(coin_id),
749                self.coinset.get_puzzle_and_solution(coin_id, None),
750            )
751            .await
752        }
753    }
754
755    /// Peer-backed: get puzzle & solution, then run puzzle(solution) to extract
756    /// parsed conditions.
757    pub async fn get_puzzle_and_solution_with_conditions(
758        &self,
759        coin_id: &str,
760        height: Option<u32>,
761    ) -> Result<CoinSpendWithConditions, ChiaQueryError> {
762        // Try to get the spend via peer first.
763        let spend = match self.get_puzzle_and_solution(coin_id, height).await {
764            Ok(s) => s,
765            Err(_) => {
766                if self.coinset_fallback_enabled {
767                    return self
768                        .coinset
769                        .get_puzzle_and_solution_with_conditions(coin_id, height)
770                        .await;
771                }
772                return Err(ChiaQueryError::PeerRejection(
773                    "could not retrieve puzzle and solution".into(),
774                ));
775            }
776        };
777
778        // Run puzzle(solution) to extract conditions.
779        let conditions = run_puzzle_conditions(&spend, self.peer.constants());
780        Ok(CoinSpendWithConditions {
781            coin_spend: spend,
782            conditions,
783        })
784    }
785
786    pub async fn push_tx(&self, bundle: &SpendBundle) -> Result<TxStatus, ChiaQueryError> {
787        self.peer_then_coinset(
788            self.peer.try_push_tx(bundle),
789            self.peer.try_push_tx(bundle),
790            self.coinset.push_tx(bundle),
791        )
792        .await
793    }
794}
795
796// ---------------------------------------------------------------------------
797// Fees (peer-backed with coinset fallback)
798// ---------------------------------------------------------------------------
799
800impl QueryRouter {
801    pub async fn get_fee_estimate(
802        &self,
803        spend_bundle: Option<&SpendBundle>,
804        target_times: Option<&[u64]>,
805        spend_count: Option<u64>,
806    ) -> Result<FeeEstimate, ChiaQueryError> {
807        let times = target_times.unwrap_or(&[60, 120, 300]);
808        self.peer_then_coinset(
809            self.peer.try_get_fee_estimate(times),
810            self.peer.try_get_fee_estimate(times),
811            self.coinset
812                .get_fee_estimate(spend_bundle, target_times, spend_count),
813        )
814        .await
815    }
816}
817
818// ---------------------------------------------------------------------------
819// Full node / network (all coinset-only)
820// ---------------------------------------------------------------------------
821
822impl QueryRouter {
823    /// Peer-backed: derived from the chia consensus constants for the
824    /// configured network.
825    pub async fn get_aggsig_additional_data(&self) -> Result<String, ChiaQueryError> {
826        Ok(self.peer.aggsig_additional_data())
827    }
828
829    /// Peer-backed: derived from the chia consensus constants for the
830    /// configured network.
831    pub async fn get_network_info(&self) -> Result<NetworkInfo, ChiaQueryError> {
832        Ok(self.peer.network_info())
833    }
834
835    /// Peer-backed partially: peak height is tracked from `NewPeakWallet`
836    /// messages received from peers.  Full state comes from coinset.
837    pub async fn get_blockchain_state(&self) -> Result<BlockchainState, ChiaQueryError> {
838        // Try coinset first for full state.
839        if self.coinset_fallback_enabled {
840            if let Ok(state) = self.coinset.get_blockchain_state().await {
841                return Ok(state);
842            }
843        }
844        // Fallback: return a minimal state from the peer-tracked peak.
845        let peak = self.peer.peak_height();
846        if peak == 0 {
847            return Err(ChiaQueryError::PeerConnection(
848                "no peak observed from peers yet".into(),
849            ));
850        }
851        Ok(BlockchainState {
852            peak: Some(BlockRecord {
853                height: peak,
854                ..Default::default()
855            }),
856            sync: Some(SyncState {
857                synced: true,
858                sync_mode: false,
859                sync_progress_height: peak,
860                sync_tip_height: peak,
861            }),
862            ..Default::default()
863        })
864    }
865
866    pub async fn get_network_space(
867        &self,
868        newer_block_header_hash: &str,
869        older_block_header_hash: &str,
870    ) -> Result<u64, ChiaQueryError> {
871        self.require_coinset("get_network_space")?;
872        self.coinset
873            .get_network_space(newer_block_header_hash, older_block_header_hash)
874            .await
875    }
876}
877
878// ---------------------------------------------------------------------------
879// Mempool (all coinset-only)
880// ---------------------------------------------------------------------------
881
882impl QueryRouter {
883    pub async fn get_all_mempool_items(
884        &self,
885    ) -> Result<HashMap<String, MempoolItem>, ChiaQueryError> {
886        self.require_coinset("get_all_mempool_items")?;
887        self.coinset.get_all_mempool_items().await
888    }
889
890    pub async fn get_all_mempool_tx_ids(&self) -> Result<Vec<String>, ChiaQueryError> {
891        self.require_coinset("get_all_mempool_tx_ids")?;
892        self.coinset.get_all_mempool_tx_ids().await
893    }
894
895    pub async fn get_mempool_item_by_tx_id(
896        &self,
897        tx_id: &str,
898    ) -> Result<MempoolItem, ChiaQueryError> {
899        self.require_coinset("get_mempool_item_by_tx_id")?;
900        self.coinset.get_mempool_item_by_tx_id(tx_id).await
901    }
902
903    pub async fn get_mempool_items_by_coin_name(
904        &self,
905        coin_name: &str,
906        include_spent_coins: Option<bool>,
907    ) -> Result<Vec<MempoolItem>, ChiaQueryError> {
908        self.require_coinset("get_mempool_items_by_coin_name")?;
909        self.coinset
910            .get_mempool_items_by_coin_name(coin_name, include_spent_coins)
911            .await
912    }
913}