1use std::sync::{Arc, Mutex};
10
11use chia_protocol::{
12 Bytes32, CoinStateFilters, CoinStateUpdate, Message, NewPeakWallet, ProtocolMessageTypes,
13 SpendBundle,
14};
15use chia_wallet_sdk::chia::traits::Streamable;
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
29pub const DEFAULT_PROVIDER_PRIORITY: i32 = 20;
31
32#[derive(Debug, Clone, Copy, PartialEq, Eq)]
34pub enum SubmitOutcome {
35 Accepted,
37 Pending,
39 Failed,
41 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 pub fn is_accepted(self) -> bool {
57 matches!(self, SubmitOutcome::Accepted | SubmitOutcome::Pending)
58 }
59}
60
61pub 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 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 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 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 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 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 pub async fn peak(&self) -> Option<(u32, Bytes32)> {
145 self.cache.read().await.peak()
146 }
147
148 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 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 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 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 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 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
233fn 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 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
262async 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::chia::bls::Signature;
339 use chia_wallet_sdk::test::PeerSimulator;
340 use dig_chainsource_interface::ChainSource;
341 use std::time::Duration;
342
343 async fn client_over_sim() -> (PeerSimulator, ChiaLightClient, chia_protocol::Coin) {
344 let sim = PeerSimulator::new().await.expect("start simulator");
345 let coin = sim.lock().await.new_coin(Bytes32::new([7; 32]), 500);
346 let (peer, receiver) = sim.connect_raw().await.expect("connect");
347 let config = ChiaPeerConfig::testnet11();
348 let tls = build_connector(&config).expect("connector");
349 let client = ChiaLightClient::from_connection(config, tls, peer, receiver);
350 (sim, client, coin)
351 }
352
353 fn blocking_read<T: Send>(f: impl FnOnce() -> T + Send) -> T {
355 std::thread::scope(|s| s.spawn(f).join().expect("thread"))
356 }
357
358 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
359 async fn subscribe_seeds_cache_and_provider_reads_it() {
360 let (_sim, client, coin) = client_over_sim().await;
361 client.subscribe_coins(vec![coin.coin_id()]).await.unwrap();
362
363 let provider = client.as_chain_source_provider(tokio::runtime::Handle::current());
364 let id = coin.coin_id();
365 let record = blocking_read(move || provider.coin_record(id)).unwrap();
366 assert!(record.is_some(), "subscribed coin is served from cache");
367 }
368
369 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
370 async fn drive_loop_tracks_peak_from_new_peak_wallet() {
371 let (_sim, client, _coin) = client_over_sim().await;
372 let mut peak = None;
373 for _ in 0..100 {
374 if let Some(p) = client.peak().await {
375 peak = Some(p);
376 break;
377 }
378 tokio::time::sleep(Duration::from_millis(10)).await;
379 }
380 assert!(
381 peak.is_some(),
382 "the drive-loop records a peak from NewPeakWallet"
383 );
384 }
385
386 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
387 async fn submit_invalid_bundle_reports_failure() {
388 let (_sim, client, _coin) = client_over_sim().await;
389 let outcome = client
390 .submit_spend(SpendBundle::new(vec![], Signature::default()))
391 .await
392 .unwrap();
393 assert_eq!(outcome, SubmitOutcome::Failed);
394 }
395
396 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
397 async fn subscribe_puzzle_hashes_and_unsubscribe_coins() {
398 let (_sim, client, coin) = client_over_sim().await;
399 let filters = CoinStateFilters {
400 include_spent: true,
401 include_unspent: true,
402 include_hinted: true,
403 min_amount: 0,
404 };
405 client
406 .subscribe_puzzle_hashes(vec![coin.puzzle_hash], filters)
407 .await
408 .unwrap();
409 client.subscribe_coins(vec![coin.coin_id()]).await.unwrap();
410 client
411 .unsubscribe_coins(vec![coin.coin_id()])
412 .await
413 .unwrap();
414 }
415
416 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
417 async fn provider_info_reflects_trusted_configuration() {
418 let sim = PeerSimulator::new().await.unwrap();
419 let (peer, receiver) = sim.connect_raw().await.unwrap();
420 let endpoint = "127.0.0.1:8444".parse().unwrap();
421 let config = ChiaPeerConfig::testnet11().with_trusted_endpoint(endpoint);
422 let tls = build_connector(&config).unwrap();
423 let client = ChiaLightClient::from_connection(config, tls, peer, receiver);
424 assert_eq!(client.provider_info().kind, ProviderKind::LocalNode);
425 }
426}