chia_query/peer/light_client/mod.rs
1//! [`ChiaLightClient`] — a subscribing Chia wallet-protocol light client that BORROWS the crate's
2//! peer pool instead of dialling a connection of its own.
3//!
4//! # What moved here, and why
5//!
6//! This is `chia-peer`'s light client, folded into chia-query (dig_ecosystem#2761). `chia-peer`
7//! held its own TLS connection, its own DNS-introducer discovery, its own IPv6-first candidate
8//! ordering and its own reconnect loop — a second, independently-maintained copy of everything
9//! [`crate::peer::connect`] and [`crate::peer::pool`] already do. A node running both therefore
10//! held two connections to two independently-chosen full nodes, with two notions of the peak and
11//! nothing able to reconcile them.
12//!
13//! One crate cannot disagree with itself. That is the whole reason this is a MERGE rather than a
14//! relocation: `dig-node-core` pinned `chia-query = "=0.5.1"` — an exact-equals pin on a
15//! foundation crate — solely so two sibling crates would agree about a third crate's minor. There
16//! is now nothing left to disagree.
17//!
18//! # The subscription follows ONE session, and says which
19//!
20//! A subscription is server-side state on one connection, so this client PINS a pooled session as
21//! its anchor (see [`fetcher`]) and its drive-loop accepts frames from that source ALONE. The pool
22//! fans every held peer's frames into one subscription, and a `CoinStateUpdate` is an unsolicited
23//! push carrying no request id: accepting one from an unfollowed peer would let any held peer
24//! inject coin states this client never asked for, indistinguishable from the ones it did.
25//!
26//! # NC-12 is untouched
27//!
28//! Corroboration remains [`PeerPool`](crate::peer::pool::PeerPool)'s: plurality sizing, the
29//! `Discovered`-only independent count, [`CorroborationReadiness`], and periodic peer cycling all
30//! continue to run over the same pool this client borrows from. Folding a subscriber in adds a
31//! borrower; it removes no voices. Nothing here sets a `trusted` flag on a dialled peer — the pool
32//! dials with `PeerOptions::default()` and this module does not touch that.
33
34pub mod cache;
35pub mod error;
36pub mod fetcher;
37pub mod provider;
38
39use std::borrow::Cow;
40use std::net::SocketAddr;
41use std::sync::atomic::{AtomicBool, Ordering};
42use std::sync::Arc;
43use std::time::Duration;
44
45use chia_protocol::{Bytes32, CoinStateFilters, SpendBundle};
46use dig_chainsource_interface::{ProviderId, ProviderInfo, ProviderKind};
47use tokio::sync::RwLock;
48use tokio::task::JoinHandle;
49
50use crate::peer::connect::PeerOrigin;
51use crate::peer::frames::{FrameSource, FrameSubscription, PoolFrame, SourcedFrame};
52use crate::peer::PeerBackend;
53
54use cache::CoinStateCache;
55use error::LightClientError;
56use fetcher::{CoinStateFetcher, PooledFetcher};
57
58pub use provider::LightClientProvider;
59
60/// The default try-order priority a light-client provider registers with (lower = tried earlier).
61///
62/// 20 places it ahead of the coinset.org HTTP tier, which is the ordering `chia-peer` established
63/// and dig-node depends on: a subscribing session sees a spend land before an HTTP index does.
64pub const DEFAULT_PROVIDER_PRIORITY: i32 = 20;
65
66/// How many frames a light client may fall behind before its subscription is terminated.
67///
68/// Terminating is the intended outcome of overflow, not a failure of it — a SKIPPED
69/// `CoinStateUpdate` is a spend the cache never learns about, after which every read reports spent
70/// money as present. Sized to absorb a burst of per-block pushes across a full pool while staying
71/// far below anything that would make a slow consumer's backlog a memory problem.
72const FRAME_BUFFER: usize = 1024;
73
74/// The outcome of submitting a spend bundle, mapped from the node's `TransactionAck` status byte.
75#[derive(Debug, Clone, Copy, PartialEq, Eq)]
76pub enum SubmitOutcome {
77 /// Accepted into the mempool (ack status `1`) — pending block confirmation.
78 Accepted,
79 /// Held pending by the node (ack status `2`).
80 Pending,
81 /// Rejected by the node (ack status `3`).
82 Failed,
83 /// An unrecognised ack status byte.
84 Unknown(u8),
85}
86
87impl SubmitOutcome {
88 fn from_status(status: u8) -> Self {
89 match status {
90 1 => SubmitOutcome::Accepted,
91 2 => SubmitOutcome::Pending,
92 3 => SubmitOutcome::Failed,
93 other => SubmitOutcome::Unknown(other),
94 }
95 }
96
97 /// Whether the node took custody of the bundle (accepted or pending), rather than rejecting it.
98 pub fn is_accepted(self) -> bool {
99 matches!(self, SubmitOutcome::Accepted | SubmitOutcome::Pending)
100 }
101}
102
103/// A subscribing Chia wallet-protocol light client over a shared [`PeerBackend`].
104pub struct ChiaLightClient {
105 fetcher: PooledFetcher,
106 cache: Arc<RwLock<CoinStateCache>>,
107 /// Set by the drive-loop when the anchor session ends, so a caller can tell a quiet chain from
108 /// a stream that stopped. Cleared by [`reconnect`](Self::reconnect).
109 ///
110 /// Signalled rather than acted on: re-arming needs the caller's runtime and its error handling,
111 /// and a drive-loop that re-subscribed itself would retry forever against a peer set it cannot
112 /// see, with nothing able to observe that it was failing.
113 rearm_needed: Arc<AtomicBool>,
114 drive: Option<JoinHandle<()>>,
115}
116
117impl ChiaLightClient {
118 /// Builds a light client over `backend`'s pool and starts the drive-loop that keeps its
119 /// subscription cache current.
120 ///
121 /// No connection is made here: the pool is already holding sessions, and this client pins one
122 /// of them the first time it subscribes.
123 pub async fn new(backend: Arc<PeerBackend>, request_timeout: Duration) -> Self {
124 let cache = Arc::new(RwLock::new(CoinStateCache::new()));
125 let fetcher = PooledFetcher::new(backend.clone(), request_timeout);
126 let rearm_needed = Arc::new(AtomicBool::new(false));
127 let subscription = backend.subscribe_frames(FRAME_BUFFER).await;
128 let drive = spawn_drive_loop(
129 subscription,
130 cache.clone(),
131 fetcher.clone(),
132 rearm_needed.clone(),
133 );
134 Self {
135 fetcher,
136 cache,
137 rearm_needed,
138 drive: Some(drive),
139 }
140 }
141
142 /// Subscribes to `coin_ids` on the anchor session and seeds the cache with their current state.
143 ///
144 /// Wraps `request_coin_state(subscribe = true)`; future changes stream back via the drive-loop.
145 pub async fn subscribe_coins(&self, coin_ids: Vec<Bytes32>) -> Result<(), LightClientError> {
146 let states = self.fetcher.coin_states(coin_ids.clone(), true).await?;
147 let mut cache = self.cache.write().await;
148 cache.track_coins(coin_ids);
149 cache.seed(states);
150 Ok(())
151 }
152
153 /// Subscribes to every coin paying to `puzzle_hashes` under `filters`, seeding the cache.
154 ///
155 /// Wraps `request_puzzle_state(subscribe = true)` (paging until finished).
156 pub async fn subscribe_puzzle_hashes(
157 &self,
158 puzzle_hashes: Vec<Bytes32>,
159 filters: CoinStateFilters,
160 ) -> Result<(), LightClientError> {
161 let states = self
162 .fetcher
163 .puzzle_states(puzzle_hashes.clone(), filters, true)
164 .await?;
165 let mut cache = self.cache.write().await;
166 cache.track_puzzle_hashes(puzzle_hashes);
167 cache.seed(states);
168 Ok(())
169 }
170
171 /// Submits `bundle` to the network, mapping the node's ack to a typed [`SubmitOutcome`].
172 ///
173 /// This is a WRITE path and is deliberately NOT part of the reads-only `ChainSource` surface.
174 pub async fn submit_spend(
175 &self,
176 bundle: SpendBundle,
177 ) -> Result<SubmitOutcome, LightClientError> {
178 let status = self.fetcher.send_transaction(bundle).await?;
179 Ok(SubmitOutcome::from_status(status))
180 }
181
182 /// The current peak `(height, header_hash)` as observed on the followed session, if known.
183 ///
184 /// Deliberately the FOLLOWED peer's peak and not
185 /// [`PeerPool::peak_height`](crate::peer::pool::PeerPool::peak_height), which is the highest
186 /// any held peer has claimed. The cache's coin states come from one session, and the
187 /// no-coin-above-the-peak invariant that keeps a confirmation count from underflowing is only
188 /// meaningful if the peak came from the same session as the coins.
189 pub async fn peak(&self) -> Option<(u32, Bytes32)> {
190 self.cache.read().await.peak()
191 }
192
193 /// Removes the subscription to `coin_ids` and stops tracking them locally.
194 pub async fn unsubscribe_coins(&self, coin_ids: Vec<Bytes32>) -> Result<(), LightClientError> {
195 self.fetcher
196 .remove_coin_subscriptions(coin_ids.clone())
197 .await?;
198 self.cache.write().await.untrack_coins(&coin_ids);
199 Ok(())
200 }
201
202 /// Whether the followed session has ENDED, so the subscription set is no longer armed anywhere.
203 ///
204 /// A consumer polling this can tell a chain with nothing to say from a stream that stopped
205 /// talking — the distinction a silent light client otherwise hides.
206 pub fn needs_rearm(&self) -> bool {
207 self.rearm_needed.load(Ordering::Acquire)
208 }
209
210 /// Re-anchors on a live pooled session and re-arms the existing subscription set, so a dropped
211 /// connection recovers without the caller re-subscribing.
212 ///
213 /// Cheaper than its `chia-peer` ancestor by exactly one dial: the pool is already holding
214 /// replacement sessions, so this re-issues subscriptions rather than reconnecting.
215 pub async fn reconnect(&self) -> Result<(), LightClientError> {
216 let (coins, puzzle_hashes) = {
217 let cache = self.cache.read().await;
218 (cache.subscribed_coins(), cache.subscribed_puzzle_hashes())
219 };
220 if !coins.is_empty() {
221 self.subscribe_coins(coins).await?;
222 }
223 if !puzzle_hashes.is_empty() {
224 self.subscribe_puzzle_hashes(puzzle_hashes, all_coin_states())
225 .await?;
226 }
227 // Cleared only once every re-subscription has SUCCEEDED. Clearing first would report an
228 // armed subscription set after a rearm that failed halfway.
229 self.rearm_needed.store(false, Ordering::Release);
230 Ok(())
231 }
232
233 /// Exposes the read side as a [`LightClientProvider`] for registration in a chain-source
234 /// registry.
235 ///
236 /// `handle` MUST belong to a multi-thread tokio runtime (the sync facade blocks on it).
237 ///
238 /// Call this AFTER subscribing. The descriptor's [`ProviderKind`] is read from the session this
239 /// client is actually anchored to, and before the first subscription there is none — so an
240 /// early call reports the conservative [`Custom`](ProviderKind::Custom) rather than guessing.
241 pub async fn as_chain_source_provider(
242 &self,
243 handle: tokio::runtime::Handle,
244 ) -> LightClientProvider {
245 LightClientProvider::new(
246 Arc::new(self.fetcher.clone()),
247 self.cache.clone(),
248 handle,
249 self.provider_info().await,
250 )
251 }
252
253 /// The provider descriptor this client registers with.
254 ///
255 /// [`LocalNode`](ProviderKind::LocalNode) when the answering session was reached from a
256 /// configured or co-resident address, [`Custom`](ProviderKind::Custom) when it came from a DNS
257 /// introducer. This is what the pool OBSERVED, not what an operator declared — `chia-peer`
258 /// derived the same field from a config flag, so a discovered peer answering a
259 /// `config.endpoint` client was reported as the operator's own node.
260 ///
261 /// `trustless` is always `false`: a light-client answer is one peer's word. The registry's
262 /// custody view is operator-assigned and fails closed, and this flag is advisory there — it
263 /// never grants trust, which is why reporting the origin honestly matters more than the
264 /// priority does.
265 pub async fn provider_info(&self) -> ProviderInfo {
266 let kind = match self.fetcher.current_anchor().await.map(|a| a.origin) {
267 Some(PeerOrigin::Priority) => ProviderKind::LocalNode,
268 Some(PeerOrigin::Discovered) | None => ProviderKind::Custom,
269 };
270 ProviderInfo {
271 id: ProviderId(Cow::Borrowed("chia-query-light-client")),
272 kind,
273 priority: DEFAULT_PROVIDER_PRIORITY,
274 trustless: false,
275 }
276 }
277}
278
279impl Drop for ChiaLightClient {
280 fn drop(&mut self) {
281 if let Some(handle) = self.drive.take() {
282 handle.abort();
283 }
284 }
285}
286
287/// The filter set a re-arm uses: everything, so a re-subscription cannot narrow what the original
288/// subscription covered.
289fn all_coin_states() -> CoinStateFilters {
290 CoinStateFilters {
291 include_spent: true,
292 include_unspent: true,
293 include_hinted: true,
294 min_amount: 0,
295 }
296}
297
298/// Spawns the background task that keeps `cache` current from the pool's frame fan-out.
299///
300/// Only frames from the ANCHOR session are applied. Every other held peer's frames are dropped:
301/// they answer questions this client never asked, and a `CoinStateUpdate` carries no request id to
302/// tell the two apart.
303fn spawn_drive_loop(
304 mut subscription: FrameSubscription,
305 cache: Arc<RwLock<CoinStateCache>>,
306 fetcher: PooledFetcher,
307 rearm_needed: Arc<AtomicBool>,
308) -> JoinHandle<()> {
309 tokio::spawn(async move {
310 while let Some(sourced) = subscription.recv().await {
311 if !follows(fetcher.anchor_address().await, sourced.source) {
312 continue;
313 }
314 let address = sourced.source.address;
315 if let AfterFrame::Resubscribe = apply_frame(&cache, sourced).await {
316 // Order matters: unpin FIRST, so a caller woken by the flag re-anchors on a live
317 // session rather than re-arming against the one that just died.
318 fetcher.release_anchor(address).await;
319 rearm_needed.store(true, Ordering::Release);
320 }
321 }
322 // The subscription ended — the pool was dropped, or this client fell behind and was
323 // terminated rather than silently skipped. Either way the cache can no longer be trusted to
324 // be current, and saying so is the point (see `frames::FrameSubscription`).
325 rearm_needed.store(true, Ordering::Release);
326 })
327}
328
329/// Whether a frame from `source` belongs to the session this client is following.
330///
331/// `None` — nothing subscribed yet — follows NOTHING. Before the first subscription there is no
332/// push this client could have asked for, so treating an unanchored client as following everything
333/// would admit exactly the unsolicited coin states the anchor exists to exclude.
334fn follows(anchor: Option<SocketAddr>, source: FrameSource) -> bool {
335 anchor.is_some_and(|address| address == source.address)
336}
337
338/// What the drive-loop must do about the SESSION after a frame has been applied to the cache.
339///
340/// Returned rather than done inline so the cache effect of a frame can be tested apart from the
341/// session bookkeeping, which needs a live peer to express at all.
342#[derive(Debug, Clone, Copy, PartialEq, Eq)]
343enum AfterFrame {
344 /// The session continues; nothing further to do.
345 Continue,
346 /// The followed session is gone. Unpin it and tell the caller to re-arm.
347 Resubscribe,
348}
349
350/// Applies one attributed frame from the followed session to `cache`.
351async fn apply_frame(cache: &RwLock<CoinStateCache>, sourced: SourcedFrame) -> AfterFrame {
352 match sourced.frame {
353 // A new session at the followed address is a DIFFERENT connection, whose subscription set
354 // is empty. Anything derived from its predecessor is stale, so the client re-arms rather
355 // than reading the replacement's silence as an unchanging chain.
356 PoolFrame::Reset => AfterFrame::Resubscribe,
357 PoolFrame::Peak {
358 height,
359 header_hash,
360 } => {
361 cache.write().await.set_peak(height, header_hash);
362 AfterFrame::Continue
363 }
364 PoolFrame::CoinStates {
365 height,
366 fork_height,
367 peak_hash,
368 items,
369 } => {
370 let spent: Vec<Bytes32> = items
371 .iter()
372 .filter(|state| state.spent_height.is_some())
373 .map(|state| state.coin.coin_id())
374 .collect();
375 let mut cache = cache.write().await;
376 cache.apply_update(&items, height, fork_height, peak_hash);
377 cache.untrack_coins(&spent);
378 AfterFrame::Continue
379 }
380 PoolFrame::SessionEnded { reason } => {
381 log::debug!(
382 "light-client session {:?} ended: {reason:?}",
383 sourced.source.session
384 );
385 AfterFrame::Resubscribe
386 }
387 }
388}
389
390#[cfg(test)]
391mod tests;