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