Skip to main content

chia_query/
lib.rs

1//! # chia-query
2//!
3//! Query the Chia blockchain through decentralized peer connections with
4//! automatic fallback to the [coinset.org](https://api.coinset.org) HTTP API.
5//!
6//! No Chia installation is required. The peer TLS client identity is generated in
7//! memory by default ([`TlsIdentity::Generated`]), so a client works from a service
8//! account with no home directory of its own.
9//!
10//! ```rust,no_run
11//! use chia_query::{ChiaQuery, ChiaQueryConfig};
12//!
13//! #[tokio::main]
14//! async fn main() -> Result<(), Box<dyn std::error::Error>> {
15//!     let client = ChiaQuery::new(ChiaQueryConfig::default()).await?;
16//!     let record = client.get_coin_record_by_name("0xabc...").await?;
17//!     println!("{:?}", record);
18//!     Ok(())
19//! }
20//! ```
21
22pub mod coinset;
23pub mod drift;
24pub mod types;
25
26// The `@dignetwork/chia-query-wasm` bindings — only for the wasm coinset build.
27#[cfg(all(target_arch = "wasm32", feature = "coinset", not(feature = "native")))]
28pub mod wasm_api;
29
30// The peer WebSocket backend + the routing/CLVM layer are native-only: they
31// pull in `chia-wallet-sdk` (native-tls), `clvmr`, and tokio networking, none
32// of which belong in the wasm coinset-only build.
33#[cfg(feature = "native")]
34pub mod peer;
35#[cfg(feature = "native")]
36pub mod provider_registry;
37#[cfg(feature = "native")]
38pub mod router;
39
40pub use types::*;
41
42// Everything below — the full `ChiaQuery` client that races peers against the
43// coinset fallback — is the native surface. A wasm consumer uses
44// [`coinset::CoinsetClient`] directly with an injected `fetch` transport.
45#[cfg(feature = "native")]
46mod native_client {
47    use std::collections::HashMap;
48    use std::path::PathBuf;
49    use std::time::Duration;
50
51    use serde_json::Value;
52
53    use crate::types::*;
54    use crate::{coinset, peer, router};
55
56    // ---------------------------------------------------------------------------
57    // NetworkType
58    // ---------------------------------------------------------------------------
59
60    #[derive(Debug, Clone, Copy, PartialEq, Eq)]
61    pub enum NetworkType {
62        Mainnet,
63        Testnet11,
64    }
65
66    impl NetworkType {
67        pub fn network_id(self) -> &'static str {
68            match self {
69                Self::Mainnet => "mainnet",
70                Self::Testnet11 => "testnet11",
71            }
72        }
73    }
74
75    // ---------------------------------------------------------------------------
76    // Configuration
77    // ---------------------------------------------------------------------------
78
79    /// Where the peer-protocol TLS client identity comes from.
80    ///
81    /// Modelled as one choice rather than a pair of optional paths so a half-configured
82    /// identity — a certificate without its key — cannot be expressed.
83    #[derive(Debug, Clone, PartialEq, Eq)]
84    pub enum TlsIdentity {
85        /// Generate a fresh self-signed certificate in memory (the default).
86        ///
87        /// Chia full nodes accept any well-formed client certificate, so nothing is
88        /// gained by requiring one on disk and a great deal is lost: a service account
89        /// has no populated `~/.chia`, which is what made every balance read fail in
90        /// dig_ecosystem#2210. See [`peer::connect::create_generated_tls`] for why the
91        /// certificate is not persisted.
92        Generated,
93
94        /// Load an existing certificate/key pair, e.g. a real Chia node's wallet cert.
95        Files {
96            cert_path: PathBuf,
97            key_path: PathBuf,
98        },
99    }
100
101    pub struct ChiaQueryConfig {
102        pub network: NetworkType,
103        pub max_peers: usize,
104        pub coinset_base_url: String,
105        pub coinset_fallback_enabled: bool,
106        pub tls_identity: TlsIdentity,
107        pub peer_connect_timeout: Duration,
108        pub peer_request_timeout: Duration,
109        pub coinset_request_timeout: Duration,
110    }
111
112    impl Default for ChiaQueryConfig {
113        fn default() -> Self {
114            Self {
115                network: NetworkType::Mainnet,
116                max_peers: 5,
117                coinset_base_url: "https://api.coinset.org".into(),
118                coinset_fallback_enabled: true,
119                tls_identity: TlsIdentity::Generated,
120                peer_connect_timeout: Duration::from_secs(8),
121                peer_request_timeout: Duration::from_secs(30),
122                coinset_request_timeout: Duration::from_secs(30),
123            }
124        }
125    }
126
127    // ---------------------------------------------------------------------------
128    // ChiaQuery -- the public entry-point
129    // ---------------------------------------------------------------------------
130
131    pub struct ChiaQuery {
132        router: router::QueryRouter,
133    }
134
135    impl ChiaQuery {
136        /// Create a new client.  This will:
137        /// 1. Establish the peer TLS identity (generated by default — no files needed).
138        /// 2. Discover peers via DNS and connect up to `max_peers` concurrently.
139        /// 3. Initialise the coinset.org HTTP client.
140        ///
141        /// Peer discovery failing is fatal ([`ChiaQueryError::PeerDiscoveryFailed`])
142        /// ONLY when the coinset fallback is disabled; with the fallback enabled the
143        /// client is usable immediately and the peer pool refills in the background.
144        pub async fn new(cfg: ChiaQueryConfig) -> Result<Self, ChiaQueryError> {
145            let tls = match &cfg.tls_identity {
146                TlsIdentity::Generated => peer::connect::create_generated_tls()?,
147                TlsIdentity::Files {
148                    cert_path,
149                    key_path,
150                } => peer::connect::create_tls(cert_path, key_path)?,
151            };
152
153            // The coinset tier is plain HTTP and needs neither a credential nor a peer,
154            // so a peer-tier problem must not deny a reader the fallback that exists
155            // for exactly that case (dig_ecosystem#2210).
156            let peer_requirement = if cfg.coinset_fallback_enabled {
157                peer::PeerRequirement::Optional
158            } else {
159                peer::PeerRequirement::Required
160            };
161
162            let peer_backend = peer::PeerBackend::new(
163                cfg.network,
164                tls,
165                cfg.max_peers,
166                peer_requirement,
167                cfg.peer_connect_timeout,
168                cfg.peer_request_timeout,
169            )
170            .await?;
171
172            let coinset_client =
173                coinset::CoinsetClient::new(&cfg.coinset_base_url, cfg.coinset_request_timeout)?;
174
175            Ok(Self {
176                router: router::QueryRouter {
177                    peer: peer_backend,
178                    coinset: coinset_client,
179                    coinset_fallback_enabled: cfg.coinset_fallback_enabled,
180                },
181            })
182        }
183
184        // =======================================================================
185        // Blocks
186        // =======================================================================
187
188        pub async fn get_additions_and_removals(
189            &self,
190            header_hash: &str,
191        ) -> Result<AdditionsAndRemovals, ChiaQueryError> {
192            self.router.get_additions_and_removals(header_hash).await
193        }
194
195        pub async fn get_block(&self, header_hash: &str) -> Result<FullBlock, ChiaQueryError> {
196            self.router.get_block(header_hash).await
197        }
198
199        /// Fetch a full block by height.  Peer-backed via `RequestBlock`.
200        pub async fn get_block_by_height(&self, height: u32) -> Result<FullBlock, ChiaQueryError> {
201            self.router.get_block_by_height(height).await
202        }
203
204        pub async fn get_block_count_metrics(&self) -> Result<BlockCountMetrics, ChiaQueryError> {
205            self.router.get_block_count_metrics().await
206        }
207
208        pub async fn get_block_record(
209            &self,
210            header_hash: &str,
211        ) -> Result<BlockRecord, ChiaQueryError> {
212            self.router.get_block_record(header_hash).await
213        }
214
215        pub async fn get_block_record_by_height(
216            &self,
217            height: u32,
218        ) -> Result<BlockRecord, ChiaQueryError> {
219            self.router.get_block_record_by_height(height).await
220        }
221
222        pub async fn get_block_records(
223            &self,
224            start: u32,
225            end: u32,
226        ) -> Result<Vec<BlockRecord>, ChiaQueryError> {
227            self.router.get_block_records(start, end).await
228        }
229
230        pub async fn get_block_spends(
231            &self,
232            header_hash: &str,
233        ) -> Result<Vec<CoinSpend>, ChiaQueryError> {
234            self.router.get_block_spends(header_hash).await
235        }
236
237        pub async fn get_block_spends_with_conditions(
238            &self,
239            header_hash: &str,
240        ) -> Result<Vec<CoinSpendWithConditions>, ChiaQueryError> {
241            self.router
242                .get_block_spends_with_conditions(header_hash)
243                .await
244        }
245
246        pub async fn get_blocks(
247            &self,
248            start: u32,
249            end: u32,
250            exclude_header_hash: bool,
251            exclude_reorged: bool,
252        ) -> Result<Vec<FullBlock>, ChiaQueryError> {
253            self.router
254                .get_blocks(start, end, exclude_header_hash, exclude_reorged)
255                .await
256        }
257
258        pub async fn get_unfinished_block_headers(
259            &self,
260        ) -> Result<Vec<UnfinishedBlockHeader>, ChiaQueryError> {
261            self.router.get_unfinished_block_headers().await
262        }
263
264        // =======================================================================
265        // Coins
266        // =======================================================================
267
268        pub async fn get_coin_record_by_name(
269            &self,
270            name: &str,
271        ) -> Result<CoinRecord, ChiaQueryError> {
272            self.router.get_coin_record_by_name(name).await
273        }
274
275        /// Absence-aware [`get_coin_record_by_name`](Self::get_coin_record_by_name).
276        ///
277        /// `Ok(None)` means **corroborated absence**: two independent sources were asked and both
278        /// reported no such coin. Absence that only one source will vouch for is
279        /// [`UncorroboratedAbsence`](ChiaQueryError::UncorroboratedAbsence), and two sources that
280        /// contradict each other are [`SourcesDisagree`](ChiaQueryError::SourcesDisagree) — both
281        /// errors, because neither is a fact about the chain (dig_ecosystem#2456).
282        ///
283        /// `Ok(Some(record))` means **corroborated presence**, and means it for the same reason:
284        /// the coin-id binding authenticates the coin's identity only, so `confirmed_block_index`
285        /// and `spent_block_index` are put to a second independent source before they are
286        /// reported. A record only one source will vouch for is
287        /// [`UncorroboratedPresence`](ChiaQueryError::UncorroboratedPresence)
288        /// (dig_ecosystem#2462).
289        ///
290        /// Used by the [`ChainSource`](dig_chainsource_interface::ChainSource) facade to honour the
291        /// fail-closed `Ok(None)`-vs-`Err` contract.
292        pub async fn get_coin_record_by_name_opt(
293            &self,
294            name: &str,
295        ) -> Result<Option<CoinRecord>, ChiaQueryError> {
296            self.router.get_coin_record_by_name_opt(name).await
297        }
298
299        /// Absence-aware read of the spend that spent `coin_id`.
300        ///
301        /// `Ok(None)` when two independent sources agree the coin is unspent or unknown,
302        /// `Ok(Some(spend))` when two agree on the spend; `Err` on failure, and on an answer in
303        /// either direction that only one source will vouch for — see
304        /// [`get_coin_record_by_name_opt`](Self::get_coin_record_by_name_opt).
305        pub async fn get_coin_spend_opt(
306            &self,
307            coin_id: &str,
308        ) -> Result<Option<CoinSpend>, ChiaQueryError> {
309            self.router.get_coin_spend_opt(coin_id).await
310        }
311
312        /// The current peak height (`Ok(None)` when unavailable), `Err` on failure.
313        pub async fn peak_height_opt(&self) -> Result<Option<u32>, ChiaQueryError> {
314            self.router.peak_height_opt().await
315        }
316
317        /// How many Chia full-node peers this client HOLDS right now.
318        ///
319        /// Exposed because a consumer that presents itself as a light client has to be able to
320        /// SAY how many peers it is a client of, and until now the pool's size was observable
321        /// only as the boolean [`has_peers`](peer::PeerBackend::has_peers). A count is not
322        /// derivable from that, and a consumer with no way to read it is left either silent or
323        /// quoting [`ChiaQueryConfig::max_peers`] — an intention presented as a measurement.
324        ///
325        /// It is the LIVE count, never the target: a filling pool reports the smaller number.
326        /// See [`peer::pool::PeerPool::peer_count`] for what "held" means with respect to a peer
327        /// that has died without being used since.
328        pub async fn peer_count(&self) -> usize {
329            self.router.peer.peer_count().await
330        }
331
332        /// How many held peers count as INDEPENDENT opinions about the chain.
333        ///
334        /// Never larger than [`peer_count`](Self::peer_count), and smaller by the peers reached
335        /// from a preferred address — an operator's `TRUSTED_FULLNODE`, or a full node on this
336        /// machine. Those are the fastest peers to read from and the worst possible witnesses to
337        /// each other: a local process is a source a local attacker can supply, so a count of
338        /// agreeing sources that includes one is not a count of independent sources.
339        ///
340        /// Use this, not `peer_count`, for any decision of the form "do enough separate sources
341        /// agree" (dig_ecosystem#2648). Use `peer_count` to tell a user how many peers are held.
342        pub async fn independent_peer_count(&self) -> usize {
343            self.router.peer.independent_peer_count().await
344        }
345
346        /// The peak height this client's OWN peers have reported, or `None` when they have
347        /// reported none yet.
348        ///
349        /// Distinct from [`peak_height_opt`](Self::peak_height_opt), which answers "what is the
350        /// chain's peak" and consults coinset FIRST — so its figure is a third party's view of
351        /// the chain even on a client holding peers. This one answers "what have MY peers told
352        /// me", which is the only form of the question a light client can demonstrate, and it
353        /// makes no network call at all: the pool tracks it from inbound `NewPeakWallet`
354        /// messages.
355        ///
356        /// `None` is UNKNOWN, never height zero. The pool spells an unobserved peak `0`
357        /// internally, and every block is trivially above zero, so returning it would silently
358        /// satisfy any "is this buried yet" comparison a caller makes.
359        pub async fn peer_peak_height(&self) -> Option<u32> {
360            observed_peak(self.router.peer.peak_height())
361        }
362
363        /// The Unix timestamp of the block at `height` (`Ok(None)` when absent), `Err` on failure.
364        pub async fn block_timestamp_opt(
365            &self,
366            height: u32,
367        ) -> Result<Option<u64>, ChiaQueryError> {
368            self.router.block_timestamp_opt(height).await
369        }
370
371        pub async fn get_coin_records_by_hint(
372            &self,
373            hint: &str,
374            start_height: Option<u32>,
375            end_height: Option<u32>,
376            include_spent_coins: bool,
377        ) -> Result<Vec<CoinRecord>, ChiaQueryError> {
378            self.router
379                .get_coin_records_by_hint(hint, start_height, end_height, include_spent_coins)
380                .await
381        }
382
383        pub async fn get_coin_records_by_hints(
384            &self,
385            hints: &[String],
386            start_height: Option<u32>,
387            end_height: Option<u32>,
388            include_spent_coins: bool,
389        ) -> Result<Vec<CoinRecord>, ChiaQueryError> {
390            self.router
391                .get_coin_records_by_hints(hints, start_height, end_height, include_spent_coins)
392                .await
393        }
394
395        pub async fn get_coin_records_by_names(
396            &self,
397            names: &[String],
398            start_height: Option<u32>,
399            end_height: Option<u32>,
400            include_spent_coins: bool,
401        ) -> Result<Vec<CoinRecord>, ChiaQueryError> {
402            self.router
403                .get_coin_records_by_names(names, start_height, end_height, include_spent_coins)
404                .await
405        }
406
407        pub async fn get_coin_records_by_parent_ids(
408            &self,
409            parent_ids: &[String],
410            start_height: Option<u32>,
411            end_height: Option<u32>,
412            include_spent_coins: bool,
413        ) -> Result<Vec<CoinRecord>, ChiaQueryError> {
414            self.router
415                .get_coin_records_by_parent_ids(
416                    parent_ids,
417                    start_height,
418                    end_height,
419                    include_spent_coins,
420                )
421                .await
422        }
423
424        pub async fn get_coin_records_by_puzzle_hash(
425            &self,
426            puzzle_hash: &str,
427            start_height: Option<u32>,
428            end_height: Option<u32>,
429            include_spent_coins: bool,
430        ) -> Result<Vec<CoinRecord>, ChiaQueryError> {
431            self.router
432                .get_coin_records_by_puzzle_hash(
433                    puzzle_hash,
434                    start_height,
435                    end_height,
436                    include_spent_coins,
437                )
438                .await
439        }
440
441        pub async fn get_coin_records_by_puzzle_hashes(
442            &self,
443            puzzle_hashes: &[String],
444            start_height: Option<u32>,
445            end_height: Option<u32>,
446            include_spent_coins: bool,
447        ) -> Result<Vec<CoinRecord>, ChiaQueryError> {
448            self.router
449                .get_coin_records_by_puzzle_hashes(
450                    puzzle_hashes,
451                    start_height,
452                    end_height,
453                    include_spent_coins,
454                )
455                .await
456        }
457
458        pub async fn get_memos_by_coin_name(&self, name: &str) -> Result<Value, ChiaQueryError> {
459            self.router.get_memos_by_coin_name(name).await
460        }
461
462        pub async fn get_puzzle_and_solution(
463            &self,
464            coin_id: &str,
465            height: Option<u32>,
466        ) -> Result<CoinSpend, ChiaQueryError> {
467            self.router.get_puzzle_and_solution(coin_id, height).await
468        }
469
470        pub async fn get_puzzle_and_solution_with_conditions(
471            &self,
472            coin_id: &str,
473            height: Option<u32>,
474        ) -> Result<CoinSpendWithConditions, ChiaQueryError> {
475            self.router
476                .get_puzzle_and_solution_with_conditions(coin_id, height)
477                .await
478        }
479
480        pub async fn push_tx(
481            &self,
482            spend_bundle: &SpendBundle,
483        ) -> Result<TxStatus, ChiaQueryError> {
484            self.router.push_tx(spend_bundle).await
485        }
486
487        // =======================================================================
488        // Fees
489        // =======================================================================
490
491        pub async fn get_fee_estimate(
492            &self,
493            spend_bundle: Option<&SpendBundle>,
494            target_times: Option<&[u64]>,
495            spend_count: Option<u64>,
496        ) -> Result<FeeEstimate, ChiaQueryError> {
497            self.router
498                .get_fee_estimate(spend_bundle, target_times, spend_count)
499                .await
500        }
501
502        // =======================================================================
503        // Full node / network
504        // =======================================================================
505
506        pub async fn get_aggsig_additional_data(&self) -> Result<String, ChiaQueryError> {
507            self.router.get_aggsig_additional_data().await
508        }
509
510        pub async fn get_network_info(&self) -> Result<NetworkInfo, ChiaQueryError> {
511            self.router.get_network_info().await
512        }
513
514        pub async fn get_blockchain_state(&self) -> Result<BlockchainState, ChiaQueryError> {
515            self.router.get_blockchain_state().await
516        }
517
518        pub async fn get_network_space(
519            &self,
520            newer_block_header_hash: &str,
521            older_block_header_hash: &str,
522        ) -> Result<u64, ChiaQueryError> {
523            self.router
524                .get_network_space(newer_block_header_hash, older_block_header_hash)
525                .await
526        }
527
528        // =======================================================================
529        // Mempool
530        // =======================================================================
531
532        pub async fn get_all_mempool_items(
533            &self,
534        ) -> Result<HashMap<String, MempoolItem>, ChiaQueryError> {
535            self.router.get_all_mempool_items().await
536        }
537
538        pub async fn get_all_mempool_tx_ids(&self) -> Result<Vec<String>, ChiaQueryError> {
539            self.router.get_all_mempool_tx_ids().await
540        }
541
542        pub async fn get_mempool_item_by_tx_id(
543            &self,
544            tx_id: &str,
545        ) -> Result<MempoolItem, ChiaQueryError> {
546            self.router.get_mempool_item_by_tx_id(tx_id).await
547        }
548
549        pub async fn get_mempool_items_by_coin_name(
550            &self,
551            coin_name: &str,
552            include_spent_coins: Option<bool>,
553        ) -> Result<Vec<MempoolItem>, ChiaQueryError> {
554            self.router
555                .get_mempool_items_by_coin_name(coin_name, include_spent_coins)
556                .await
557        }
558
559        // =======================================================================
560        // Convenience helpers
561        // =======================================================================
562
563        /// Poll the blockchain until a coin appears on-chain (confirmed) or the
564        /// timeout elapses.
565        ///
566        /// Returns the [`CoinRecord`] once the coin is found with a non-zero
567        /// `confirmed_block_index`.  Returns an error if the timeout expires
568        /// before the coin is confirmed.
569        ///
570        /// ```rust,no_run
571        /// # use chia_query::{ChiaQuery, ChiaQueryConfig};
572        /// # use std::time::Duration;
573        /// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
574        /// let client = ChiaQuery::new(ChiaQueryConfig::default()).await?;
575        /// let record = client.wait_for_confirmation(
576        ///     "0xabc...",
577        ///     Duration::from_secs(5),   // poll every 5 seconds
578        ///     Duration::from_secs(300), // give up after 5 minutes
579        /// ).await?;
580        /// println!("confirmed at height {}", record.confirmed_block_index);
581        /// # Ok(())
582        /// # }
583        /// ```
584        pub async fn wait_for_confirmation(
585            &self,
586            coin_id: &str,
587            poll_interval: Duration,
588            timeout: Duration,
589        ) -> Result<CoinRecord, ChiaQueryError> {
590            let deadline = tokio::time::Instant::now() + timeout;
591
592            loop {
593                match self.get_coin_record_by_name(coin_id).await {
594                    Ok(record) if record.confirmed_block_index > 0 => {
595                        return Ok(record);
596                    }
597                    Ok(_) => {
598                        // Coin exists but confirmed_block_index is 0 -- not
599                        // confirmed yet, keep polling.
600                    }
601                    Err(ChiaQueryError::PeerRejection(_))
602                    | Err(ChiaQueryError::CoinsetApiError(_)) => {
603                        // Coin not found yet -- keep polling.
604                    }
605                    Err(e) => {
606                        // Transient connection errors -- log and keep trying.
607                        log::debug!("wait_for_confirmation poll error: {e}");
608                    }
609                }
610
611                if tokio::time::Instant::now() + poll_interval > deadline {
612                    return Err(ChiaQueryError::PeerConnection(format!(
613                        "coin {coin_id} not confirmed within {timeout:?}"
614                    )));
615                }
616
617                tokio::time::sleep(poll_interval).await;
618            }
619        }
620    }
621
622    /// The pool's peak sentinel as an honest optional height.
623    ///
624    /// Kept as a named pure function rather than inlined, because the rule it encodes — an
625    /// unobserved peak is UNKNOWN and not height zero — is the whole reason
626    /// [`ChiaQuery::peer_peak_height`] returns an `Option`, and inline it is unreachable from a
627    /// test on a machine with no peers.
628    fn observed_peak(raw: u32) -> Option<u32> {
629        (raw != 0).then_some(raw)
630    }
631
632    #[cfg(test)]
633    mod tests {
634        use super::observed_peak;
635
636        /// **An unobserved peak is unknown, never zero.** The pool spells "no peer has told me a
637        /// peak" as `0`, and every block is trivially above zero — so a caller asking "is this
638        /// coin buried yet" against a leaked `0` gets a confident yes about a chain nobody has
639        /// looked at.
640        #[test]
641        fn an_unobserved_peak_is_unknown_and_a_real_height_survives() {
642            assert_eq!(observed_peak(0), None);
643            assert_eq!(observed_peak(1), Some(1));
644            assert_eq!(observed_peak(9_139_211), Some(9_139_211));
645        }
646    }
647} // mod native_client
648
649#[cfg(feature = "native")]
650pub use native_client::{ChiaQuery, ChiaQueryConfig, NetworkType, TlsIdentity};