Skip to main content

chia_peer/
provider.rs

1//! [`ChiaPeerProvider`] — a synchronous [`ChainSource`] facade over the light client's subscription
2//! cache + wallet-protocol peer.
3//!
4//! Reads answer from the local [`CoinStateCache`](crate::cache::CoinStateCache) first; a miss falls
5//! through to a **non-subscribing** peer query (so a read never silently grows the subscription set).
6//! Every outcome honours the interface's fail-closed contract: `Ok(None)`/empty means the peer
7//! reliably reported absence, while any transport/subscription-gap failure is an `Err` — NEVER a
8//! 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 forward
15//!   walk better served by an aggregating source; answering it from subscription state would risk a
16//!   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
21use std::sync::Arc;
22
23use chia_protocol::{Bytes32, CoinSpend, CoinState, CoinStateFilters, Program};
24use dig_chainsource_interface::{
25    ChainSource, ChainSourceError, ChainSourceProvider, CoinRecord, ProviderInfo, SingletonLineage,
26};
27use tokio::runtime::Handle;
28use tokio::sync::RwLock;
29
30use crate::bridge::run_blocking;
31use crate::cache::CoinStateCache;
32use crate::fetcher::CoinStateFetcher;
33
34/// A [`ChainSource`] provider backed by a subscribing Chia light client.
35///
36/// Cloning shares the underlying cache, fetcher, and runtime handle.
37#[derive(Clone)]
38pub struct ChiaPeerProvider {
39    fetcher: Arc<dyn CoinStateFetcher>,
40    cache: Arc<RwLock<CoinStateCache>>,
41    handle: Handle,
42    info: ProviderInfo,
43}
44
45impl ChiaPeerProvider {
46    /// Builds a provider reading through `fetcher` + `cache`, driving async reads on `handle` (which
47    /// MUST belong to a multi-thread runtime — see the crate's async→sync bridge), described by
48    /// `info`.
49    pub fn new(
50        fetcher: Arc<dyn CoinStateFetcher>,
51        cache: Arc<RwLock<CoinStateCache>>,
52        handle: Handle,
53        info: ProviderInfo,
54    ) -> Self {
55        Self {
56            fetcher,
57            cache,
58            handle,
59            info,
60        }
61    }
62
63    /// Resolves a coin's current state: cache first, then a non-subscribing peer read.
64    fn coin_state(&self, coin_id: Bytes32) -> Result<Option<CoinState>, ChainSourceError> {
65        let fetcher = self.fetcher.clone();
66        let cache = self.cache.clone();
67        run_blocking(&self.handle, async move {
68            if let Some(cached) = cache.read().await.get(coin_id) {
69                return Ok(Some(cached));
70            }
71            let states = fetcher.coin_states(vec![coin_id], false).await?;
72            Ok::<_, crate::error::ChiaPeerError>(
73                states.into_iter().find(|s| s.coin.coin_id() == coin_id),
74            )
75        })?
76        .map_err(ChainSourceError::from)
77    }
78}
79
80impl ChainSource for ChiaPeerProvider {
81    type Error = ChainSourceError;
82
83    fn coin_record(&self, coin_id: Bytes32) -> Result<Option<CoinRecord>, Self::Error> {
84        let peak = self.peak_height()?;
85        Ok(self
86            .coin_state(coin_id)?
87            .map(CoinRecord::from_coin_state)
88            .map(|record| clamp_confirmed_to_peak(record, peak)))
89    }
90
91    fn coin_records_by_puzzle_hash(
92        &self,
93        puzzle_hash: Bytes32,
94        include_spent: bool,
95    ) -> Result<Vec<CoinRecord>, Self::Error> {
96        let fetcher = self.fetcher.clone();
97        let filters = CoinStateFilters {
98            include_spent,
99            include_unspent: true,
100            include_hinted: true,
101            min_amount: 0,
102        };
103        let states = run_blocking(&self.handle, async move {
104            fetcher
105                .puzzle_states(vec![puzzle_hash], filters, false)
106                .await
107        })?
108        .map_err(ChainSourceError::from)?;
109        let peak = self.peak_height()?;
110        Ok(states
111            .into_iter()
112            .map(CoinRecord::from_coin_state)
113            .map(|record| clamp_confirmed_to_peak(record, peak))
114            .collect())
115    }
116
117    fn coin_records_by_parent(
118        &self,
119        parent_coin_id: Bytes32,
120    ) -> Result<Vec<CoinRecord>, Self::Error> {
121        let fetcher = self.fetcher.clone();
122        let states = run_blocking(&self.handle, async move {
123            fetcher.children(parent_coin_id).await
124        })?
125        .map_err(ChainSourceError::from)?;
126        let peak = self.peak_height()?;
127        Ok(states
128            .into_iter()
129            .map(CoinRecord::from_coin_state)
130            .map(|record| clamp_confirmed_to_peak(record, peak))
131            .collect())
132    }
133
134    fn coin_spend(&self, coin_id: Bytes32) -> Result<Option<CoinSpend>, Self::Error> {
135        // The spend that spent `coin_id` exists only once the coin has a spent height; the coin
136        // itself supplies the real puzzle hash the CoinSpend needs (never a placeholder).
137        let Some(state) = self.coin_state(coin_id)? else {
138            return Ok(None);
139        };
140        let Some(spent_height) = state.spent_height else {
141            return Ok(None);
142        };
143        let fetcher = self.fetcher.clone();
144        let (puzzle, solution) = run_blocking(&self.handle, async move {
145            fetcher.puzzle_and_solution(coin_id, spent_height).await
146        })?
147        .map_err(ChainSourceError::from)?;
148
149        // Defend against a lying peer: the reveal MUST hash to the coin's own puzzle hash, else the
150        // spend is not this coin's. Fail closed on a mismatch or an unparseable reveal.
151        verify_reveal_matches(&puzzle, state.coin.puzzle_hash)?;
152        Ok(Some(CoinSpend::new(state.coin, puzzle, solution)))
153    }
154
155    fn resolve_singleton_lineage(
156        &self,
157        _launcher_id: Bytes32,
158    ) -> Result<Option<SingletonLineage>, Self::Error> {
159        Err(ChainSourceError::Unsupported(
160            "singleton lineage resolution is not provided by the light-client source; \
161             use an aggregating chain source",
162        ))
163    }
164
165    fn peak_height(&self) -> Result<Option<u32>, Self::Error> {
166        let cache = self.cache.clone();
167        let peak = run_blocking(&self.handle, async move { cache.read().await.peak() })?;
168        Ok(peak.map(|(height, _)| height))
169    }
170
171    fn block_timestamp(&self, _height: u32) -> Result<Option<u64>, Self::Error> {
172        Err(ChainSourceError::Unsupported(
173            "block timestamps are not indexed by the light-client source",
174        ))
175    }
176}
177
178impl ChainSourceProvider for ChiaPeerProvider {
179    fn provider_info(&self) -> ProviderInfo {
180        self.info.clone()
181    }
182}
183
184/// Bounds a record's reported `confirmed_height` by the current known peak.
185///
186/// The cache read path already upholds "no coin has `created_height > peak_height`" structurally
187/// (see [`CoinStateCache`](crate::cache::CoinStateCache)), but the cache-MISS *live-fetch* path
188/// surfaces the peer's `created_height` directly. An unsubscribed coin created in the current tip
189/// block — read in the one-block window before the drive loop processes the matching
190/// `NewPeakWallet` — would otherwise report `confirmed_height > peak_height`, underflowing a
191/// consumer's `peak_height - confirmed_height` (u32) confirmation count into a spurious ~4.29-billion
192/// value on a money path.
193///
194/// Clamping to `min(created, peak)` makes such a coin report 0 confirmations — the conservative,
195/// understating direction — while keeping it PRESENT (never omitted): the coin genuinely exists, so a
196/// false absence would be worse. The peak is left untouched (a lying peer must not be able to inflate
197/// it via a fetched coin). When no peak is known yet, `peak_height` is also `None`, so no
198/// `peak - confirmed` subtraction is possible and the height is left as reported.
199fn clamp_confirmed_to_peak(mut record: CoinRecord, peak: Option<u32>) -> CoinRecord {
200    if let (Some(confirmed), Some(peak)) = (record.confirmed_height, peak) {
201        if confirmed > peak {
202            record.confirmed_height = Some(peak);
203        }
204    }
205    record
206}
207
208/// Verifies a puzzle reveal hashes to `expected` (the coin's own puzzle hash), failing closed on a
209/// mismatch or an unparseable reveal. A lying peer cannot pass off a wrong reveal as this coin's.
210fn verify_reveal_matches(puzzle: &Program, expected: Bytes32) -> Result<(), ChainSourceError> {
211    let actual: Bytes32 = chia::clvm_utils::tree_hash_from_bytes(puzzle.as_ref())
212        .map_err(|e| ChainSourceError::Malformed(format!("undecodable puzzle reveal: {e}")))?
213        .into();
214    if actual != expected {
215        return Err(ChainSourceError::Malformed(
216            "puzzle reveal does not hash to the coin's puzzle hash".into(),
217        ));
218    }
219    Ok(())
220}
221
222#[cfg(test)]
223mod tests {
224    use super::*;
225    use crate::error::ChiaPeerError;
226    use async_trait::async_trait;
227    use chia_protocol::{Coin, Program};
228    use dig_chainsource_interface::{ProviderId, ProviderKind};
229    use std::borrow::Cow;
230
231    /// A scripted fetcher: each read returns the configured `Ok(..)` states or a forced error, so
232    /// the provider's fail-closed mapping can be exercised without a live node.
233    #[derive(Default, Clone)]
234    struct MockFetcher {
235        coin_states: Vec<CoinState>,
236        fail: Option<ChiaPeerError>,
237        children: Vec<CoinState>,
238        puzzle_states: Vec<CoinState>,
239        reveal: Option<(Program, Program)>,
240    }
241
242    #[async_trait]
243    impl CoinStateFetcher for MockFetcher {
244        async fn coin_states(
245            &self,
246            _coin_ids: Vec<Bytes32>,
247            _subscribe: bool,
248        ) -> Result<Vec<CoinState>, ChiaPeerError> {
249            match &self.fail {
250                Some(e) => Err(e.clone()),
251                None => Ok(self.coin_states.clone()),
252            }
253        }
254        async fn puzzle_states(
255            &self,
256            _puzzle_hashes: Vec<Bytes32>,
257            _filters: CoinStateFilters,
258            _subscribe: bool,
259        ) -> Result<Vec<CoinState>, ChiaPeerError> {
260            match &self.fail {
261                Some(e) => Err(e.clone()),
262                None => Ok(self.puzzle_states.clone()),
263            }
264        }
265        async fn children(&self, _coin_id: Bytes32) -> Result<Vec<CoinState>, ChiaPeerError> {
266            match &self.fail {
267                Some(e) => Err(e.clone()),
268                None => Ok(self.children.clone()),
269            }
270        }
271        async fn puzzle_and_solution(
272            &self,
273            _coin_id: Bytes32,
274            _height: u32,
275        ) -> Result<(Program, Program), ChiaPeerError> {
276            if let Some(e) = &self.fail {
277                return Err(e.clone());
278            }
279            match &self.reveal {
280                Some(reveal) => Ok(reveal.clone()),
281                // Absence is impossible on this path (caller confirmed spent) → fail closed.
282                None => Err(ChiaPeerError::Rejected("no reveal".into())),
283            }
284        }
285    }
286
287    /// A puzzle reveal and the coin puzzle hash it hashes to, so `coin_spend`'s reveal verification
288    /// passes for a legitimately-served spend.
289    fn reveal_and_matching_puzzle_hash() -> (Program, Bytes32) {
290        let puzzle = Program::from(vec![1u8]);
291        let ph: Bytes32 = chia::clvm_utils::tree_hash_from_bytes(puzzle.as_ref())
292            .unwrap()
293            .into();
294        (puzzle, ph)
295    }
296
297    fn info() -> ProviderInfo {
298        ProviderInfo {
299            id: ProviderId(Cow::Borrowed("chia-peer-test")),
300            kind: ProviderKind::Custom,
301            priority: 20,
302            trustless: false,
303        }
304    }
305
306    fn provider_with(fetcher: MockFetcher) -> (tokio::runtime::Runtime, ChiaPeerProvider) {
307        provider_with_peak(fetcher, None)
308    }
309
310    /// Builds a provider whose cache has been advanced to `peak` (if any), so the live-fetch clamp
311    /// against the known peak can be exercised.
312    fn provider_with_peak(
313        fetcher: MockFetcher,
314        peak: Option<u32>,
315    ) -> (tokio::runtime::Runtime, ChiaPeerProvider) {
316        let rt = tokio::runtime::Builder::new_multi_thread()
317            .worker_threads(1)
318            .enable_all()
319            .build()
320            .expect("multi-thread runtime");
321        let mut cache = CoinStateCache::new();
322        if let Some(height) = peak {
323            cache.set_peak(height, Bytes32::new([0xAB; 32]));
324        }
325        let provider = ChiaPeerProvider::new(
326            Arc::new(fetcher),
327            Arc::new(RwLock::new(cache)),
328            rt.handle().clone(),
329            info(),
330        );
331        (rt, provider)
332    }
333
334    /// Runs the sync facade method off any ambient runtime (bridge's "outside a runtime" path).
335    fn call<T: Send>(f: impl FnOnce() -> T + Send) -> T {
336        std::thread::scope(|s| s.spawn(f).join().expect("thread panicked"))
337    }
338
339    fn coin(seed: u8) -> Coin {
340        Coin::new(Bytes32::new([seed; 32]), Bytes32::new([seed ^ 1; 32]), 1)
341    }
342
343    // ---- Test #1: the fail-closed crux ----
344
345    #[test]
346    fn coin_record_returns_some_for_a_known_coin() {
347        let c = coin(7);
348        let id = c.coin_id();
349        let fetcher = MockFetcher {
350            coin_states: vec![CoinState {
351                coin: c,
352                created_height: Some(100),
353                spent_height: None,
354            }],
355            ..Default::default()
356        };
357        let (_rt, provider) = provider_with(fetcher);
358        let record = call(move || provider.coin_record(id)).expect("read ok");
359        assert!(record.is_some());
360        assert_eq!(record.unwrap().confirmed_height, Some(100));
361    }
362
363    /// #1326 regression: a cache-miss live fetch returning a coin created ABOVE the current peak (the
364    /// one-block window before the matching NewPeakWallet lands) must report `confirmed_height`
365    /// clamped to the peak (0 confirmations), NEVER above it — and the coin must stay PRESENT, not
366    /// omitted, since it genuinely exists.
367    #[test]
368    fn live_fetched_coin_above_peak_reports_clamped_confirmed_height() {
369        let c = coin(11);
370        let id = c.coin_id();
371        let fetcher = MockFetcher {
372            coin_states: vec![CoinState {
373                coin: c,
374                created_height: Some(1_000_001), // above the peak below
375                spent_height: None,
376            }],
377            ..Default::default()
378        };
379        let (_rt, provider) = provider_with_peak(fetcher, Some(1_000_000));
380        let record = call(move || provider.coin_record(id))
381            .expect("read ok")
382            .expect("coin present, never omitted");
383        assert_eq!(
384            record.confirmed_height,
385            Some(1_000_000),
386            "an above-peak live coin must clamp to the peak (0 confirmations), never overstate"
387        );
388    }
389
390    /// A live-fetched coin created at/below the peak keeps its real confirmation height (the clamp is
391    /// a no-op on the normal path).
392    #[test]
393    fn live_fetched_coin_at_or_below_peak_is_unaffected() {
394        let c = coin(12);
395        let id = c.coin_id();
396        let fetcher = MockFetcher {
397            coin_states: vec![CoinState {
398                coin: c,
399                created_height: Some(900_000),
400                spent_height: None,
401            }],
402            ..Default::default()
403        };
404        let (_rt, provider) = provider_with_peak(fetcher, Some(1_000_000));
405        let record = call(move || provider.coin_record(id))
406            .expect("read ok")
407            .expect("coin present");
408        assert_eq!(record.confirmed_height, Some(900_000));
409    }
410
411    /// The same clamp holds on the discovery read paths, which are always live (never cache-first).
412    #[test]
413    fn discovery_reads_clamp_above_peak_confirmed_height() {
414        let fetcher = MockFetcher {
415            puzzle_states: vec![CoinState {
416                coin: coin(13),
417                created_height: Some(2_000_000),
418                spent_height: None,
419            }],
420            children: vec![CoinState {
421                coin: coin(14),
422                created_height: Some(2_000_000),
423                spent_height: None,
424            }],
425            ..Default::default()
426        };
427        let (_rt, provider) = provider_with_peak(fetcher, Some(1_000_000));
428        let ph = Bytes32::new([8; 32]);
429        let parent = Bytes32::new([9; 32]);
430        let p = provider.clone();
431        let by_ph = call(move || p.coin_records_by_puzzle_hash(ph, true)).unwrap();
432        assert_eq!(by_ph[0].confirmed_height, Some(1_000_000));
433        let by_parent = call(move || provider.coin_records_by_parent(parent)).unwrap();
434        assert_eq!(by_parent[0].confirmed_height, Some(1_000_000));
435    }
436
437    #[test]
438    fn coin_record_returns_none_for_provable_absence() {
439        let (_rt, provider) = provider_with(MockFetcher::default());
440        let id = coin(9).coin_id();
441        let record = call(move || provider.coin_record(id)).expect("read ok");
442        assert_eq!(record, None);
443    }
444
445    #[test]
446    fn transport_failure_is_err_never_false_absence() {
447        let fetcher = MockFetcher {
448            fail: Some(ChiaPeerError::Transport("socket reset".into())),
449            ..Default::default()
450        };
451        let (_rt, provider) = provider_with(fetcher);
452        let id = coin(3).coin_id();
453        let result = call(move || provider.coin_record(id));
454        assert!(
455            matches!(result, Err(ChainSourceError::Transport(_))),
456            "a transport failure MUST be Err, never Ok(None): {result:?}"
457        );
458    }
459
460    #[test]
461    fn coin_spend_of_unspent_coin_is_none() {
462        let c = coin(4);
463        let id = c.coin_id();
464        let fetcher = MockFetcher {
465            coin_states: vec![CoinState {
466                coin: c,
467                created_height: Some(10),
468                spent_height: None,
469            }],
470            ..Default::default()
471        };
472        let (_rt, provider) = provider_with(fetcher);
473        assert_eq!(call(move || provider.coin_spend(id)).unwrap(), None);
474    }
475
476    #[test]
477    fn coin_spend_of_spent_coin_assembles_from_real_coin() {
478        let (puzzle, ph) = reveal_and_matching_puzzle_hash();
479        let c = Coin::new(Bytes32::new([5; 32]), ph, 1);
480        let id = c.coin_id();
481        let fetcher = MockFetcher {
482            coin_states: vec![CoinState {
483                coin: c,
484                created_height: Some(10),
485                spent_height: Some(20),
486            }],
487            reveal: Some((puzzle, Program::from(vec![2u8]))),
488            ..Default::default()
489        };
490        let (_rt, provider) = provider_with(fetcher);
491        let spend = call(move || provider.coin_spend(id))
492            .unwrap()
493            .expect("spend");
494        assert_eq!(spend.coin, c);
495    }
496
497    /// Fix 3 regression: a KNOWN-SPENT coin whose reveal the peer rejects must fail closed with
498    /// `Err` — NEVER `Ok(None)` (which would corrupt the interface's parent-walk authentication).
499    #[test]
500    fn coin_spend_of_spent_coin_with_rejected_reveal_is_err_never_none() {
501        let c = coin(6);
502        let id = c.coin_id();
503        let fetcher = MockFetcher {
504            coin_states: vec![CoinState {
505                coin: c,
506                created_height: Some(10),
507                spent_height: Some(20),
508            }],
509            reveal: None, // peer rejects / has no reveal
510            ..Default::default()
511        };
512        let (_rt, provider) = provider_with(fetcher);
513        let result = call(move || provider.coin_spend(id));
514        assert!(
515            matches!(result, Err(ChainSourceError::Transport(_))),
516            "a rejected reveal for a spent coin must be Err, never Ok(None): {result:?}"
517        );
518    }
519
520    /// Fix 4 regression: a reveal that does NOT hash to the coin's puzzle hash (a lying peer) is
521    /// rejected as malformed, never assembled into a bogus spend.
522    #[test]
523    fn coin_spend_rejects_a_reveal_that_does_not_hash_to_the_coin() {
524        let c = coin(8); // puzzle_hash is [8^1;32], which the reveal below will NOT hash to
525        let id = c.coin_id();
526        let fetcher = MockFetcher {
527            coin_states: vec![CoinState {
528                coin: c,
529                created_height: Some(10),
530                spent_height: Some(20),
531            }],
532            reveal: Some((Program::from(vec![1u8]), Program::from(vec![2u8]))),
533            ..Default::default()
534        };
535        let (_rt, provider) = provider_with(fetcher);
536        let result = call(move || provider.coin_spend(id));
537        assert!(
538            matches!(result, Err(ChainSourceError::Malformed(_))),
539            "a mismatched reveal must be Malformed: {result:?}"
540        );
541    }
542
543    #[test]
544    fn records_by_puzzle_hash_and_parent_map_states() {
545        let fetcher = MockFetcher {
546            puzzle_states: vec![CoinState {
547                coin: coin(1),
548                created_height: Some(1),
549                spent_height: None,
550            }],
551            children: vec![CoinState {
552                coin: coin(2),
553                created_height: Some(2),
554                spent_height: None,
555            }],
556            ..Default::default()
557        };
558        let (_rt, provider) = provider_with(fetcher);
559        let ph = Bytes32::new([8; 32]);
560        let parent = Bytes32::new([9; 32]);
561        let p = provider.clone();
562        assert_eq!(
563            call(move || p.coin_records_by_puzzle_hash(ph, true))
564                .unwrap()
565                .len(),
566            1
567        );
568        assert_eq!(
569            call(move || provider.coin_records_by_parent(parent))
570                .unwrap()
571                .len(),
572            1
573        );
574    }
575
576    #[test]
577    fn lineage_and_timestamp_are_unsupported_not_false_absence() {
578        let (_rt, provider) = provider_with(MockFetcher::default());
579        let p = provider.clone();
580        assert!(matches!(
581            call(move || p.resolve_singleton_lineage(Bytes32::new([1; 32]))),
582            Err(ChainSourceError::Unsupported(_))
583        ));
584        assert!(matches!(
585            call(move || provider.block_timestamp(1)),
586            Err(ChainSourceError::Unsupported(_))
587        ));
588    }
589
590    #[test]
591    fn provider_info_is_reported() {
592        let (_rt, provider) = provider_with(MockFetcher::default());
593        assert_eq!(provider.provider_info().priority, 20);
594        assert_eq!(provider.peak_height().unwrap(), None);
595    }
596}