chia_query/provider_registry/
coinset_source.rs1use 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
48pub const DEFAULT_COINSET_URL: &str = "https://api.coinset.org";
52
53pub const COINSET_URL_ENV: &str = "DIG_COINSET_URL";
56
57#[cfg(feature = "native")]
60const DEFAULT_REQUEST_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(30);
61
62const COINSET_PROVIDER_PRIORITY: i32 = 30;
66
67const MAX_COIN_RECORDS: usize = 100_000;
75
76#[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 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 pub fn from_env() -> Result<Self, ChainSourceError> {
102 Self::from_url(&coinset_url_from_env())
103 }
104}
105
106impl<T: HttpTransport> CoinsetChainSource<T> {
107 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 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 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 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
212fn 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
227fn 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 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 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 #[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 #[test]
352 fn coinset_provider_from_url_constructs_without_handshake() {
353 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 std::env::set_var(COINSET_URL_ENV, "https://env.coinset.test");
366 assert_eq!(coinset_url_from_env(), "https://env.coinset.test");
367
368 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 #[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 #[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 #[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}