Skip to main content

chia_query/peer/light_client/
provider.rs

1//! [`LightClientProvider`] — a synchronous [`ChainSource`] facade over the light client's
2//! subscription cache + a pooled wallet-protocol session.
3//!
4//! Reads answer from the local [`CoinStateCache`](super::cache::CoinStateCache) first; a miss falls
5//! through to a **non-subscribing** peer query (so a read never silently grows the subscription
6//! set). Every outcome honours the interface's fail-closed contract: `Ok(None)`/empty means the
7//! peer reliably reported absence, while any transport/subscription-gap failure is an `Err` — NEVER
8//! a false `Ok(None)`.
9//!
10//! ## Boundary
11//!
12//! A subscribing light client is not a full archival index. Two reads are deliberately reported as
13//! [`ChainSourceError::Unsupported`] rather than answered unreliably:
14//! - [`resolve_singleton_lineage`](ChainSource::resolve_singleton_lineage) — a money-critical
15//!   forward walk better served by an aggregating source; answering it from subscription state
16//!   would risk a spoofable, partial lineage.
17//! - [`block_timestamp`](ChainSource::block_timestamp) — a light source keeps no timestamp index.
18//!
19//! The registry composes providers, so these fall through to a source that does support them.
20//!
21//! ## The provider descriptor is EARNED, not asserted
22//!
23//! `chia-peer` derived this provider's [`ProviderKind`] from a config flag — whether the operator
24//! had *named* an endpoint — with no way to check that the peer actually answering was the one
25//! named. A co-resident or `config.endpoint` peer therefore outranked coinset.org at priority 20
26//! with nothing able to tell it apart from an anonymous introducer result.
27//!
28//! Pooled, the answering session carries a
29//! [`PeerOrigin`](crate::peer::connect::PeerOrigin), so the descriptor is built from what the pool
30//! OBSERVED rather than from what the operator declared — see
31//! [`ChiaLightClient::as_chain_source_provider`](super::ChiaLightClient::as_chain_source_provider).
32
33use 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/// A [`ChainSource`] provider backed by a subscribing Chia light client.
47///
48/// Cloning shares the underlying cache, fetcher, and runtime handle.
49#[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    /// Builds a provider reading through `fetcher` + `cache`, driving async reads on `handle` (which
59    /// MUST belong to a multi-thread runtime — see the crate's async→sync bridge), described by
60    /// `info`.
61    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    /// Resolves a coin's current state: cache first, then a non-subscribing peer read.
76    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        // The spend that spent `coin_id` exists only once the coin has a spent height; the coin
148        // itself supplies the real puzzle hash the CoinSpend needs (never a placeholder).
149        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        // Defend against a lying peer: the reveal MUST hash to the coin's own puzzle hash, else the
162        // spend is not this coin's. Fail closed on a mismatch or an unparseable reveal.
163        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
196/// Bounds a record's reported block heights (`confirmed_height` and `spent_height`) by the current
197/// known peak.
198///
199/// The cache read path already upholds "no coin has a height `> peak_height`" structurally (see
200/// [`CoinStateCache`](super::cache::CoinStateCache)), but the cache-MISS *live-fetch* path surfaces
201/// the peer's heights directly. A coin created or spent in the current tip block — read in the
202/// one-block window before the drive loop processes the matching `NewPeakWallet` — would otherwise
203/// report a height `> peak_height`, underflowing a consumer's `peak_height - height` (u32) depth count
204/// (confirmations for `confirmed_height`, spend-depth for `spent_height`) into a spurious ~4.29-billion
205/// value on a money path.
206///
207/// Clamping each height to `min(height, peak)` makes such a coin report 0 confirmations / 0 spend-depth
208/// — the conservative, understating direction — while keeping it PRESENT (never omitted) and keeping
209/// `spent_height` `Some` (the coin IS spent; only the reported HEIGHT is clamped, never the
210/// spent-vs-unspent flag). The peak is left untouched (a lying peer must not be able to inflate it via
211/// a fetched coin). When no peak is known yet, `peak_height` is `None`, so no `peak - height`
212/// subtraction is possible and the heights are left as reported.
213fn 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
224/// Verifies a puzzle reveal hashes to `expected` (the coin's own puzzle hash), failing closed on a
225/// mismatch or an unparseable reveal. A lying peer cannot pass off a wrong reveal as this coin's.
226fn 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    /// A scripted fetcher: each read returns the configured `Ok(..)` states or a forced error, so
248    /// the provider's fail-closed mapping can be exercised without a live node.
249    #[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                // Absence is impossible on this path (caller confirmed spent) → fail closed.
298                None => Err(LightClientError::Rejected("no reveal".into())),
299            }
300        }
301    }
302
303    /// A puzzle reveal and the coin puzzle hash it hashes to, so `coin_spend`'s reveal verification
304    /// passes for a legitimately-served spend.
305    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    /// Builds a provider whose cache has been advanced to `peak` (if any), so the live-fetch clamp
327    /// against the known peak can be exercised.
328    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    /// Runs the sync facade method off any ambient runtime (bridge's "outside a runtime" path).
351    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 #1: the fail-closed crux ----
360
361    #[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    /// #1326 regression: a cache-miss live fetch returning a coin created ABOVE the current peak (the
380    /// one-block window before the matching NewPeakWallet lands) must report `confirmed_height`
381    /// clamped to the peak (0 confirmations), NEVER above it — and the coin must stay PRESENT, not
382    /// omitted, since it genuinely exists.
383    #[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), // above the peak below
391                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    /// A live-fetched coin created at/below the peak keeps its real confirmation height (the clamp is
407    /// a no-op on the normal path).
408    #[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    /// The same clamp holds on the discovery read paths, which are always live (never cache-first).
428    #[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    /// #1346 regression (symmetric to #1326): a cache-miss live fetch returning a coin SPENT above
456    /// the current peak (the one-block window before the matching NewPeakWallet lands) must report
457    /// `spent_height` clamped to the peak (0 spend-depth), NEVER above it — closing the identical
458    /// `peak_height - spent_height` (u32) underflow. The coin stays PRESENT and stays marked SPENT
459    /// (`spent_height` remains `Some`); only the reported height is clamped.
460    #[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), // spent above the peak below
469            }],
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    /// A live-fetched coin spent at/below the peak keeps its real spend height (the clamp is a no-op
488    /// on the normal path).
489    #[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    /// The clamp must not flip a spent coin to unspent: `coin_spend` keys spentness on the RAW peer
509    /// state (not the clamped record), so a coin spent above the lagged peak still assembles its spend.
510    #[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), // above the peak
520            }],
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    /// Fix 3 regression: a KNOWN-SPENT coin whose reveal the peer rejects must fail closed with
592    /// `Err` — NEVER `Ok(None)` (which would corrupt the interface's parent-walk authentication).
593    #[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, // peer rejects / has no reveal
604            ..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    /// Fix 4 regression: a reveal that does NOT hash to the coin's puzzle hash (a lying peer) is
615    /// rejected as malformed, never assembled into a bogus spend.
616    #[test]
617    fn coin_spend_rejects_a_reveal_that_does_not_hash_to_the_coin() {
618        let c = coin(8); // puzzle_hash is [8^1;32], which the reveal below will NOT hash to
619        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}