1use std::sync::Arc;
34
35use chia_protocol::{Bytes32, CoinSpend, CoinState, CoinStateFilters, Program};
36use dig_chainsource_interface::{
37 ChainSource, ChainSourceError, ChainSourceProvider, CoinRecord, ProviderInfo, SingletonLineage,
38};
39use tokio::runtime::Handle;
40use tokio::sync::RwLock;
41
42use super::cache::CoinStateCache;
43use super::fetcher::CoinStateFetcher;
44use crate::provider_registry::bridge::run_blocking;
45
46#[derive(Clone)]
50pub struct LightClientProvider {
51 fetcher: Arc<dyn CoinStateFetcher>,
52 cache: Arc<RwLock<CoinStateCache>>,
53 handle: Handle,
54 info: ProviderInfo,
55}
56
57impl LightClientProvider {
58 pub fn new(
62 fetcher: Arc<dyn CoinStateFetcher>,
63 cache: Arc<RwLock<CoinStateCache>>,
64 handle: Handle,
65 info: ProviderInfo,
66 ) -> Self {
67 Self {
68 fetcher,
69 cache,
70 handle,
71 info,
72 }
73 }
74
75 fn coin_state(&self, coin_id: Bytes32) -> Result<Option<CoinState>, ChainSourceError> {
77 let fetcher = self.fetcher.clone();
78 let cache = self.cache.clone();
79 run_blocking(&self.handle, async move {
80 if let Some(cached) = cache.read().await.get(coin_id) {
81 return Ok(Some(cached));
82 }
83 let states = fetcher.coin_states(vec![coin_id], false).await?;
84 Ok::<_, super::error::LightClientError>(
85 states.into_iter().find(|s| s.coin.coin_id() == coin_id),
86 )
87 })?
88 .map_err(ChainSourceError::from)
89 }
90}
91
92impl ChainSource for LightClientProvider {
93 type Error = ChainSourceError;
94
95 fn coin_record(&self, coin_id: Bytes32) -> Result<Option<CoinRecord>, Self::Error> {
96 let peak = self.peak_height()?;
97 Ok(self
98 .coin_state(coin_id)?
99 .map(CoinRecord::from_coin_state)
100 .map(|record| clamp_record_heights_to_peak(record, peak)))
101 }
102
103 fn coin_records_by_puzzle_hash(
104 &self,
105 puzzle_hash: Bytes32,
106 include_spent: bool,
107 ) -> Result<Vec<CoinRecord>, Self::Error> {
108 let fetcher = self.fetcher.clone();
109 let filters = CoinStateFilters {
110 include_spent,
111 include_unspent: true,
112 include_hinted: true,
113 min_amount: 0,
114 };
115 let states = run_blocking(&self.handle, async move {
116 fetcher
117 .puzzle_states(vec![puzzle_hash], filters, false)
118 .await
119 })?
120 .map_err(ChainSourceError::from)?;
121 let peak = self.peak_height()?;
122 Ok(states
123 .into_iter()
124 .map(CoinRecord::from_coin_state)
125 .map(|record| clamp_record_heights_to_peak(record, peak))
126 .collect())
127 }
128
129 fn coin_records_by_parent(
130 &self,
131 parent_coin_id: Bytes32,
132 ) -> Result<Vec<CoinRecord>, Self::Error> {
133 let fetcher = self.fetcher.clone();
134 let states = run_blocking(&self.handle, async move {
135 fetcher.children(parent_coin_id).await
136 })?
137 .map_err(ChainSourceError::from)?;
138 let peak = self.peak_height()?;
139 Ok(states
140 .into_iter()
141 .map(CoinRecord::from_coin_state)
142 .map(|record| clamp_record_heights_to_peak(record, peak))
143 .collect())
144 }
145
146 fn coin_spend(&self, coin_id: Bytes32) -> Result<Option<CoinSpend>, Self::Error> {
147 let Some(state) = self.coin_state(coin_id)? else {
150 return Ok(None);
151 };
152 let Some(spent_height) = state.spent_height else {
153 return Ok(None);
154 };
155 let fetcher = self.fetcher.clone();
156 let (puzzle, solution) = run_blocking(&self.handle, async move {
157 fetcher.puzzle_and_solution(coin_id, spent_height).await
158 })?
159 .map_err(ChainSourceError::from)?;
160
161 verify_reveal_matches(&puzzle, state.coin.puzzle_hash)?;
164 Ok(Some(CoinSpend::new(state.coin, puzzle, solution)))
165 }
166
167 fn resolve_singleton_lineage(
168 &self,
169 _launcher_id: Bytes32,
170 ) -> Result<Option<SingletonLineage>, Self::Error> {
171 Err(ChainSourceError::Unsupported(
172 "singleton lineage resolution is not provided by the light-client source; \
173 use an aggregating chain source",
174 ))
175 }
176
177 fn peak_height(&self) -> Result<Option<u32>, Self::Error> {
178 let cache = self.cache.clone();
179 let peak = run_blocking(&self.handle, async move { cache.read().await.peak() })?;
180 Ok(peak.map(|(height, _)| height))
181 }
182
183 fn block_timestamp(&self, _height: u32) -> Result<Option<u64>, Self::Error> {
184 Err(ChainSourceError::Unsupported(
185 "block timestamps are not indexed by the light-client source",
186 ))
187 }
188}
189
190impl ChainSourceProvider for LightClientProvider {
191 fn provider_info(&self) -> ProviderInfo {
192 self.info.clone()
193 }
194}
195
196fn clamp_record_heights_to_peak(mut record: CoinRecord, peak: Option<u32>) -> CoinRecord {
214 let Some(peak) = peak else { return record };
215 if let Some(confirmed) = record.confirmed_height {
216 record.confirmed_height = Some(confirmed.min(peak));
217 }
218 if let Some(spent) = record.spent_height {
219 record.spent_height = Some(spent.min(peak));
220 }
221 record
222}
223
224fn verify_reveal_matches(puzzle: &Program, expected: Bytes32) -> Result<(), ChainSourceError> {
227 let actual: Bytes32 = chia_wallet_sdk::clvm_utils::tree_hash_from_bytes(puzzle.as_ref())
228 .map_err(|e| ChainSourceError::Malformed(format!("undecodable puzzle reveal: {e}")))?
229 .into();
230 if actual != expected {
231 return Err(ChainSourceError::Malformed(
232 "puzzle reveal does not hash to the coin's puzzle hash".into(),
233 ));
234 }
235 Ok(())
236}
237
238#[cfg(test)]
239mod tests {
240 use super::*;
241 use crate::peer::light_client::error::LightClientError;
242 use async_trait::async_trait;
243 use chia_protocol::{Coin, Program};
244 use dig_chainsource_interface::{ProviderId, ProviderKind};
245 use std::borrow::Cow;
246
247 #[derive(Default, Clone)]
250 struct MockFetcher {
251 coin_states: Vec<CoinState>,
252 fail: Option<LightClientError>,
253 children: Vec<CoinState>,
254 puzzle_states: Vec<CoinState>,
255 reveal: Option<(Program, Program)>,
256 }
257
258 #[async_trait]
259 impl CoinStateFetcher for MockFetcher {
260 async fn coin_states(
261 &self,
262 _coin_ids: Vec<Bytes32>,
263 _subscribe: bool,
264 ) -> Result<Vec<CoinState>, LightClientError> {
265 match &self.fail {
266 Some(e) => Err(e.clone()),
267 None => Ok(self.coin_states.clone()),
268 }
269 }
270 async fn puzzle_states(
271 &self,
272 _puzzle_hashes: Vec<Bytes32>,
273 _filters: CoinStateFilters,
274 _subscribe: bool,
275 ) -> Result<Vec<CoinState>, LightClientError> {
276 match &self.fail {
277 Some(e) => Err(e.clone()),
278 None => Ok(self.puzzle_states.clone()),
279 }
280 }
281 async fn children(&self, _coin_id: Bytes32) -> Result<Vec<CoinState>, LightClientError> {
282 match &self.fail {
283 Some(e) => Err(e.clone()),
284 None => Ok(self.children.clone()),
285 }
286 }
287 async fn puzzle_and_solution(
288 &self,
289 _coin_id: Bytes32,
290 _height: u32,
291 ) -> Result<(Program, Program), LightClientError> {
292 if let Some(e) = &self.fail {
293 return Err(e.clone());
294 }
295 match &self.reveal {
296 Some(reveal) => Ok(reveal.clone()),
297 None => Err(LightClientError::Rejected("no reveal".into())),
299 }
300 }
301 }
302
303 fn reveal_and_matching_puzzle_hash() -> (Program, Bytes32) {
306 let puzzle = Program::from(vec![1u8]);
307 let ph: Bytes32 = chia_wallet_sdk::clvm_utils::tree_hash_from_bytes(puzzle.as_ref())
308 .unwrap()
309 .into();
310 (puzzle, ph)
311 }
312
313 fn info() -> ProviderInfo {
314 ProviderInfo {
315 id: ProviderId(Cow::Borrowed("chia-query-light-client-test")),
316 kind: ProviderKind::Custom,
317 priority: 20,
318 trustless: false,
319 }
320 }
321
322 fn provider_with(fetcher: MockFetcher) -> (tokio::runtime::Runtime, LightClientProvider) {
323 provider_with_peak(fetcher, None)
324 }
325
326 fn provider_with_peak(
329 fetcher: MockFetcher,
330 peak: Option<u32>,
331 ) -> (tokio::runtime::Runtime, LightClientProvider) {
332 let rt = tokio::runtime::Builder::new_multi_thread()
333 .worker_threads(1)
334 .enable_all()
335 .build()
336 .expect("multi-thread runtime");
337 let mut cache = CoinStateCache::new();
338 if let Some(height) = peak {
339 cache.set_peak(height, Bytes32::new([0xAB; 32]));
340 }
341 let provider = LightClientProvider::new(
342 Arc::new(fetcher),
343 Arc::new(RwLock::new(cache)),
344 rt.handle().clone(),
345 info(),
346 );
347 (rt, provider)
348 }
349
350 fn call<T: Send>(f: impl FnOnce() -> T + Send) -> T {
352 std::thread::scope(|s| s.spawn(f).join().expect("thread panicked"))
353 }
354
355 fn coin(seed: u8) -> Coin {
356 Coin::new(Bytes32::new([seed; 32]), Bytes32::new([seed ^ 1; 32]), 1)
357 }
358
359 #[test]
362 fn coin_record_returns_some_for_a_known_coin() {
363 let c = coin(7);
364 let id = c.coin_id();
365 let fetcher = MockFetcher {
366 coin_states: vec![CoinState {
367 coin: c,
368 created_height: Some(100),
369 spent_height: None,
370 }],
371 ..Default::default()
372 };
373 let (_rt, provider) = provider_with(fetcher);
374 let record = call(move || provider.coin_record(id)).expect("read ok");
375 assert!(record.is_some());
376 assert_eq!(record.unwrap().confirmed_height, Some(100));
377 }
378
379 #[test]
384 fn live_fetched_coin_above_peak_reports_clamped_confirmed_height() {
385 let c = coin(11);
386 let id = c.coin_id();
387 let fetcher = MockFetcher {
388 coin_states: vec![CoinState {
389 coin: c,
390 created_height: Some(1_000_001), spent_height: None,
392 }],
393 ..Default::default()
394 };
395 let (_rt, provider) = provider_with_peak(fetcher, Some(1_000_000));
396 let record = call(move || provider.coin_record(id))
397 .expect("read ok")
398 .expect("coin present, never omitted");
399 assert_eq!(
400 record.confirmed_height,
401 Some(1_000_000),
402 "an above-peak live coin must clamp to the peak (0 confirmations), never overstate"
403 );
404 }
405
406 #[test]
409 fn live_fetched_coin_at_or_below_peak_is_unaffected() {
410 let c = coin(12);
411 let id = c.coin_id();
412 let fetcher = MockFetcher {
413 coin_states: vec![CoinState {
414 coin: c,
415 created_height: Some(900_000),
416 spent_height: None,
417 }],
418 ..Default::default()
419 };
420 let (_rt, provider) = provider_with_peak(fetcher, Some(1_000_000));
421 let record = call(move || provider.coin_record(id))
422 .expect("read ok")
423 .expect("coin present");
424 assert_eq!(record.confirmed_height, Some(900_000));
425 }
426
427 #[test]
429 fn discovery_reads_clamp_above_peak_confirmed_height() {
430 let fetcher = MockFetcher {
431 puzzle_states: vec![CoinState {
432 coin: coin(13),
433 created_height: Some(2_000_000),
434 spent_height: Some(2_000_000),
435 }],
436 children: vec![CoinState {
437 coin: coin(14),
438 created_height: Some(2_000_000),
439 spent_height: Some(2_000_000),
440 }],
441 ..Default::default()
442 };
443 let (_rt, provider) = provider_with_peak(fetcher, Some(1_000_000));
444 let ph = Bytes32::new([8; 32]);
445 let parent = Bytes32::new([9; 32]);
446 let p = provider.clone();
447 let by_ph = call(move || p.coin_records_by_puzzle_hash(ph, true)).unwrap();
448 assert_eq!(by_ph[0].confirmed_height, Some(1_000_000));
449 assert_eq!(by_ph[0].spent_height, Some(1_000_000));
450 let by_parent = call(move || provider.coin_records_by_parent(parent)).unwrap();
451 assert_eq!(by_parent[0].confirmed_height, Some(1_000_000));
452 assert_eq!(by_parent[0].spent_height, Some(1_000_000));
453 }
454
455 #[test]
461 fn live_fetched_coin_spent_above_peak_reports_clamped_spent_height() {
462 let c = coin(15);
463 let id = c.coin_id();
464 let fetcher = MockFetcher {
465 coin_states: vec![CoinState {
466 coin: c,
467 created_height: Some(999_999),
468 spent_height: Some(1_000_001), }],
470 ..Default::default()
471 };
472 let (_rt, provider) = provider_with_peak(fetcher, Some(1_000_000));
473 let record = call(move || provider.coin_record(id))
474 .expect("read ok")
475 .expect("coin present, never omitted");
476 assert_eq!(
477 record.spent_height,
478 Some(1_000_000),
479 "an above-peak spent coin must clamp spent_height to the peak (0 spend-depth), never overstate"
480 );
481 assert!(
482 record.is_spent(),
483 "the coin IS spent — clamping the reported height must never drop the spent flag"
484 );
485 }
486
487 #[test]
490 fn live_fetched_coin_spent_at_or_below_peak_is_unaffected() {
491 let c = coin(16);
492 let id = c.coin_id();
493 let fetcher = MockFetcher {
494 coin_states: vec![CoinState {
495 coin: c,
496 created_height: Some(800_000),
497 spent_height: Some(900_000),
498 }],
499 ..Default::default()
500 };
501 let (_rt, provider) = provider_with_peak(fetcher, Some(1_000_000));
502 let record = call(move || provider.coin_record(id))
503 .expect("read ok")
504 .expect("coin present");
505 assert_eq!(record.spent_height, Some(900_000));
506 }
507
508 #[test]
511 fn coin_spend_of_coin_spent_above_peak_still_identified_as_spent() {
512 let (puzzle, ph) = reveal_and_matching_puzzle_hash();
513 let c = Coin::new(Bytes32::new([17; 32]), ph, 1);
514 let id = c.coin_id();
515 let fetcher = MockFetcher {
516 coin_states: vec![CoinState {
517 coin: c,
518 created_height: Some(999_999),
519 spent_height: Some(1_000_001), }],
521 reveal: Some((puzzle, Program::from(vec![2u8]))),
522 ..Default::default()
523 };
524 let (_rt, provider) = provider_with_peak(fetcher, Some(1_000_000));
525 let spend = call(move || provider.coin_spend(id))
526 .unwrap()
527 .expect("a coin spent above the lagged peak is still spent");
528 assert_eq!(spend.coin, c);
529 }
530
531 #[test]
532 fn coin_record_returns_none_for_provable_absence() {
533 let (_rt, provider) = provider_with(MockFetcher::default());
534 let id = coin(9).coin_id();
535 let record = call(move || provider.coin_record(id)).expect("read ok");
536 assert_eq!(record, None);
537 }
538
539 #[test]
540 fn transport_failure_is_err_never_false_absence() {
541 let fetcher = MockFetcher {
542 fail: Some(LightClientError::Transport("socket reset".into())),
543 ..Default::default()
544 };
545 let (_rt, provider) = provider_with(fetcher);
546 let id = coin(3).coin_id();
547 let result = call(move || provider.coin_record(id));
548 assert!(
549 matches!(result, Err(ChainSourceError::Transport(_))),
550 "a transport failure MUST be Err, never Ok(None): {result:?}"
551 );
552 }
553
554 #[test]
555 fn coin_spend_of_unspent_coin_is_none() {
556 let c = coin(4);
557 let id = c.coin_id();
558 let fetcher = MockFetcher {
559 coin_states: vec![CoinState {
560 coin: c,
561 created_height: Some(10),
562 spent_height: None,
563 }],
564 ..Default::default()
565 };
566 let (_rt, provider) = provider_with(fetcher);
567 assert_eq!(call(move || provider.coin_spend(id)).unwrap(), None);
568 }
569
570 #[test]
571 fn coin_spend_of_spent_coin_assembles_from_real_coin() {
572 let (puzzle, ph) = reveal_and_matching_puzzle_hash();
573 let c = Coin::new(Bytes32::new([5; 32]), ph, 1);
574 let id = c.coin_id();
575 let fetcher = MockFetcher {
576 coin_states: vec![CoinState {
577 coin: c,
578 created_height: Some(10),
579 spent_height: Some(20),
580 }],
581 reveal: Some((puzzle, Program::from(vec![2u8]))),
582 ..Default::default()
583 };
584 let (_rt, provider) = provider_with(fetcher);
585 let spend = call(move || provider.coin_spend(id))
586 .unwrap()
587 .expect("spend");
588 assert_eq!(spend.coin, c);
589 }
590
591 #[test]
594 fn coin_spend_of_spent_coin_with_rejected_reveal_is_err_never_none() {
595 let c = coin(6);
596 let id = c.coin_id();
597 let fetcher = MockFetcher {
598 coin_states: vec![CoinState {
599 coin: c,
600 created_height: Some(10),
601 spent_height: Some(20),
602 }],
603 reveal: None, ..Default::default()
605 };
606 let (_rt, provider) = provider_with(fetcher);
607 let result = call(move || provider.coin_spend(id));
608 assert!(
609 matches!(result, Err(ChainSourceError::Transport(_))),
610 "a rejected reveal for a spent coin must be Err, never Ok(None): {result:?}"
611 );
612 }
613
614 #[test]
617 fn coin_spend_rejects_a_reveal_that_does_not_hash_to_the_coin() {
618 let c = coin(8); let id = c.coin_id();
620 let fetcher = MockFetcher {
621 coin_states: vec![CoinState {
622 coin: c,
623 created_height: Some(10),
624 spent_height: Some(20),
625 }],
626 reveal: Some((Program::from(vec![1u8]), Program::from(vec![2u8]))),
627 ..Default::default()
628 };
629 let (_rt, provider) = provider_with(fetcher);
630 let result = call(move || provider.coin_spend(id));
631 assert!(
632 matches!(result, Err(ChainSourceError::Malformed(_))),
633 "a mismatched reveal must be Malformed: {result:?}"
634 );
635 }
636
637 #[test]
638 fn records_by_puzzle_hash_and_parent_map_states() {
639 let fetcher = MockFetcher {
640 puzzle_states: vec![CoinState {
641 coin: coin(1),
642 created_height: Some(1),
643 spent_height: None,
644 }],
645 children: vec![CoinState {
646 coin: coin(2),
647 created_height: Some(2),
648 spent_height: None,
649 }],
650 ..Default::default()
651 };
652 let (_rt, provider) = provider_with(fetcher);
653 let ph = Bytes32::new([8; 32]);
654 let parent = Bytes32::new([9; 32]);
655 let p = provider.clone();
656 assert_eq!(
657 call(move || p.coin_records_by_puzzle_hash(ph, true))
658 .unwrap()
659 .len(),
660 1
661 );
662 assert_eq!(
663 call(move || provider.coin_records_by_parent(parent))
664 .unwrap()
665 .len(),
666 1
667 );
668 }
669
670 #[test]
671 fn lineage_and_timestamp_are_unsupported_not_false_absence() {
672 let (_rt, provider) = provider_with(MockFetcher::default());
673 let p = provider.clone();
674 assert!(matches!(
675 call(move || p.resolve_singleton_lineage(Bytes32::new([1; 32]))),
676 Err(ChainSourceError::Unsupported(_))
677 ));
678 assert!(matches!(
679 call(move || provider.block_timestamp(1)),
680 Err(ChainSourceError::Unsupported(_))
681 ));
682 }
683
684 #[test]
685 fn provider_info_is_reported() {
686 let (_rt, provider) = provider_with(MockFetcher::default());
687 assert_eq!(provider.provider_info().priority, 20);
688 assert_eq!(provider.peak_height().unwrap(), None);
689 }
690}