Skip to main content

chia_peer/
fetcher.rs

1//! [`CoinStateFetcher`] — the async seam the [`ChiaPeerProvider`](crate::ChiaPeerProvider) reads
2//! through, and [`PeerFetcher`], its real implementation over a connected wallet-protocol
3//! [`Peer`](chia_wallet_sdk::client::Peer).
4//!
5//! Isolating the peer behind a trait keeps the provider's fail-closed logic unit-testable with an
6//! in-memory mock (no live full node), and lets [`reconnect`](crate::ChiaLightClient::reconnect)
7//! swap the underlying peer without disturbing readers.
8
9use std::sync::Arc;
10use std::time::Duration;
11
12use async_trait::async_trait;
13use chia_protocol::{Bytes32, CoinState, CoinStateFilters, Program, SpendBundle};
14use chia_wallet_sdk::client::Peer;
15use tokio::sync::RwLock;
16
17use crate::error::ChiaPeerError;
18
19/// Max pages an UNTRUSTED peer may return for one paged puzzle-state read before we fail closed.
20/// Bounds a hostile peer that never sets `is_finished` (would otherwise hang + OOM).
21const MAX_PUZZLE_STATE_PAGES: usize = 10_000;
22
23/// Max coin states accumulated across a single paged read before we fail closed. Bounds unbounded
24/// memory growth from a peer that streams coins forever.
25const MAX_ACCUMULATED_COIN_STATES: usize = 500_000;
26
27/// The reads a provider issues against a Chia full node when its local cache misses.
28///
29/// `subscribe = true` arms a server-side subscription so future changes stream back as
30/// `CoinStateUpdate`s; `subscribe = false` is a one-shot read that never grows the subscription set.
31#[async_trait]
32pub trait CoinStateFetcher: Send + Sync {
33    /// Reads the current state of `coin_ids`. An empty result is a PROVABLE absence.
34    async fn coin_states(
35        &self,
36        coin_ids: Vec<Bytes32>,
37        subscribe: bool,
38    ) -> Result<Vec<CoinState>, ChiaPeerError>;
39
40    /// Reads every coin paying to `puzzle_hashes` (paging through the peer's `is_finished` protocol
41    /// until complete), applying `filters`.
42    async fn puzzle_states(
43        &self,
44        puzzle_hashes: Vec<Bytes32>,
45        filters: CoinStateFilters,
46        subscribe: bool,
47    ) -> Result<Vec<CoinState>, ChiaPeerError>;
48
49    /// Reads the direct children created by spending `coin_id`.
50    async fn children(&self, coin_id: Bytes32) -> Result<Vec<CoinState>, ChiaPeerError>;
51
52    /// Reads the puzzle reveal + solution of the coin spent at `height`.
53    ///
54    /// Callers reach this ONLY after confirming the coin is spent, so absence is impossible: a
55    /// rejection/absence is a "could not answer" and is returned as `Err(_)` (fail closed), NEVER a
56    /// misleading `Ok(None)` that would corrupt the interface's parent-walk authentication.
57    async fn puzzle_and_solution(
58        &self,
59        coin_id: Bytes32,
60        height: u32,
61    ) -> Result<(Program, Program), ChiaPeerError>;
62}
63
64/// A [`CoinStateFetcher`] backed by a live wallet-protocol [`Peer`].
65///
66/// The peer sits behind an `RwLock<Option<_>>` so [`swap_peer`](Self::swap_peer) can replace it on
67/// reconnect while in-flight readers keep working against the peer they cloned.
68#[derive(Clone)]
69pub struct PeerFetcher {
70    peer: Arc<RwLock<Option<Peer>>>,
71    genesis_challenge: Bytes32,
72    request_timeout: Duration,
73}
74
75impl PeerFetcher {
76    /// Builds a fetcher over `peer`, using `genesis_challenge` as the height-0 `header_hash` and
77    /// bounding every request with `request_timeout`.
78    pub fn new(peer: Peer, genesis_challenge: Bytes32, request_timeout: Duration) -> Self {
79        Self {
80            peer: Arc::new(RwLock::new(Some(peer))),
81            genesis_challenge,
82            request_timeout,
83        }
84    }
85
86    /// A fetcher with no peer, so every read fails closed with [`ChiaPeerError::NotConnected`].
87    #[cfg(test)]
88    fn disconnected(genesis_challenge: Bytes32, request_timeout: Duration) -> Self {
89        Self {
90            peer: Arc::new(RwLock::new(None)),
91            genesis_challenge,
92            request_timeout,
93        }
94    }
95
96    /// Replaces the underlying peer (used by reconnect).
97    pub async fn swap_peer(&self, peer: Peer) {
98        *self.peer.write().await = Some(peer);
99    }
100
101    /// Clones the current peer, or fails closed if the client is not connected.
102    async fn peer(&self) -> Result<Peer, ChiaPeerError> {
103        self.peer
104            .read()
105            .await
106            .clone()
107            .ok_or(ChiaPeerError::NotConnected)
108    }
109
110    /// Submits `bundle` to the network, returning the ack `status` byte (`1` = success/pending).
111    pub async fn send_transaction(&self, bundle: SpendBundle) -> Result<u8, ChiaPeerError> {
112        let peer = self.peer().await?;
113        let ack = self
114            .with_timeout(peer.send_transaction(bundle))
115            .await?
116            .map_err(|e| ChiaPeerError::Transport(e.to_string()))?;
117        Ok(ack.status)
118    }
119
120    /// Removes the server-side subscription to `coin_ids` (wraps `remove_coin_subscriptions`).
121    pub async fn remove_coin_subscriptions(
122        &self,
123        coin_ids: Vec<Bytes32>,
124    ) -> Result<(), ChiaPeerError> {
125        let peer = self.peer().await?;
126        self.with_timeout(peer.remove_coin_subscriptions(Some(coin_ids)))
127            .await?
128            .map_err(|e| ChiaPeerError::Transport(e.to_string()))?;
129        Ok(())
130    }
131
132    /// Wraps `fut` with the configured request timeout, mapping an elapsed deadline to
133    /// [`ChiaPeerError::Timeout`].
134    async fn with_timeout<T>(
135        &self,
136        fut: impl std::future::Future<Output = T>,
137    ) -> Result<T, ChiaPeerError> {
138        tokio::time::timeout(self.request_timeout, fut)
139            .await
140            .map_err(|_| ChiaPeerError::Timeout)
141    }
142}
143
144/// One page of a paged `request_puzzle_state` read.
145struct PuzzleStatePage {
146    coin_states: Vec<CoinState>,
147    height: u32,
148    header_hash: Bytes32,
149    is_finished: bool,
150}
151
152/// Drives a paged puzzle-state read to completion, bounding an UNTRUSTED peer three ways so it can
153/// neither hang the caller nor exhaust memory: a page cap, a total accumulated-coin cap, and a
154/// strict-progress requirement (each unfinished page MUST advance `height`). Any violation fails
155/// closed with `Err`, never an unbounded loop.
156///
157/// The page fetch is injected so the bounding policy is unit-testable without a live peer.
158async fn collect_paged<F, Fut>(
159    genesis_challenge: Bytes32,
160    mut fetch_page: F,
161) -> Result<Vec<CoinState>, ChiaPeerError>
162where
163    F: FnMut(Option<u32>, Bytes32) -> Fut,
164    Fut: std::future::Future<Output = Result<PuzzleStatePage, ChiaPeerError>>,
165{
166    let mut all = Vec::new();
167    let mut previous_height: Option<u32> = None;
168    let mut header_hash = genesis_challenge;
169
170    for _page in 0..MAX_PUZZLE_STATE_PAGES {
171        let page = fetch_page(previous_height, header_hash).await?;
172        all.extend(page.coin_states);
173        if all.len() > MAX_ACCUMULATED_COIN_STATES {
174            return Err(ChiaPeerError::Rejected(format!(
175                "puzzle-state response exceeded {MAX_ACCUMULATED_COIN_STATES} coins"
176            )));
177        }
178        if page.is_finished {
179            return Ok(all);
180        }
181        if previous_height.is_some_and(|prev| page.height <= prev) {
182            return Err(ChiaPeerError::Rejected(
183                "puzzle-state paging did not advance the height".into(),
184            ));
185        }
186        previous_height = Some(page.height);
187        header_hash = page.header_hash;
188    }
189    Err(ChiaPeerError::Rejected(format!(
190        "puzzle-state paging exceeded {MAX_PUZZLE_STATE_PAGES} pages"
191    )))
192}
193
194#[async_trait]
195impl CoinStateFetcher for PeerFetcher {
196    async fn coin_states(
197        &self,
198        coin_ids: Vec<Bytes32>,
199        subscribe: bool,
200    ) -> Result<Vec<CoinState>, ChiaPeerError> {
201        let peer = self.peer().await?;
202        let response = self
203            .with_timeout(peer.request_coin_state(
204                coin_ids,
205                None,
206                self.genesis_challenge,
207                subscribe,
208            ))
209            .await?
210            .map_err(|e| ChiaPeerError::Transport(e.to_string()))?
211            .map_err(|_| ChiaPeerError::Rejected("coin-state request rejected".into()))?;
212        Ok(response.coin_states)
213    }
214
215    async fn puzzle_states(
216        &self,
217        puzzle_hashes: Vec<Bytes32>,
218        filters: CoinStateFilters,
219        subscribe: bool,
220    ) -> Result<Vec<CoinState>, ChiaPeerError> {
221        let peer = self.peer().await?;
222        // Subscribe only once the final page arrives, so a single subscription covers the set.
223        collect_paged(self.genesis_challenge, |previous_height, header_hash| {
224            let peer = peer.clone();
225            let puzzle_hashes = puzzle_hashes.clone();
226            let filters = filters.clone();
227            let this = self;
228            async move {
229                let response = this
230                    .with_timeout(peer.request_puzzle_state(
231                        puzzle_hashes,
232                        previous_height,
233                        header_hash,
234                        filters,
235                        subscribe,
236                    ))
237                    .await?
238                    .map_err(|e| ChiaPeerError::Transport(e.to_string()))?
239                    .map_err(|_| ChiaPeerError::Rejected("puzzle-state request rejected".into()))?;
240                Ok(PuzzleStatePage {
241                    coin_states: response.coin_states,
242                    height: response.height,
243                    header_hash: response.header_hash,
244                    is_finished: response.is_finished,
245                })
246            }
247        })
248        .await
249    }
250
251    async fn children(&self, coin_id: Bytes32) -> Result<Vec<CoinState>, ChiaPeerError> {
252        let peer = self.peer().await?;
253        let response = self
254            .with_timeout(peer.request_children(coin_id))
255            .await?
256            .map_err(|e| ChiaPeerError::Transport(e.to_string()))?;
257        Ok(response.coin_states)
258    }
259
260    async fn puzzle_and_solution(
261        &self,
262        coin_id: Bytes32,
263        height: u32,
264    ) -> Result<(Program, Program), ChiaPeerError> {
265        let peer = self.peer().await?;
266        let outcome = self
267            .with_timeout(peer.request_puzzle_and_solution(coin_id, height))
268            .await?
269            .map_err(|e| ChiaPeerError::Transport(e.to_string()))?;
270        match outcome {
271            Ok(response) => Ok((response.puzzle, response.solution)),
272            // The caller only asks after confirming a spent height, so a reject here is NOT a
273            // genuine absence — it is a peer that could not/would not answer. Fail closed with Err,
274            // never a misleading Ok(None) (mirrors how coin-state rejects map to Err(Rejected)).
275            Err(_) => Err(ChiaPeerError::Rejected(
276                "peer rejected puzzle/solution for a known-spent coin".into(),
277            )),
278        }
279    }
280}
281
282#[cfg(test)]
283mod tests {
284    use super::*;
285    use crate::config::ChiaNetwork;
286    use chia_protocol::{Coin, SpendBundle};
287    use chia_wallet_sdk::chia::bls::Signature;
288    use chia_wallet_sdk::test::PeerSimulator;
289    use std::time::Duration;
290
291    fn genesis() -> Bytes32 {
292        ChiaNetwork::Testnet11.genesis_challenge()
293    }
294
295    async fn fetcher_over_sim() -> (PeerSimulator, PeerFetcher, Coin) {
296        let sim = PeerSimulator::new().await.expect("start simulator");
297        let coin = sim.lock().await.new_coin(Bytes32::new([7; 32]), 1_000);
298        let (peer, _receiver) = sim.connect_raw().await.expect("connect to simulator");
299        let fetcher = PeerFetcher::new(peer, genesis(), Duration::from_secs(5));
300        (sim, fetcher, coin)
301    }
302
303    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
304    async fn coin_states_reads_an_inserted_coin() {
305        let (_sim, fetcher, coin) = fetcher_over_sim().await;
306        let states = fetcher
307            .coin_states(vec![coin.coin_id()], false)
308            .await
309            .unwrap();
310        assert_eq!(states.len(), 1);
311        assert_eq!(states[0].coin, coin);
312    }
313
314    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
315    async fn coin_states_of_unknown_coin_is_empty() {
316        let (_sim, fetcher, _coin) = fetcher_over_sim().await;
317        let states = fetcher
318            .coin_states(vec![Bytes32::new([0xee; 32])], false)
319            .await
320            .unwrap();
321        assert!(states.is_empty());
322    }
323
324    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
325    async fn puzzle_states_reads_by_puzzle_hash() {
326        let (_sim, fetcher, coin) = fetcher_over_sim().await;
327        let filters = CoinStateFilters {
328            include_spent: true,
329            include_unspent: true,
330            include_hinted: true,
331            min_amount: 0,
332        };
333        let states = fetcher
334            .puzzle_states(vec![coin.puzzle_hash], filters, false)
335            .await
336            .unwrap();
337        assert!(states.iter().any(|s| s.coin == coin));
338    }
339
340    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
341    async fn children_reads_child_coins_of_a_parent() {
342        let sim = PeerSimulator::new().await.unwrap();
343        let parent = Bytes32::new([9; 32]);
344        let child = Coin::new(parent, Bytes32::new([3; 32]), 5);
345        sim.lock().await.insert_coin(child);
346        let (peer, _receiver) = sim.connect_raw().await.unwrap();
347        let fetcher = PeerFetcher::new(peer, genesis(), Duration::from_secs(5));
348
349        let kids = fetcher.children(parent).await.unwrap();
350        assert_eq!(kids.len(), 1);
351        assert_eq!(kids[0].coin, child);
352    }
353
354    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
355    async fn puzzle_and_solution_of_unspent_coin_never_yields_a_spend() {
356        let (_sim, fetcher, coin) = fetcher_over_sim().await;
357        let result = fetcher.puzzle_and_solution(coin.coin_id(), 1).await;
358        // An unspent coin has no reveal. Fail closed with `Err` — NEVER fabricate a spend, and never
359        // a misleading `Ok` (the caller only asks after confirming a spent height).
360        assert!(
361            result.is_err(),
362            "unspent coin must fail closed, not yield a reveal: {result:?}"
363        );
364    }
365
366    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
367    async fn submitting_an_invalid_bundle_returns_a_failure_ack() {
368        let (_sim, fetcher, _coin) = fetcher_over_sim().await;
369        let bundle = SpendBundle::new(vec![], Signature::default());
370        let status = fetcher.send_transaction(bundle).await.unwrap();
371        assert_eq!(status, 3, "an empty bundle is rejected with a failure ack");
372    }
373
374    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
375    async fn subscribe_then_remove_coin_subscriptions_succeeds() {
376        let (_sim, fetcher, coin) = fetcher_over_sim().await;
377        fetcher
378            .coin_states(vec![coin.coin_id()], true)
379            .await
380            .unwrap();
381        fetcher
382            .remove_coin_subscriptions(vec![coin.coin_id()])
383            .await
384            .unwrap();
385    }
386
387    fn page(states: usize, height: u32, is_finished: bool) -> PuzzleStatePage {
388        PuzzleStatePage {
389            coin_states: (0..states)
390                .map(|_| CoinState {
391                    coin: Coin::new(Bytes32::new([1; 32]), Bytes32::new([2; 32]), 1),
392                    created_height: Some(height),
393                    spent_height: None,
394                })
395                .collect(),
396            height,
397            header_hash: Bytes32::new([height as u8; 32]),
398            is_finished,
399        }
400    }
401
402    #[tokio::test]
403    async fn paging_that_never_finishes_fails_closed_not_hangs() {
404        // A hostile peer that always advances height but never sets is_finished must hit the page cap.
405        let mut next_height = 0u32;
406        let result = collect_paged(Bytes32::default(), |_prev, _hdr| {
407            next_height += 1;
408            let h = next_height;
409            async move { Ok(page(1, h, false)) }
410        })
411        .await;
412        assert!(
413            matches!(result, Err(ChiaPeerError::Rejected(_))),
414            "{result:?}"
415        );
416    }
417
418    #[tokio::test]
419    async fn paging_without_progress_fails_closed() {
420        // A peer that returns unfinished pages at a NON-advancing height is rejected immediately.
421        let result = collect_paged(Bytes32::default(), |_prev, _hdr| async move {
422            Ok(page(1, 42, false))
423        })
424        .await;
425        assert!(
426            matches!(result, Err(ChiaPeerError::Rejected(_))),
427            "{result:?}"
428        );
429    }
430
431    #[tokio::test]
432    async fn paging_over_coin_cap_fails_closed() {
433        let result = collect_paged(Bytes32::default(), |_prev, _hdr| async move {
434            Ok(page(MAX_ACCUMULATED_COIN_STATES + 1, 1, false))
435        })
436        .await;
437        assert!(
438            matches!(result, Err(ChiaPeerError::Rejected(_))),
439            "{result:?}"
440        );
441    }
442
443    #[tokio::test]
444    async fn paging_finishes_normally_returns_all() {
445        let mut calls = 0u32;
446        let result = collect_paged(Bytes32::default(), |_prev, _hdr| {
447            calls += 1;
448            let finished = calls == 2;
449            let h = calls;
450            async move { Ok(page(1, h, finished)) }
451        })
452        .await
453        .unwrap();
454        assert_eq!(result.len(), 2, "both pages accumulated then finished");
455    }
456
457    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
458    async fn disconnected_fetcher_fails_closed_on_every_read() {
459        let fetcher = PeerFetcher::disconnected(genesis(), Duration::from_secs(1));
460        let id = Bytes32::new([1; 32]);
461        assert_eq!(
462            fetcher.coin_states(vec![id], false).await,
463            Err(ChiaPeerError::NotConnected)
464        );
465        let filters = CoinStateFilters {
466            include_spent: true,
467            include_unspent: true,
468            include_hinted: true,
469            min_amount: 0,
470        };
471        assert_eq!(
472            fetcher.puzzle_states(vec![id], filters, false).await,
473            Err(ChiaPeerError::NotConnected)
474        );
475        assert_eq!(fetcher.children(id).await, Err(ChiaPeerError::NotConnected));
476        assert_eq!(
477            fetcher.puzzle_and_solution(id, 1).await,
478            Err(ChiaPeerError::NotConnected)
479        );
480        assert_eq!(
481            fetcher.remove_coin_subscriptions(vec![id]).await,
482            Err(ChiaPeerError::NotConnected)
483        );
484        assert_eq!(
485            fetcher
486                .send_transaction(SpendBundle::new(vec![], Signature::default()))
487                .await,
488            Err(ChiaPeerError::NotConnected)
489        );
490    }
491}