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};