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