Skip to main content

chia_query/provider_registry/
coinset_source.rs

1//! [`CoinsetChainSource`] — a lightweight, no-handshake [`ChainSource`] served entirely by the
2//! coinset.org HTTP tier, plus the [`CoinsetProvider::from_url`] / [`CoinsetProvider::from_env`]
3//! constructors that wrap it.
4//!
5//! ## Why this exists (#1354)
6//!
7//! chia-query's full [`ChiaQueryProvider`](super::ChiaQueryProvider) races decentralized Chia peers
8//! against the coinset fallback, so constructing it needs a LIVE peer handshake + TLS certs — far
9//! too heavy for a consumer that only needs coinset HTTP point-reads (a coin record, a coin spend,
10//! the peak height, a block timestamp). This source constructs from JUST a coinset base URL: no
11//! Chia peer, no certificate, no sync. It owns its own multi-thread tokio runtime so a synchronous
12//! consumer can build it and call it directly.
13//!
14//! ## Trust posture
15//!
16//! coinset.org is a single public oracle that can lie, so [`CoinsetProvider`] labels this source
17//! [`ProviderKind::PublicOracle`](dig_chainsource_interface::ProviderKind::PublicOracle) with
18//! `trustless: false`. The registry's operator-assigned trust + quorum — never this source alone —
19//! gates custody.
20//!
21//! ## What it serves vs. refuses (fail-closed)
22//!
23//! Every point-read maps to one coinset REST round-trip and preserves the money-critical
24//! `Ok(None)`-vs-`Err` contract (SPEC §3): a provable absence is `Ok(None)`, a transport/parse
25//! failure is `Err` — never collapsed into a false absence.
26//!
27//! [`resolve_singleton_lineage`](ChainSource::resolve_singleton_lineage) is deliberately
28//! [`Unsupported`](dig_chainsource_interface::ChainSourceError::Unsupported): a genuine forward walk
29//! launcher → tip needs the CLVM singleton-shape machinery (per hop) that would make this "point-read"
30//! source anything but lightweight. A consumer needing lineage uses the full
31//! [`ChiaQueryProvider`](super::ChiaQueryProvider), or composes
32//! [`parent_spend`](ChainSource::parent_spend) walks itself. This is a fail-closed `Err`, not a false
33//! `Ok(None)`.
34
35use std::future::Future;
36use std::sync::Arc;
37
38use chia_protocol::{Bytes32, CoinSpend};
39use dig_chainsource_interface::{ChainSource, ChainSourceError, CoinRecord, SingletonLineage};
40use tokio::runtime::Runtime;
41
42use super::bridge::run_blocking;
43use super::convert::{bytes32_to_hex, coin_record_from_chq, coin_spend_from_chq, map_query_error};
44use super::providers::CoinsetProvider;
45use crate::coinset::transport::HttpTransport;
46use crate::coinset::CoinsetClient;
47
48/// The canonical coinset.org base URL — the ecosystem's default chain-read tier (the same host the
49/// full router and the drift monitor use). Overridable via [`CoinsetChainSource::from_env`] or an
50/// explicit [`from_url`](CoinsetChainSource::from_url).
51pub const DEFAULT_COINSET_URL: &str = "https://api.coinset.org";
52
53/// The environment variable that overrides the coinset base URL (canonical: the chain-read tier's
54/// `$DIG_COINSET_URL` / `--coinset-url` override, distinct from the §5.3 content-read ladder).
55pub const COINSET_URL_ENV: &str = "DIG_COINSET_URL";
56
57/// The default per-request timeout for the lightweight source, matching the full router's coinset
58/// timeout so behaviour is consistent across chia-query's two coinset paths.
59#[cfg(feature = "native")]
60const DEFAULT_REQUEST_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(30);
61
62/// The try-order priority [`CoinsetProvider::from_url`] registers coinset at — a public oracle is
63/// tried after an operator's local node (priority 0) and any DIG-peers tier, matching how the
64/// ecosystem orders the coinset fallback last.
65const COINSET_PROVIDER_PRIORITY: i32 = 30;
66
67/// The upper bound on coin records the source accepts from a single list read. A misbehaving or
68/// hostile coinset endpoint could answer a puzzle-hash/parent query with an unbounded list; capping
69/// it fails closed ([`ChainSourceError::TooManyRecords`]) rather than letting the record count drive
70/// unbounded DOWNSTREAM work. This record cap is complementary to — not a substitute for — the
71/// transport-level byte cap (`MAX_RESPONSE_BYTES` in [`crate::coinset::transport`]), which bounds the
72/// RECEIVE/PARSE peak by rejecting an over-large body before it is fully buffered and deserialized;
73/// this cap then bounds the work done on the records that survive that parse.
74const MAX_COIN_RECORDS: usize = 100_000;
75
76/// A synchronous, no-handshake [`ChainSource`] served entirely by coinset.org HTTP.
77///
78/// Generic over the [`HttpTransport`] so production uses the native `reqwest` transport while tests
79/// inject a mock; [`from_url`](Self::from_url) / [`from_env`](Self::from_env) build the native
80/// variant. It owns its runtime, so cloning shares one runtime + client via [`Arc`].
81#[derive(Clone)]
82pub struct CoinsetChainSource<T: HttpTransport> {
83    client: Arc<CoinsetClient<T>>,
84    runtime: Arc<Runtime>,
85}
86
87#[cfg(feature = "native")]
88impl CoinsetChainSource<crate::coinset::transport::ReqwestTransport> {
89    /// Builds a lightweight coinset source against `coinset_url`, with NO peer handshake or certs.
90    ///
91    /// Fails closed with [`ChainSourceError::Transport`] if the HTTP client or the owned runtime
92    /// cannot be constructed.
93    pub fn from_url(coinset_url: &str) -> Result<Self, ChainSourceError> {
94        let client = CoinsetClient::new(coinset_url, DEFAULT_REQUEST_TIMEOUT)
95            .map_err(|e| ChainSourceError::Transport(e.to_string()))?;
96        Self::with_client(client)
97    }
98
99    /// Builds a lightweight coinset source from the environment: `$DIG_COINSET_URL` when set and
100    /// non-empty, else [`DEFAULT_COINSET_URL`].
101    pub fn from_env() -> Result<Self, ChainSourceError> {
102        Self::from_url(&coinset_url_from_env())
103    }
104}
105
106impl<T: HttpTransport> CoinsetChainSource<T> {
107    /// Builds a source from an already-constructed [`CoinsetClient`], provisioning the owned
108    /// multi-thread runtime the sync facade blocks on. Used by [`from_url`](Self::from_url) and, in
109    /// tests, with a mock transport.
110    pub fn with_client(client: CoinsetClient<T>) -> Result<Self, ChainSourceError> {
111        let runtime = tokio::runtime::Builder::new_multi_thread()
112            .worker_threads(1)
113            .enable_all()
114            .build()
115            .map_err(|e| {
116                ChainSourceError::Transport(format!("failed to build coinset source runtime: {e}"))
117            })?;
118        Ok(Self {
119            client: Arc::new(client),
120            runtime: Arc::new(runtime),
121        })
122    }
123
124    /// Drives an async coinset read to completion on the owned runtime, translating a runtime-misuse
125    /// panic into a clear [`ChainSourceError`] (see [`run_blocking`]).
126    fn block_on<F>(&self, fut: F) -> Result<F::Output, ChainSourceError>
127    where
128        F: Future,
129    {
130        run_blocking(self.runtime.handle(), fut)
131    }
132}
133
134impl<T: HttpTransport> ChainSource for CoinsetChainSource<T> {
135    type Error = ChainSourceError;
136
137    fn coin_record(&self, coin_id: Bytes32) -> Result<Option<CoinRecord>, Self::Error> {
138        let name = bytes32_to_hex(coin_id);
139        let record = self
140            .block_on(self.client.get_coin_record_by_name_opt(&name))?
141            .map_err(map_query_error)?;
142        record.as_ref().map(coin_record_from_chq).transpose()
143    }
144
145    fn coin_records_by_puzzle_hash(
146        &self,
147        puzzle_hash: Bytes32,
148        include_spent: bool,
149    ) -> Result<Vec<CoinRecord>, Self::Error> {
150        let hash = bytes32_to_hex(puzzle_hash);
151        let records = self
152            .block_on(self.client.get_coin_records_by_puzzle_hash(
153                &hash,
154                None,
155                None,
156                include_spent,
157            ))?
158            .map_err(map_query_error)?;
159        convert_records(records)
160    }
161
162    fn coin_records_by_parent(
163        &self,
164        parent_coin_id: Bytes32,
165    ) -> Result<Vec<CoinRecord>, Self::Error> {
166        // The interface wants every child, so include spent coins.
167        let parent_ids = [bytes32_to_hex(parent_coin_id)];
168        let records = self
169            .block_on(
170                self.client
171                    .get_coin_records_by_parent_ids(&parent_ids, None, None, true),
172            )?
173            .map_err(map_query_error)?;
174        convert_records(records)
175    }
176
177    fn coin_spend(&self, coin_id: Bytes32) -> Result<Option<CoinSpend>, Self::Error> {
178        let id = bytes32_to_hex(coin_id);
179        let spend = self
180            .block_on(self.client.get_puzzle_and_solution_opt(&id, None))?
181            .map_err(map_query_error)?;
182        spend.as_ref().map(coin_spend_from_chq).transpose()
183    }
184
185    /// Deliberately unsupported: a genuine launcher → tip walk is not a lightweight coinset
186    /// point-read (see the module docs). Fails closed rather than returning a misleading `Ok(None)`.
187    fn resolve_singleton_lineage(
188        &self,
189        _launcher_id: Bytes32,
190    ) -> Result<Option<SingletonLineage>, Self::Error> {
191        Err(ChainSourceError::Unsupported(
192            "resolve_singleton_lineage is not served by the lightweight coinset source; use \
193             ChiaQueryProvider or walk parent_spend",
194        ))
195    }
196
197    fn peak_height(&self) -> Result<Option<u32>, Self::Error> {
198        let state = self
199            .block_on(self.client.get_blockchain_state())?
200            .map_err(map_query_error)?;
201        Ok(state.peak.map(|peak| peak.height))
202    }
203
204    fn block_timestamp(&self, height: u32) -> Result<Option<u64>, Self::Error> {
205        let record = self
206            .block_on(self.client.get_block_record_by_height_opt(height))?
207            .map_err(map_query_error)?;
208        Ok(record.and_then(|record| record.timestamp))
209    }
210}
211
212/// Converts a coinset list response into interface records, failing closed if the list exceeds
213/// [`MAX_COIN_RECORDS`] (hostile-input bound, reported as [`ChainSourceError::TooManyRecords`]) or if
214/// any record is malformed.
215fn convert_records(
216    records: Vec<crate::types::CoinRecord>,
217) -> Result<Vec<CoinRecord>, ChainSourceError> {
218    if records.len() > MAX_COIN_RECORDS {
219        return Err(ChainSourceError::TooManyRecords {
220            count: records.len(),
221            limit: MAX_COIN_RECORDS,
222        });
223    }
224    records.iter().map(coin_record_from_chq).collect()
225}
226
227/// The coinset base URL from the environment: `$DIG_COINSET_URL` when set and non-empty, else the
228/// canonical [`DEFAULT_COINSET_URL`].
229fn coinset_url_from_env() -> String {
230    std::env::var(COINSET_URL_ENV)
231        .ok()
232        .map(|url| url.trim().to_string())
233        .filter(|url| !url.is_empty())
234        .unwrap_or_else(|| DEFAULT_COINSET_URL.to_string())
235}
236
237#[cfg(feature = "native")]
238impl CoinsetProvider<CoinsetChainSource<crate::coinset::transport::ReqwestTransport>> {
239    /// Builds a registry-ready coinset provider against `coinset_url`, with NO peer handshake.
240    ///
241    /// The provider registers as a [`ProviderKind::PublicOracle`] (`trustless: false`) at the
242    /// coinset try-order priority; hand it to a
243    /// [`ProviderRegistry`](super::ProviderRegistry) by dependency injection like any other source.
244    pub fn from_url(coinset_url: &str) -> Result<Self, ChainSourceError> {
245        let source = CoinsetChainSource::from_url(coinset_url)?;
246        Ok(CoinsetProvider::new(
247            "coinset.org",
248            COINSET_PROVIDER_PRIORITY,
249            source,
250        ))
251    }
252
253    /// Builds a registry-ready coinset provider from `$DIG_COINSET_URL` (or [`DEFAULT_COINSET_URL`]).
254    pub fn from_env() -> Result<Self, ChainSourceError> {
255        let source = CoinsetChainSource::from_env()?;
256        Ok(CoinsetProvider::new(
257            "coinset.org",
258            COINSET_PROVIDER_PRIORITY,
259            source,
260        ))
261    }
262}
263
264#[cfg(test)]
265mod tests {
266    use super::*;
267    use std::sync::Mutex;
268
269    use chia_protocol::Coin;
270    use dig_chainsource_interface::{ChainSourceProvider, ProviderKind};
271    use serde_json::{json, Value};
272
273    use crate::coinset::transport::HttpTransport;
274    use crate::types::ChiaQueryError;
275
276    /// A scripted [`HttpTransport`] that returns a canned JSON body per endpoint (the trailing path
277    /// segment of the POST URL), or a scripted transport error — so the source is exercised with no
278    /// network and no peer handshake.
279    #[derive(Default)]
280    struct MockTransport {
281        responses: Mutex<std::collections::HashMap<String, Value>>,
282        fail: Mutex<Option<String>>,
283    }
284
285    impl MockTransport {
286        fn with(endpoint: &str, body: Value) -> Self {
287            let t = MockTransport::default();
288            t.responses
289                .lock()
290                .unwrap()
291                .insert(endpoint.to_string(), body);
292            t
293        }
294
295        fn add(self, endpoint: &str, body: Value) -> Self {
296            self.responses
297                .lock()
298                .unwrap()
299                .insert(endpoint.to_string(), body);
300            self
301        }
302
303        fn failing(msg: &str) -> Self {
304            let t = MockTransport::default();
305            *t.fail.lock().unwrap() = Some(msg.to_string());
306            t
307        }
308    }
309
310    impl HttpTransport for MockTransport {
311        async fn post_json(&self, url: String, _body: Value) -> Result<Value, ChiaQueryError> {
312            if let Some(msg) = self.fail.lock().unwrap().clone() {
313                return Err(ChiaQueryError::CoinsetHttp(msg));
314            }
315            let endpoint = url.rsplit('/').next().unwrap_or_default().to_string();
316            self.responses
317                .lock()
318                .unwrap()
319                .get(&endpoint)
320                .cloned()
321                .ok_or_else(|| ChiaQueryError::CoinsetHttp(format!("no mock for `{endpoint}`")))
322        }
323    }
324
325    fn source(transport: MockTransport) -> CoinsetChainSource<MockTransport> {
326        let client = CoinsetClient::with_transport("https://coinset.test", transport);
327        CoinsetChainSource::with_client(client).expect("build source")
328    }
329
330    fn coin_id() -> Bytes32 {
331        Coin::new(Bytes32::new([0x11; 32]), Bytes32::new([0x22; 32]), 1).coin_id()
332    }
333
334    fn hex32(byte: u8) -> String {
335        format!("0x{}", hex::encode([byte; 32]))
336    }
337
338    fn coin_record_json(spent: bool) -> Value {
339        json!({
340            "coin": { "parent_coin_info": hex32(0x11), "puzzle_hash": hex32(0x22), "amount": 1 },
341            "confirmed_block_index": 100,
342            "spent_block_index": if spent { 200 } else { 0 },
343            "spent": spent,
344            "coinbase": false,
345            "timestamp": 1_700_000_000_u64
346        })
347    }
348
349    // ---- construction ----
350
351    #[test]
352    fn coinset_provider_from_url_constructs_without_handshake() {
353        // No Peer, no cert, no sync — just a base URL.
354        let provider =
355            CoinsetProvider::from_url("https://coinset.test").expect("construct from url");
356        let info = provider.provider_info();
357        assert_eq!(info.kind, ProviderKind::PublicOracle);
358        assert!(!info.trustless, "a public oracle is never trustless");
359        assert_eq!(info.priority, COINSET_PROVIDER_PRIORITY);
360    }
361
362    #[test]
363    fn from_env_reads_dig_coinset_url_then_defaults() {
364        // Env override wins.
365        std::env::set_var(COINSET_URL_ENV, "https://env.coinset.test");
366        assert_eq!(coinset_url_from_env(), "https://env.coinset.test");
367
368        // Blank/unset falls back to the canonical default.
369        std::env::set_var(COINSET_URL_ENV, "   ");
370        assert_eq!(coinset_url_from_env(), DEFAULT_COINSET_URL);
371        std::env::remove_var(COINSET_URL_ENV);
372        assert_eq!(coinset_url_from_env(), DEFAULT_COINSET_URL);
373    }
374
375    // ---- point-reads ----
376
377    #[test]
378    fn coin_record_reads_over_coinset_http() {
379        let src = source(MockTransport::with(
380            "get_coin_record_by_name",
381            json!({ "success": true, "coin_record": coin_record_json(false) }),
382        ));
383        let record = src.coin_record(coin_id()).unwrap().expect("record present");
384        assert_eq!(record.confirmed_height, Some(100));
385    }
386
387    #[test]
388    fn coin_record_absence_is_ok_none_not_err() {
389        let src = source(MockTransport::with(
390            "get_coin_record_by_name",
391            json!({ "success": true, "coin_record": null }),
392        ));
393        assert_eq!(src.coin_record(coin_id()).unwrap(), None);
394    }
395
396    #[test]
397    fn coin_record_transport_error_fails_closed_never_false_absence() {
398        let src = source(MockTransport::failing("socket reset"));
399        let err = src.coin_record(coin_id()).unwrap_err();
400        assert!(
401            matches!(err, ChainSourceError::Transport(_)),
402            "a transport failure MUST be Err, never Ok(None)"
403        );
404    }
405
406    #[test]
407    fn coin_spend_reads_the_spend_that_spent_the_coin() {
408        let src = source(MockTransport::with(
409            "get_puzzle_and_solution",
410            json!({
411                "success": true,
412                "coin_solution": {
413                    "coin": { "parent_coin_info": hex32(0x11), "puzzle_hash": hex32(0x22), "amount": 1 },
414                    "puzzle_reveal": "0xff",
415                    "solution": "0x80"
416                }
417            }),
418        ));
419        assert!(src.coin_spend(coin_id()).unwrap().is_some());
420    }
421
422    #[test]
423    fn coin_spend_unspent_is_ok_none() {
424        let src = source(MockTransport::with(
425            "get_puzzle_and_solution",
426            json!({ "success": true, "coin_solution": null }),
427        ));
428        assert_eq!(src.coin_spend(coin_id()).unwrap(), None);
429    }
430
431    #[test]
432    fn coin_records_by_puzzle_hash_maps_the_list() {
433        let src = source(MockTransport::with(
434            "get_coin_records_by_puzzle_hash",
435            json!({ "success": true, "coin_records": [coin_record_json(false)] }),
436        ));
437        let records = src
438            .coin_records_by_puzzle_hash(Bytes32::new([0x22; 32]), true)
439            .unwrap();
440        assert_eq!(records.len(), 1);
441    }
442
443    #[test]
444    fn coin_records_by_parent_maps_the_list() {
445        let src = source(MockTransport::with(
446            "get_coin_records_by_parent_ids",
447            json!({ "success": true, "coin_records": [coin_record_json(true)] }),
448        ));
449        let records = src
450            .coin_records_by_parent(Bytes32::new([0x11; 32]))
451            .unwrap();
452        assert_eq!(records.len(), 1);
453    }
454
455    #[test]
456    fn peak_height_reads_the_blockchain_state_peak() {
457        let src = source(MockTransport::with(
458            "get_blockchain_state",
459            json!({
460                "success": true,
461                "blockchain_state": { "peak": { "height": 5_000_123 } }
462            }),
463        ));
464        assert_eq!(src.peak_height().unwrap(), Some(5_000_123));
465    }
466
467    #[test]
468    fn block_timestamp_reads_the_block_record() {
469        let src = source(MockTransport::with(
470            "get_block_record_by_height",
471            json!({
472                "success": true,
473                "block_record": { "height": 42, "timestamp": 1_700_000_000_u64 }
474            }),
475        ));
476        assert_eq!(src.block_timestamp(42).unwrap(), Some(1_700_000_000));
477    }
478
479    #[test]
480    fn block_timestamp_absent_block_is_ok_none() {
481        let src = source(MockTransport::with(
482            "get_block_record_by_height",
483            json!({ "success": true, "block_record": null }),
484        ));
485        assert_eq!(src.block_timestamp(999).unwrap(), None);
486    }
487
488    // ---- unsupported (fail-closed) ----
489
490    #[test]
491    fn resolve_singleton_lineage_is_unsupported_not_false_absence() {
492        let src = source(MockTransport::default());
493        let err = src
494            .resolve_singleton_lineage(Bytes32::new([0x33; 32]))
495            .unwrap_err();
496        assert!(
497            matches!(err, ChainSourceError::Unsupported(_)),
498            "lineage MUST fail closed as Unsupported, never Ok(None)"
499        );
500    }
501
502    // ---- hostile-input bound ----
503
504    #[test]
505    fn oversized_coin_record_list_fails_closed() {
506        let flood: Vec<Value> = (0..=MAX_COIN_RECORDS)
507            .map(|_| coin_record_json(false))
508            .collect();
509        let src = source(MockTransport::default().add(
510            "get_coin_records_by_puzzle_hash",
511            json!({ "success": true, "coin_records": flood }),
512        ));
513        let err = src
514            .coin_records_by_puzzle_hash(Bytes32::new([0x22; 32]), true)
515            .unwrap_err();
516        assert!(
517            matches!(
518                err,
519                ChainSourceError::TooManyRecords { count, limit }
520                    if count == MAX_COIN_RECORDS + 1 && limit == MAX_COIN_RECORDS
521            ),
522            "an unbounded coinset list MUST fail closed as TooManyRecords, not be returned"
523        );
524    }
525}