Skip to main content

chia_peer/
client.rs

1//! [`ChiaLightClient`] — the crate's public entry point: connect to a Chia full node as a
2//! wallet-protocol light client, subscribe to coin/puzzle-hash state, track the peak, submit spend
3//! bundles, and expose the read side as a [`ChiaPeerProvider`].
4//!
5//! It is a thin driver over the SDK: connecting, subscribing, and submitting are all SDK `Peer`
6//! calls; this type adds the subscription cache, the drive-loop that keeps that cache current from
7//! the peer's `CoinStateUpdate` stream, IPv6-first dialing, and reconnect-with-re-arm.
8
9use std::sync::{Arc, Mutex};
10
11use chia::traits::Streamable;
12use chia_protocol::{
13    Bytes32, CoinStateFilters, CoinStateUpdate, Message, NewPeakWallet, ProtocolMessageTypes,
14    SpendBundle,
15};
16use dig_chainsource_interface::{ProviderId, ProviderInfo, ProviderKind};
17use std::borrow::Cow;
18use tokio::sync::{mpsc, RwLock};
19use tokio::task::JoinHandle;
20use tokio_tungstenite::Connector;
21
22use crate::cache::CoinStateCache;
23use crate::config::ChiaPeerConfig;
24use crate::connect::{build_connector, connect};
25use crate::error::ChiaPeerError;
26use crate::fetcher::PeerFetcher;
27use crate::provider::ChiaPeerProvider;
28
29/// The default try-order priority a chia-peer provider registers with (lower = tried earlier).
30pub const DEFAULT_PROVIDER_PRIORITY: i32 = 20;
31
32/// The outcome of submitting a spend bundle, mapped from the node's `TransactionAck` status byte.
33#[derive(Debug, Clone, Copy, PartialEq, Eq)]
34pub enum SubmitOutcome {
35    /// Accepted into the mempool (ack status `1`) — pending block confirmation.
36    Accepted,
37    /// Held pending by the node (ack status `2`).
38    Pending,
39    /// Rejected by the node (ack status `3`).
40    Failed,
41    /// An unrecognised ack status byte.
42    Unknown(u8),
43}
44
45impl SubmitOutcome {
46    fn from_status(status: u8) -> Self {
47        match status {
48            1 => SubmitOutcome::Accepted,
49            2 => SubmitOutcome::Pending,
50            3 => SubmitOutcome::Failed,
51            other => SubmitOutcome::Unknown(other),
52        }
53    }
54
55    /// Whether the node took custody of the bundle (accepted or pending), rather than rejecting it.
56    pub fn is_accepted(self) -> bool {
57        matches!(self, SubmitOutcome::Accepted | SubmitOutcome::Pending)
58    }
59}
60
61/// A connected Chia wallet-protocol light client.
62pub struct ChiaLightClient {
63    config: ChiaPeerConfig,
64    tls: Connector,
65    fetcher: Arc<PeerFetcher>,
66    cache: Arc<RwLock<CoinStateCache>>,
67    drive: Mutex<Option<JoinHandle<()>>>,
68}
69
70impl ChiaLightClient {
71    /// Connects to a full node per `config` (IPv6-first, §5.2), starts the drive-loop that keeps the
72    /// subscription cache current, and returns the ready client.
73    pub async fn connect(config: ChiaPeerConfig) -> Result<Self, ChiaPeerError> {
74        let tls = build_connector(&config)?;
75        let (peer, receiver) = connect(&config, &tls).await?;
76        Ok(Self::from_connection(config, tls, peer, receiver))
77    }
78
79    /// Assembles a ready client from an already-connected peer: builds the cache + fetcher and starts
80    /// the drive-loop. Shared by [`connect`](Self::connect) and (in tests) the peer simulator.
81    fn from_connection(
82        config: ChiaPeerConfig,
83        tls: Connector,
84        peer: chia_wallet_sdk::client::Peer,
85        receiver: mpsc::Receiver<Message>,
86    ) -> Self {
87        let cache = Arc::new(RwLock::new(CoinStateCache::new()));
88        let fetcher = Arc::new(PeerFetcher::new(
89            peer,
90            config.network.genesis_challenge(),
91            config.request_timeout,
92        ));
93        let drive = spawn_drive_loop(receiver, cache.clone());
94
95        Self {
96            config,
97            tls,
98            fetcher,
99            cache,
100            drive: Mutex::new(Some(drive)),
101        }
102    }
103
104    /// Subscribes to `coin_ids`, seeds the cache with their current state, and returns that state.
105    ///
106    /// Wraps `request_coin_state(subscribe = true)`; future changes stream back via the drive-loop.
107    pub async fn subscribe_coins(&self, coin_ids: Vec<Bytes32>) -> Result<(), ChiaPeerError> {
108        use crate::fetcher::CoinStateFetcher;
109        let states = self.fetcher.coin_states(coin_ids.clone(), true).await?;
110        let mut cache = self.cache.write().await;
111        cache.track_coins(coin_ids);
112        cache.seed(states);
113        Ok(())
114    }
115
116    /// Subscribes to every coin paying to `puzzle_hashes` under `filters`, seeding the cache.
117    ///
118    /// Wraps `request_puzzle_state(subscribe = true)` (paging until finished).
119    pub async fn subscribe_puzzle_hashes(
120        &self,
121        puzzle_hashes: Vec<Bytes32>,
122        filters: CoinStateFilters,
123    ) -> Result<(), ChiaPeerError> {
124        use crate::fetcher::CoinStateFetcher;
125        let states = self
126            .fetcher
127            .puzzle_states(puzzle_hashes.clone(), filters, true)
128            .await?;
129        let mut cache = self.cache.write().await;
130        cache.track_puzzle_hashes(puzzle_hashes);
131        cache.seed(states);
132        Ok(())
133    }
134
135    /// Submits `bundle` to the network, mapping the node's ack to a typed [`SubmitOutcome`].
136    ///
137    /// This is a WRITE path and is deliberately NOT part of the reads-only `ChainSource` surface.
138    pub async fn submit_spend(&self, bundle: SpendBundle) -> Result<SubmitOutcome, ChiaPeerError> {
139        let status = self.fetcher.send_transaction(bundle).await?;
140        Ok(SubmitOutcome::from_status(status))
141    }
142
143    /// The current peak `(height, header_hash)` as tracked by the drive-loop, if known.
144    pub async fn peak(&self) -> Option<(u32, Bytes32)> {
145        self.cache.read().await.peak()
146    }
147
148    /// Removes the client's subscription to `coin_ids` (wraps `remove_coin_subscriptions`) and stops
149    /// tracking them locally.
150    pub async fn unsubscribe_coins(&self, coin_ids: Vec<Bytes32>) -> Result<(), ChiaPeerError> {
151        self.fetcher
152            .remove_coin_subscriptions(coin_ids.clone())
153            .await?;
154        self.cache.write().await.untrack_coins(&coin_ids);
155        Ok(())
156    }
157
158    /// Reconnects to a (possibly different) full node and re-arms the existing subscription set, so a
159    /// dropped connection recovers without the caller re-subscribing.
160    pub async fn reconnect(&self) -> Result<(), ChiaPeerError> {
161        let (peer, receiver) = connect(&self.config, &self.tls).await?;
162        self.fetcher.swap_peer(peer).await;
163
164        // Replace the drive-loop with one reading the new peer's stream.
165        if let Some(previous) = self.drive.lock().expect("drive lock").take() {
166            previous.abort();
167        }
168        let handle = spawn_drive_loop(receiver, self.cache.clone());
169        *self.drive.lock().expect("drive lock") = Some(handle);
170
171        self.rearm_subscriptions().await
172    }
173
174    /// Re-issues the tracked coin + puzzle-hash subscriptions against the current peer.
175    async fn rearm_subscriptions(&self) -> Result<(), ChiaPeerError> {
176        let (coins, puzzle_hashes) = {
177            let cache = self.cache.read().await;
178            (cache.subscribed_coins(), cache.subscribed_puzzle_hashes())
179        };
180        if !coins.is_empty() {
181            self.subscribe_coins(coins).await?;
182        }
183        if !puzzle_hashes.is_empty() {
184            let filters = CoinStateFilters {
185                include_spent: true,
186                include_unspent: true,
187                include_hinted: true,
188                min_amount: 0,
189            };
190            self.subscribe_puzzle_hashes(puzzle_hashes, filters).await?;
191        }
192        Ok(())
193    }
194
195    /// Exposes the read side as a [`ChiaPeerProvider`] for registration in a chain-source registry.
196    ///
197    /// `handle` MUST belong to a multi-thread tokio runtime (the sync facade blocks on it).
198    pub fn as_chain_source_provider(&self, handle: tokio::runtime::Handle) -> ChiaPeerProvider {
199        ChiaPeerProvider::new(
200            self.fetcher.clone(),
201            self.cache.clone(),
202            handle,
203            self.provider_info(),
204        )
205    }
206
207    /// The provider descriptor this client registers with: a [`LocalNode`](ProviderKind::LocalNode)
208    /// when pointed at the operator's own trusted node, else a [`Custom`](ProviderKind::Custom)
209    /// introducer-discovered source. Always `trustless = false` (answers are taken on trust).
210    pub fn provider_info(&self) -> ProviderInfo {
211        let kind = if self.config.trusted {
212            ProviderKind::LocalNode
213        } else {
214            ProviderKind::Custom
215        };
216        ProviderInfo {
217            id: ProviderId(Cow::Borrowed("chia-peer")),
218            kind,
219            priority: DEFAULT_PROVIDER_PRIORITY,
220            trustless: false,
221        }
222    }
223}
224
225impl Drop for ChiaLightClient {
226    fn drop(&mut self) {
227        if let Some(handle) = self.drive.lock().expect("drive lock").take() {
228            handle.abort();
229        }
230    }
231}
232
233/// Spawns the background task that keeps `cache` current from a peer's inbound message stream:
234/// `NewPeakWallet` advances the peak, `CoinStateUpdate` applies the reorg-aware state update and
235/// drops spent coins from local tracking. Other message types are ignored.
236fn spawn_drive_loop(
237    mut receiver: mpsc::Receiver<Message>,
238    cache: Arc<RwLock<CoinStateCache>>,
239) -> JoinHandle<()> {
240    tokio::spawn(async move {
241        while let Some(message) = receiver.recv().await {
242            match message.msg_type {
243                ProtocolMessageTypes::NewPeakWallet => {
244                    match NewPeakWallet::from_bytes(&message.data) {
245                        Ok(peak) => cache.write().await.set_peak(peak.height, peak.header_hash),
246                        // A malformed push is non-fatal (drop it), but log it to aid diagnosis.
247                        Err(error) => log::debug!("undecodable NewPeakWallet push: {error}"),
248                    }
249                }
250                ProtocolMessageTypes::CoinStateUpdate => {
251                    match CoinStateUpdate::from_bytes(&message.data) {
252                        Ok(update) => apply_coin_state_update(&cache, update).await,
253                        Err(error) => log::debug!("undecodable CoinStateUpdate push: {error}"),
254                    }
255                }
256                _ => {}
257            }
258        }
259    })
260}
261
262/// Applies a decoded `CoinStateUpdate` to the cache and stops tracking any coins the update reports
263/// as spent (their state is retained for reads; only the live subscription is dropped locally).
264async fn apply_coin_state_update(cache: &RwLock<CoinStateCache>, update: CoinStateUpdate) {
265    let spent: Vec<Bytes32> = update
266        .items
267        .iter()
268        .filter(|state| state.spent_height.is_some())
269        .map(|state| state.coin.coin_id())
270        .collect();
271
272    let mut cache = cache.write().await;
273    cache.apply_update(
274        &update.items,
275        update.height,
276        update.fork_height,
277        update.peak_hash,
278    );
279    cache.untrack_coins(&spent);
280}
281
282#[cfg(test)]
283mod tests {
284    use super::*;
285    use chia_protocol::{Coin, CoinState};
286
287    #[test]
288    fn submit_outcome_maps_ack_status() {
289        assert_eq!(SubmitOutcome::from_status(1), SubmitOutcome::Accepted);
290        assert_eq!(SubmitOutcome::from_status(2), SubmitOutcome::Pending);
291        assert_eq!(SubmitOutcome::from_status(3), SubmitOutcome::Failed);
292        assert_eq!(SubmitOutcome::from_status(9), SubmitOutcome::Unknown(9));
293        assert!(SubmitOutcome::Accepted.is_accepted());
294        assert!(SubmitOutcome::Pending.is_accepted());
295        assert!(!SubmitOutcome::Failed.is_accepted());
296    }
297
298    fn coin(seed: u8) -> Coin {
299        Coin::new(Bytes32::new([seed; 32]), Bytes32::new([seed ^ 2; 32]), 1)
300    }
301
302    #[tokio::test(flavor = "multi_thread", worker_threads = 1)]
303    async fn drive_loop_update_advances_cache_and_drops_spent_tracking() {
304        let cache = Arc::new(RwLock::new(CoinStateCache::new()));
305        let spent = coin(1);
306        let spent_id = spent.coin_id();
307        cache.write().await.track_coins([spent_id]);
308
309        let update = CoinStateUpdate {
310            height: 200,
311            fork_height: 199,
312            peak_hash: Bytes32::new([0xab; 32]),
313            items: vec![CoinState {
314                coin: spent,
315                created_height: Some(100),
316                spent_height: Some(150),
317            }],
318        };
319        apply_coin_state_update(&cache, update).await;
320
321        let cache = cache.read().await;
322        assert_eq!(cache.peak(), Some((200, Bytes32::new([0xab; 32]))));
323        assert!(
324            cache.get(spent_id).is_some(),
325            "spent coin state is retained for reads"
326        );
327        assert!(
328            !cache.is_subscribed_coin(spent_id),
329            "spent coin is untracked"
330        );
331    }
332}
333
334#[cfg(test)]
335mod simulator_tests {
336    use super::*;
337    use chia_protocol::SpendBundle;
338    use chia_wallet_sdk::test::PeerSimulator;
339    use dig_chainsource_interface::ChainSource;
340    use std::time::Duration;
341
342    async fn client_over_sim() -> (PeerSimulator, ChiaLightClient, chia_protocol::Coin) {
343        let sim = PeerSimulator::new().await.expect("start simulator");
344        let coin = sim.lock().await.new_coin(Bytes32::new([7; 32]), 500);
345        let (peer, receiver) = sim.connect_raw().await.expect("connect");
346        let config = ChiaPeerConfig::testnet11();
347        let tls = build_connector(&config).expect("connector");
348        let client = ChiaLightClient::from_connection(config, tls, peer, receiver);
349        (sim, client, coin)
350    }
351
352    /// Runs a blocking provider read off the async runtime (bridge's "outside a runtime" path).
353    fn blocking_read<T: Send>(f: impl FnOnce() -> T + Send) -> T {
354        std::thread::scope(|s| s.spawn(f).join().expect("thread"))
355    }
356
357    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
358    async fn subscribe_seeds_cache_and_provider_reads_it() {
359        let (_sim, client, coin) = client_over_sim().await;
360        client.subscribe_coins(vec![coin.coin_id()]).await.unwrap();
361
362        let provider = client.as_chain_source_provider(tokio::runtime::Handle::current());
363        let id = coin.coin_id();
364        let record = blocking_read(move || provider.coin_record(id)).unwrap();
365        assert!(record.is_some(), "subscribed coin is served from cache");
366    }
367
368    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
369    async fn drive_loop_tracks_peak_from_new_peak_wallet() {
370        let (_sim, client, _coin) = client_over_sim().await;
371        let mut peak = None;
372        for _ in 0..100 {
373            if let Some(p) = client.peak().await {
374                peak = Some(p);
375                break;
376            }
377            tokio::time::sleep(Duration::from_millis(10)).await;
378        }
379        assert!(
380            peak.is_some(),
381            "the drive-loop records a peak from NewPeakWallet"
382        );
383    }
384
385    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
386    async fn submit_invalid_bundle_reports_failure() {
387        let (_sim, client, _coin) = client_over_sim().await;
388        let outcome = client
389            .submit_spend(SpendBundle::new(vec![], chia::bls::Signature::default()))
390            .await
391            .unwrap();
392        assert_eq!(outcome, SubmitOutcome::Failed);
393    }
394
395    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
396    async fn subscribe_puzzle_hashes_and_unsubscribe_coins() {
397        let (_sim, client, coin) = client_over_sim().await;
398        let filters = CoinStateFilters {
399            include_spent: true,
400            include_unspent: true,
401            include_hinted: true,
402            min_amount: 0,
403        };
404        client
405            .subscribe_puzzle_hashes(vec![coin.puzzle_hash], filters)
406            .await
407            .unwrap();
408        client.subscribe_coins(vec![coin.coin_id()]).await.unwrap();
409        client
410            .unsubscribe_coins(vec![coin.coin_id()])
411            .await
412            .unwrap();
413    }
414
415    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
416    async fn provider_info_reflects_trusted_configuration() {
417        let sim = PeerSimulator::new().await.unwrap();
418        let (peer, receiver) = sim.connect_raw().await.unwrap();
419        let endpoint = "127.0.0.1:8444".parse().unwrap();
420        let config = ChiaPeerConfig::testnet11().with_trusted_endpoint(endpoint);
421        let tls = build_connector(&config).unwrap();
422        let client = ChiaLightClient::from_connection(config, tls, peer, receiver);
423        assert_eq!(client.provider_info().kind, ProviderKind::LocalNode);
424    }
425}