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 /// Presence is unaffected and needs no quorum: a returned record is checkable against its
284 /// own fields, so it is returned from the first peer that has it.
285 ///
286 /// Used by the [`ChainSource`](dig_chainsource_interface::ChainSource) facade to honour the
287 /// fail-closed `Ok(None)`-vs-`Err` contract.
288 pub async fn get_coin_record_by_name_opt(
289 &self,
290 name: &str,
291 ) -> Result<Option<CoinRecord>, ChiaQueryError> {
292 self.router.get_coin_record_by_name_opt(name).await
293 }
294
295 /// Absence-aware read of the spend that spent `coin_id`.
296 ///
297 /// `Ok(None)` when two independent sources agree the coin is unspent or unknown; `Err` on
298 /// failure, and on an absence only one source will vouch for — see
299 /// [`get_coin_record_by_name_opt`](Self::get_coin_record_by_name_opt).
300 pub async fn get_coin_spend_opt(
301 &self,
302 coin_id: &str,
303 ) -> Result<Option<CoinSpend>, ChiaQueryError> {
304 self.router.get_coin_spend_opt(coin_id).await
305 }
306
307 /// The current peak height (`Ok(None)` when unavailable), `Err` on failure.
308 pub async fn peak_height_opt(&self) -> Result<Option<u32>, ChiaQueryError> {
309 self.router.peak_height_opt().await
310 }
311
312 /// How many Chia full-node peers this client HOLDS right now.
313 ///
314 /// Exposed because a consumer that presents itself as a light client has to be able to
315 /// SAY how many peers it is a client of, and until now the pool's size was observable
316 /// only as the boolean [`has_peers`](peer::PeerBackend::has_peers). A count is not
317 /// derivable from that, and a consumer with no way to read it is left either silent or
318 /// quoting [`ChiaQueryConfig::max_peers`] — an intention presented as a measurement.
319 ///
320 /// It is the LIVE count, never the target: a filling pool reports the smaller number.
321 /// See [`peer::pool::PeerPool::peer_count`] for what "held" means with respect to a peer
322 /// that has died without being used since.
323 pub async fn peer_count(&self) -> usize {
324 self.router.peer.peer_count().await
325 }
326
327 /// How many held peers count as INDEPENDENT opinions about the chain.
328 ///
329 /// Never larger than [`peer_count`](Self::peer_count), and smaller by the peers reached
330 /// from a preferred address — an operator's `TRUSTED_FULLNODE`, or a full node on this
331 /// machine. Those are the fastest peers to read from and the worst possible witnesses to
332 /// each other: a local process is a source a local attacker can supply, so a count of
333 /// agreeing sources that includes one is not a count of independent sources.
334 ///
335 /// Use this, not `peer_count`, for any decision of the form "do enough separate sources
336 /// agree" (dig_ecosystem#2648). Use `peer_count` to tell a user how many peers are held.
337 pub async fn independent_peer_count(&self) -> usize {
338 self.router.peer.independent_peer_count().await
339 }
340
341 /// The peak height this client's OWN peers have reported, or `None` when they have
342 /// reported none yet.
343 ///
344 /// Distinct from [`peak_height_opt`](Self::peak_height_opt), which answers "what is the
345 /// chain's peak" and consults coinset FIRST — so its figure is a third party's view of
346 /// the chain even on a client holding peers. This one answers "what have MY peers told
347 /// me", which is the only form of the question a light client can demonstrate, and it
348 /// makes no network call at all: the pool tracks it from inbound `NewPeakWallet`
349 /// messages.
350 ///
351 /// `None` is UNKNOWN, never height zero. The pool spells an unobserved peak `0`
352 /// internally, and every block is trivially above zero, so returning it would silently
353 /// satisfy any "is this buried yet" comparison a caller makes.
354 pub async fn peer_peak_height(&self) -> Option<u32> {
355 observed_peak(self.router.peer.peak_height())
356 }
357
358 /// The Unix timestamp of the block at `height` (`Ok(None)` when absent), `Err` on failure.
359 pub async fn block_timestamp_opt(
360 &self,
361 height: u32,
362 ) -> Result<Option<u64>, ChiaQueryError> {
363 self.router.block_timestamp_opt(height).await
364 }
365
366 pub async fn get_coin_records_by_hint(
367 &self,
368 hint: &str,
369 start_height: Option<u32>,
370 end_height: Option<u32>,
371 include_spent_coins: bool,
372 ) -> Result<Vec<CoinRecord>, ChiaQueryError> {
373 self.router
374 .get_coin_records_by_hint(hint, start_height, end_height, include_spent_coins)
375 .await
376 }
377
378 pub async fn get_coin_records_by_hints(
379 &self,
380 hints: &[String],
381 start_height: Option<u32>,
382 end_height: Option<u32>,
383 include_spent_coins: bool,
384 ) -> Result<Vec<CoinRecord>, ChiaQueryError> {
385 self.router
386 .get_coin_records_by_hints(hints, start_height, end_height, include_spent_coins)
387 .await
388 }
389
390 pub async fn get_coin_records_by_names(
391 &self,
392 names: &[String],
393 start_height: Option<u32>,
394 end_height: Option<u32>,
395 include_spent_coins: bool,
396 ) -> Result<Vec<CoinRecord>, ChiaQueryError> {
397 self.router
398 .get_coin_records_by_names(names, start_height, end_height, include_spent_coins)
399 .await
400 }
401
402 pub async fn get_coin_records_by_parent_ids(
403 &self,
404 parent_ids: &[String],
405 start_height: Option<u32>,
406 end_height: Option<u32>,
407 include_spent_coins: bool,
408 ) -> Result<Vec<CoinRecord>, ChiaQueryError> {
409 self.router
410 .get_coin_records_by_parent_ids(
411 parent_ids,
412 start_height,
413 end_height,
414 include_spent_coins,
415 )
416 .await
417 }
418
419 pub async fn get_coin_records_by_puzzle_hash(
420 &self,
421 puzzle_hash: &str,
422 start_height: Option<u32>,
423 end_height: Option<u32>,
424 include_spent_coins: bool,
425 ) -> Result<Vec<CoinRecord>, ChiaQueryError> {
426 self.router
427 .get_coin_records_by_puzzle_hash(
428 puzzle_hash,
429 start_height,
430 end_height,
431 include_spent_coins,
432 )
433 .await
434 }
435
436 pub async fn get_coin_records_by_puzzle_hashes(
437 &self,
438 puzzle_hashes: &[String],
439 start_height: Option<u32>,
440 end_height: Option<u32>,
441 include_spent_coins: bool,
442 ) -> Result<Vec<CoinRecord>, ChiaQueryError> {
443 self.router
444 .get_coin_records_by_puzzle_hashes(
445 puzzle_hashes,
446 start_height,
447 end_height,
448 include_spent_coins,
449 )
450 .await
451 }
452
453 pub async fn get_memos_by_coin_name(&self, name: &str) -> Result<Value, ChiaQueryError> {
454 self.router.get_memos_by_coin_name(name).await
455 }
456
457 pub async fn get_puzzle_and_solution(
458 &self,
459 coin_id: &str,
460 height: Option<u32>,
461 ) -> Result<CoinSpend, ChiaQueryError> {
462 self.router.get_puzzle_and_solution(coin_id, height).await
463 }
464
465 pub async fn get_puzzle_and_solution_with_conditions(
466 &self,
467 coin_id: &str,
468 height: Option<u32>,
469 ) -> Result<CoinSpendWithConditions, ChiaQueryError> {
470 self.router
471 .get_puzzle_and_solution_with_conditions(coin_id, height)
472 .await
473 }
474
475 pub async fn push_tx(
476 &self,
477 spend_bundle: &SpendBundle,
478 ) -> Result<TxStatus, ChiaQueryError> {
479 self.router.push_tx(spend_bundle).await
480 }
481
482 // =======================================================================
483 // Fees
484 // =======================================================================
485
486 pub async fn get_fee_estimate(
487 &self,
488 spend_bundle: Option<&SpendBundle>,
489 target_times: Option<&[u64]>,
490 spend_count: Option<u64>,
491 ) -> Result<FeeEstimate, ChiaQueryError> {
492 self.router
493 .get_fee_estimate(spend_bundle, target_times, spend_count)
494 .await
495 }
496
497 // =======================================================================
498 // Full node / network
499 // =======================================================================
500
501 pub async fn get_aggsig_additional_data(&self) -> Result<String, ChiaQueryError> {
502 self.router.get_aggsig_additional_data().await
503 }
504
505 pub async fn get_network_info(&self) -> Result<NetworkInfo, ChiaQueryError> {
506 self.router.get_network_info().await
507 }
508
509 pub async fn get_blockchain_state(&self) -> Result<BlockchainState, ChiaQueryError> {
510 self.router.get_blockchain_state().await
511 }
512
513 pub async fn get_network_space(
514 &self,
515 newer_block_header_hash: &str,
516 older_block_header_hash: &str,
517 ) -> Result<u64, ChiaQueryError> {
518 self.router
519 .get_network_space(newer_block_header_hash, older_block_header_hash)
520 .await
521 }
522
523 // =======================================================================
524 // Mempool
525 // =======================================================================
526
527 pub async fn get_all_mempool_items(
528 &self,
529 ) -> Result<HashMap<String, MempoolItem>, ChiaQueryError> {
530 self.router.get_all_mempool_items().await
531 }
532
533 pub async fn get_all_mempool_tx_ids(&self) -> Result<Vec<String>, ChiaQueryError> {
534 self.router.get_all_mempool_tx_ids().await
535 }
536
537 pub async fn get_mempool_item_by_tx_id(
538 &self,
539 tx_id: &str,
540 ) -> Result<MempoolItem, ChiaQueryError> {
541 self.router.get_mempool_item_by_tx_id(tx_id).await
542 }
543
544 pub async fn get_mempool_items_by_coin_name(
545 &self,
546 coin_name: &str,
547 include_spent_coins: Option<bool>,
548 ) -> Result<Vec<MempoolItem>, ChiaQueryError> {
549 self.router
550 .get_mempool_items_by_coin_name(coin_name, include_spent_coins)
551 .await
552 }
553
554 // =======================================================================
555 // Convenience helpers
556 // =======================================================================
557
558 /// Poll the blockchain until a coin appears on-chain (confirmed) or the
559 /// timeout elapses.
560 ///
561 /// Returns the [`CoinRecord`] once the coin is found with a non-zero
562 /// `confirmed_block_index`. Returns an error if the timeout expires
563 /// before the coin is confirmed.
564 ///
565 /// ```rust,no_run
566 /// # use chia_query::{ChiaQuery, ChiaQueryConfig};
567 /// # use std::time::Duration;
568 /// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
569 /// let client = ChiaQuery::new(ChiaQueryConfig::default()).await?;
570 /// let record = client.wait_for_confirmation(
571 /// "0xabc...",
572 /// Duration::from_secs(5), // poll every 5 seconds
573 /// Duration::from_secs(300), // give up after 5 minutes
574 /// ).await?;
575 /// println!("confirmed at height {}", record.confirmed_block_index);
576 /// # Ok(())
577 /// # }
578 /// ```
579 pub async fn wait_for_confirmation(
580 &self,
581 coin_id: &str,
582 poll_interval: Duration,
583 timeout: Duration,
584 ) -> Result<CoinRecord, ChiaQueryError> {
585 let deadline = tokio::time::Instant::now() + timeout;
586
587 loop {
588 match self.get_coin_record_by_name(coin_id).await {
589 Ok(record) if record.confirmed_block_index > 0 => {
590 return Ok(record);
591 }
592 Ok(_) => {
593 // Coin exists but confirmed_block_index is 0 -- not
594 // confirmed yet, keep polling.
595 }
596 Err(ChiaQueryError::PeerRejection(_))
597 | Err(ChiaQueryError::CoinsetApiError(_)) => {
598 // Coin not found yet -- keep polling.
599 }
600 Err(e) => {
601 // Transient connection errors -- log and keep trying.
602 log::debug!("wait_for_confirmation poll error: {e}");
603 }
604 }
605
606 if tokio::time::Instant::now() + poll_interval > deadline {
607 return Err(ChiaQueryError::PeerConnection(format!(
608 "coin {coin_id} not confirmed within {timeout:?}"
609 )));
610 }
611
612 tokio::time::sleep(poll_interval).await;
613 }
614 }
615 }
616
617 /// The pool's peak sentinel as an honest optional height.
618 ///
619 /// Kept as a named pure function rather than inlined, because the rule it encodes — an
620 /// unobserved peak is UNKNOWN and not height zero — is the whole reason
621 /// [`ChiaQuery::peer_peak_height`] returns an `Option`, and inline it is unreachable from a
622 /// test on a machine with no peers.
623 fn observed_peak(raw: u32) -> Option<u32> {
624 (raw != 0).then_some(raw)
625 }
626
627 #[cfg(test)]
628 mod tests {
629 use super::observed_peak;
630
631 /// **An unobserved peak is unknown, never zero.** The pool spells "no peer has told me a
632 /// peak" as `0`, and every block is trivially above zero — so a caller asking "is this
633 /// coin buried yet" against a leaked `0` gets a confident yes about a chain nobody has
634 /// looked at.
635 #[test]
636 fn an_unobserved_peak_is_unknown_and_a_real_height_survives() {
637 assert_eq!(observed_peak(0), None);
638 assert_eq!(observed_peak(1), Some(1));
639 assert_eq!(observed_peak(9_139_211), Some(9_139_211));
640 }
641 }
642} // mod native_client
643
644#[cfg(feature = "native")]
645pub use native_client::{ChiaQuery, ChiaQueryConfig, NetworkType, TlsIdentity};