Skip to main content

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;