Skip to main content

satd_events_client/
client.rs

1//! The streaming client: connect, `Subscribe` firehose, and the `Watch` handle
2//! with its typed watch helpers. This module establishes the connection, auth,
3//! and cursor-capturing event stream that the reconnect/replay resilience layer
4//! (the `resilience` module) builds on.
5
6use std::sync::{Arc, Mutex};
7use std::time::Duration;
8
9use satd_events_proto::v1 as pb;
10use satd_events_proto::v1::node_event_stream_client::NodeEventStreamClient;
11use tokio::sync::mpsc;
12use tokio_stream::wrappers::ReceiverStream;
13use tonic::metadata::{Ascii, MetadataMap, MetadataValue};
14use tonic::transport::{Channel, Endpoint};
15
16use crate::compat::{self, Compat};
17use crate::error::StreamError;
18use crate::event::{Cursor, Event};
19
20/// Bounded control-channel depth for a `Watch` stream.
21const CONTROL_BUFFER: usize = 16;
22
23/// Category bitfield helpers for [`SubscribeOptions::categories`].
24///
25/// `0` (the default, [`Categories::ALL`]) means "all categories". Combine the
26/// others with `|`.
27pub struct Categories;
28
29impl Categories {
30    /// All categories (the server default).
31    pub const ALL: u32 = 0;
32    /// Mempool events.
33    pub const MEMPOOL: u32 = 1;
34    /// Chain (block connect/disconnect/reorg) events.
35    pub const CHAIN: u32 = 2;
36    /// Heartbeats.
37    pub const HEARTBEAT: u32 = 4;
38    /// BIP 352 silent-payment per-block tweaks (Tier 1 client-side scan). Unlike
39    /// the other bits this is **not** part of the [`ALL`](Self::ALL) (`0`)
40    /// default: a subscription must request it explicitly, so an existing
41    /// `0`-subscriber never starts receiving tweak volume after a node upgrade.
42    /// Requires the node's tweak index (`silentpaymentindex=1`); a `tweaks`
43    /// subscription against a node with it disabled is refused in-band.
44    pub const TWEAKS: u32 = 8;
45    /// Node-health conditions the daemon detects about itself
46    /// ([`Event::Status`](crate::Event::Status)). Like
47    /// [`TWEAKS`](Self::TWEAKS) this is **not** part of the [`ALL`](Self::ALL)
48    /// (`0`) default — a client written against an older node never starts
49    /// receiving a body it has no parser for. Unlike `TWEAKS` there is no index
50    /// prerequisite: any node serves it.
51    pub const STATUS: u32 = 16;
52}
53
54/// Options for a [`StreamClient::subscribe`] firehose.
55#[derive(Debug, Clone, Default)]
56pub struct SubscribeOptions {
57    /// Category bitfield; `0` = all. See [`Categories`].
58    pub categories: u32,
59    /// Durable replay anchor. When set, the server replays confirmed history
60    /// forward from this cursor, then joins live with no gap or duplicate.
61    pub from_cursor: Option<Cursor>,
62    /// Forward-only dedup filter: drop events with `seq <= since_seq`. Use after
63    /// a brief reconnect within the broadcast window — not for durable replay.
64    pub since_seq: Option<u64>,
65    /// Silent-payment tweak dust floor (satoshis): drop [`TweakEntry`](crate::TweakEntry)
66    /// whose `max_value` is below this, and flag the block `filtered`. Only
67    /// meaningful with the [`TWEAKS`](Categories::TWEAKS) category. `None` = keep
68    /// every tweak.
69    pub tweak_dust_limit: Option<u64>,
70    /// Compact tweak form: when `true`, tweak entries carry only the 33-byte
71    /// tweak (no `txid`/`max_value`), minimizing bytes for a pure client-side
72    /// scan. Only meaningful with the [`TWEAKS`](Categories::TWEAKS) category.
73    pub tweaks_only: bool,
74    /// Mempool-time tweaks ("Tier 1.5"): when `true`, the server additionally
75    /// streams an [`Event::MempoolTweak`](crate::Event::MempoolTweak) at each
76    /// SP-eligible transaction's admission, for mempool-latency detection without
77    /// uploading a scan key. A modifier on the [`TWEAKS`](Categories::TWEAKS)
78    /// category — the server rejects it if that bit is not set. `false` (default)
79    /// = block tweaks only. Ephemeral and best-effort: mempool tweaks are not
80    /// replayable, so a missed admission is caught at confirmation via
81    /// [`Event::BlockTweaks`](crate::Event::BlockTweaks).
82    pub mempool_tweaks: bool,
83    /// Include the transaction's taproot outputs on each
84    /// [`Event::BlockTweaks`](crate::Event::BlockTweaks) entry
85    /// ([`TweakEntry::taproot_outputs`](crate::TweakEntry::taproot_outputs)), so
86    /// a match is confirmed against the on-chain output without fetching the
87    /// block. `false` (default) keeps the confirmed firehose lean. A modifier on
88    /// the [`TWEAKS`](Categories::TWEAKS) category — the server rejects it if that
89    /// bit is not set, or if the node has no block source. A
90    /// [`MempoolTweak`](crate::Event::MempoolTweak) carries its outputs
91    /// regardless of this flag.
92    pub tweak_outputs: bool,
93    /// Cut-through: drop [`Event::BlockTweaks`](crate::Event::BlockTweaks)
94    /// entries whose taproot outputs are **all** already spent on the confirmed
95    /// chain. A balance scan then never runs ECDH against a coin that is already
96    /// gone. A surviving entry carries its **full** taproot output set —
97    /// spentness decides whether an entry survives, never which outputs it
98    /// carries, so BIP 352 `k` enumeration is never truncated.
99    ///
100    /// A **restore** that needs transaction history must leave this `false`: a
101    /// payment received and later spent is omitted entirely, so the balance is
102    /// right and the history is not. Spentness is evaluated at serve time, so the
103    /// same historical block can yield fewer entries on a later scan. Dropped
104    /// entries set the block's `filtered` flag. A modifier on the
105    /// [`TWEAKS`](Categories::TWEAKS) category — the server rejects it without
106    /// that bit, or on a node with no block source. Never applies to
107    /// [`MempoolTweak`](crate::Event::MempoolTweak).
108    pub tweak_unspent_only: bool,
109}
110
111impl SubscribeOptions {
112    // `..Default::default()` is redundant against the current workspace proto but
113    // retained so this published crate still compiles if the schema grows fields
114    // ahead of this literal (the same resilience the released-proto pin needs).
115    #[allow(clippy::needless_update)]
116    fn into_request(self) -> pb::SubscribeRequest {
117        pb::SubscribeRequest {
118            categories: self.categories,
119            since_seq: self.since_seq,
120            from_cursor: self.from_cursor,
121            tweak_dust_limit: self.tweak_dust_limit,
122            // Send the flag only when set, so a default subscription is
123            // byte-identical to one built before these knobs existed.
124            tweaks_only: self.tweaks_only.then_some(true),
125            mempool_tweaks: self.mempool_tweaks.then_some(true),
126            tweak_outputs: self.tweak_outputs.then_some(true),
127            tweak_unspent_only: self.tweak_unspent_only.then_some(true),
128            ..Default::default()
129        }
130    }
131}
132
133/// Server-enforced cap on the number of *distinct* labels one scan-key target
134/// may carry (mirrors the node's `MAX_SP_LABELS_PER_TARGET`). The server rejects
135/// an over-label target; enforcing the same bound client-side turns that into a
136/// deterministic error at the call site instead of a silent skip.
137pub const MAX_SP_LABELS_PER_TARGET: usize = 16;
138
139/// Server-enforced cap on the number of scan-key targets per connection (mirrors
140/// the node's `MAX_SP_TARGETS_PER_CONNECTION`). The server silently sheds an
141/// over-cap add; a stateful [`ResilientWatch`](crate::ResilientWatch) enforces
142/// this bound before recording/sending so it never mirrors and replays a target
143/// the server dropped.
144pub const MAX_SP_TARGETS_PER_CONNECTION: usize = 16;
145
146/// A BIP 352 scan-key watch target (Tier 2): a `(scan_secret, spend_pubkey)`
147/// pair plus optional labels, registered via
148/// [`WatchHandle::add_silent_payments`]. The node runs the ECDH match and pushes
149/// [`Event::SilentPaymentMatched`](crate::Event::SilentPaymentMatched).
150///
151/// `scan_secret` is a **watch credential**, not a spending key: disclosing it
152/// lets the node (and anyone who compromises it) learn *which* outputs are
153/// yours, but never spend them — spend authority stays with `B_spend`'s private
154/// half, which is never sent. `Debug` redacts the scan secret.
155#[derive(Clone)]
156pub struct SilentPaymentTarget {
157    /// 32-byte scan secret `b_scan`.
158    pub scan_secret: [u8; 32],
159    /// 33-byte compressed spend public key `B_spend` (public half only).
160    pub spend_pubkey: [u8; 33],
161    /// Receiver label integers to also match. Include `0` to catch change.
162    pub labels: Vec<u32>,
163}
164
165impl core::fmt::Debug for SilentPaymentTarget {
166    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
167        f.debug_struct("SilentPaymentTarget")
168            .field("scan_secret", &"<redacted>")
169            .field("spend_pubkey", &hex_lower(&self.spend_pubkey))
170            .field("labels", &self.labels)
171            .finish()
172    }
173}
174
175impl Drop for SilentPaymentTarget {
176    /// Scrub the retained scan secret on drop. `b_scan` is a watch credential —
177    /// the long-lived copies held by a `ResilientWatch` mirror (and any clones)
178    /// must not linger in freed memory once a target is removed or the mirror is
179    /// torn down. (The transient `Vec<u8>` a `to_proto` copy places on the wire
180    /// is owned by the outbound message and cleared with it; this covers every
181    /// in-process copy the SDK retains.)
182    fn drop(&mut self) {
183        use zeroize::Zeroize;
184        self.scan_secret.zeroize();
185    }
186}
187
188impl SilentPaymentTarget {
189    pub(crate) fn to_proto(&self) -> pb::SilentPaymentTarget {
190        pb::SilentPaymentTarget {
191            scan_secret: self.scan_secret.to_vec(),
192            spend_pubkey: self.spend_pubkey.to_vec(),
193            labels: self.labels.clone(),
194        }
195    }
196
197    /// The target's identity `b_scan·G` (33-byte compressed) — the key used to
198    /// remove it ([`WatchHandle::remove_silent_payments`]) and how a
199    /// [`ResilientWatch`](crate::ResilientWatch) tracks it. Requires the
200    /// `bitcoin` feature (enabled by default) for the elliptic-curve derivation.
201    #[cfg(feature = "bitcoin")]
202    pub fn scan_pubkey(&self) -> Result<[u8; 33], StreamError> {
203        use bitcoin::secp256k1::{PublicKey, Secp256k1, SecretKey};
204        let sk = SecretKey::from_slice(&self.scan_secret)
205            .map_err(|_| StreamError::InvalidArgument("scan_secret is not a valid scalar".into()))?;
206        let secp = Secp256k1::signing_only();
207        Ok(PublicKey::from_secret_key(&secp, &sk).serialize())
208    }
209
210    /// Validate **both** curve values — the `scan_secret` scalar and the
211    /// `spend_pubkey` point — and return the identity `b_scan·G`. Client-side
212    /// validation turns a malformed target into a deterministic
213    /// [`InvalidArgument`](StreamError::InvalidArgument) at the call site instead
214    /// of a silent server-side skip that would return `Ok(())` while installing
215    /// no watch (and repeat the invalid target on every resilient reconnect).
216    /// Requires the `bitcoin` feature (enabled by default).
217    #[cfg(feature = "bitcoin")]
218    pub fn validate(&self) -> Result<[u8; 33], StreamError> {
219        let id = self.scan_pubkey()?; // validates the b_scan scalar
220        bitcoin::secp256k1::PublicKey::from_slice(&self.spend_pubkey)
221            .map_err(|_| StreamError::InvalidArgument("spend_pubkey is not a valid point".into()))?;
222        // Mirror the server's per-target label cap: an over-label target is
223        // otherwise silently skipped by the node while the client believes it
224        // was installed (and a resilient reconnect replays it forever).
225        let mut labels = self.labels.clone();
226        labels.sort_unstable();
227        labels.dedup();
228        if labels.len() > MAX_SP_LABELS_PER_TARGET {
229            return Err(StreamError::InvalidArgument(format!(
230                "too many silent-payment labels: {} distinct (max {MAX_SP_LABELS_PER_TARGET})",
231                labels.len()
232            )));
233        }
234        Ok(id)
235    }
236}
237
238/// Lowercase hex, for redacting `Debug` impls (no external dep).
239fn hex_lower(bytes: &[u8]) -> String {
240    let mut s = String::with_capacity(bytes.len() * 2);
241    for b in bytes {
242        s.push_str(&format!("{b:02x}"));
243    }
244    s
245}
246
247/// A live stream of typed [`Event`]s, shared by `Subscribe` and `Watch`.
248///
249/// As confirmed events flow, the stream captures their durable [`Cursor`];
250/// [`cursor`](Self::cursor) returns the latest, which a consumer persists and
251/// presents as [`SubscribeOptions::from_cursor`] to resume.
252pub struct EventStream {
253    inner: tonic::Streaming<pb::NodeEvent>,
254    last_cursor: Option<Cursor>,
255    /// Set once the first event's `schema_version` has been checked.
256    schema_checked: bool,
257}
258
259impl EventStream {
260    /// Await the next event. Returns `Ok(None)` when the server closes the
261    /// stream.
262    pub async fn message(&mut self) -> Result<Option<Event>, StreamError> {
263        match self.inner.message().await {
264            Ok(Some(ev)) => {
265                // The response header already carried the schema on a node
266                // that sends one. This covers a node that predates the header.
267                // One event is enough: a node does not change schema mid-stream.
268                if !self.schema_checked {
269                    self.schema_checked = true;
270                    if ev.schema_version != satd_events_proto::SCHEMA_VERSION {
271                        return Err(StreamError::SchemaMismatch {
272                            node: ev.schema_version,
273                            sdk: satd_events_proto::SCHEMA_VERSION,
274                        });
275                    }
276                }
277                if let Some(c) = ev.cursor {
278                    self.last_cursor = Some(c);
279                }
280                Ok(Some(Event::from(ev)))
281            }
282            Ok(None) => Ok(None),
283            Err(status) => Err(StreamError::from_status(status)),
284        }
285    }
286
287    /// The most recent durable cursor seen on this stream, if any. Persist it
288    /// to resume after a disconnect.
289    pub fn cursor(&self) -> Option<&Cursor> {
290        self.last_cursor.as_ref()
291    }
292
293    fn new(inner: tonic::Streaming<pb::NodeEvent>) -> Self {
294        EventStream { inner, last_cursor: None, schema_checked: false }
295    }
296}
297
298/// Auto-close policy for a transaction lifecycle watch: self-evict and emit
299/// `TxidFinalized` once the tx is buried this many confirmations deep.
300#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
301pub enum AutoClose {
302    /// Never auto-close; the lifecycle watch persists until removed.
303    #[default]
304    Never,
305    /// Auto-close once the tx reaches this confirmation depth (>= 1).
306    AtDepth(u32),
307}
308
309impl AutoClose {
310    fn depth(self) -> u32 {
311        match self {
312            AutoClose::Never => 0,
313            AutoClose::AtDepth(d) => d,
314        }
315    }
316}
317
318/// A handle to send control messages on a bidirectional `Watch` stream.
319///
320/// Typed helpers build and send the right [`SubscribeControl`](pb::SubscribeControl)
321/// for each watch kind; [`send_control`](Self::send_control) remains for raw
322/// access. Empty inputs are no-ops (nothing is sent). Dropping the handle closes
323/// the control channel, which the server uses to tear the stream down.
324pub struct WatchHandle {
325    tx: mpsc::Sender<pb::SubscribeControl>,
326}
327
328impl WatchHandle {
329    /// Send a raw [`SubscribeControl`](pb::SubscribeControl) message.
330    pub async fn send_control(&self, ctrl: pb::SubscribeControl) -> Result<(), StreamError> {
331        self.tx.send(ctrl).await.map_err(|_| StreamError::ControlClosed)
332    }
333
334    async fn send_msg(&self, msg: pb::subscribe_control::Msg) -> Result<(), StreamError> {
335        self.send_control(pb::SubscribeControl { msg: Some(msg) }).await
336    }
337
338    /// Add scripthashes (each `sha256(scriptPubKey)`), with an optional per-script
339    /// `min_value` floor in satoshis. Matches below a script's floor are
340    /// suppressed (a `None` floor delivers everything). Re-asserting a held
341    /// scripthash updates its floor. Charges one watch-quota unit per scripthash.
342    pub async fn add_scripts(
343        &self,
344        items: impl IntoIterator<Item = ([u8; 32], Option<u64>)>,
345    ) -> Result<(), StreamError> {
346        let items: Vec<_> = items.into_iter().collect();
347        if items.is_empty() {
348            return Ok(());
349        }
350        let scripthashes = items.iter().map(|(h, _)| h.to_vec()).collect();
351        // `min_values` is empty when no floor is set on any script; otherwise it
352        // must be parallel to `scripthashes`, with 0 (deliver-all) for the
353        // unfloored entries.
354        let min_values = if items.iter().any(|(_, f)| f.is_some()) {
355            items.iter().map(|(_, f)| f.unwrap_or(0)).collect()
356        } else {
357            Vec::new()
358        };
359        self.send_msg(pb::subscribe_control::Msg::AddScripts(pb::AddScripts {
360            scripthashes,
361            min_values,
362        }))
363        .await
364    }
365
366    /// Remove scripthashes from the watch-set, releasing their quota.
367    pub async fn remove_scripts(
368        &self,
369        scripthashes: impl IntoIterator<Item = [u8; 32]>,
370    ) -> Result<(), StreamError> {
371        let scripthashes: Vec<Vec<u8>> = scripthashes.into_iter().map(|h| h.to_vec()).collect();
372        if scripthashes.is_empty() {
373            return Ok(());
374        }
375        self.send_msg(pb::subscribe_control::Msg::RemoveScripts(pb::RemoveScripts {
376            scripthashes,
377        }))
378        .await
379    }
380
381    /// Add outpoints (`txid:vout`) to the watch-set. Charges one unit each.
382    pub async fn add_outpoints(
383        &self,
384        outpoints: impl IntoIterator<Item = ([u8; 32], u32)>,
385    ) -> Result<(), StreamError> {
386        let outpoints: Vec<pb::Outpoint> = outpoints
387            .into_iter()
388            .map(|(txid, vout)| pb::Outpoint { txid: txid.to_vec(), vout })
389            .collect();
390        if outpoints.is_empty() {
391            return Ok(());
392        }
393        self.send_msg(pb::subscribe_control::Msg::AddOutpoints(pb::AddOutpoints { outpoints }))
394            .await
395    }
396
397    /// Remove outpoints from the watch-set, releasing their quota.
398    pub async fn remove_outpoints(
399        &self,
400        outpoints: impl IntoIterator<Item = ([u8; 32], u32)>,
401    ) -> Result<(), StreamError> {
402        let outpoints: Vec<pb::Outpoint> = outpoints
403            .into_iter()
404            .map(|(txid, vout)| pb::Outpoint { txid: txid.to_vec(), vout })
405            .collect();
406        if outpoints.is_empty() {
407            return Ok(());
408        }
409        self.send_msg(pb::subscribe_control::Msg::RemoveOutpoints(pb::RemoveOutpoints {
410            outpoints,
411        }))
412        .await
413    }
414
415    /// Add persistent lifecycle watches on txids (seen → confirmed → replaced /
416    /// evicted / unconfirmed). `auto_close` optionally self-evicts the watch once
417    /// the tx is buried. Charges one unit per txid.
418    pub async fn add_tx_lifecycle(
419        &self,
420        txids: impl IntoIterator<Item = [u8; 32]>,
421        auto_close: AutoClose,
422    ) -> Result<(), StreamError> {
423        let txids: Vec<Vec<u8>> = txids.into_iter().map(|t| t.to_vec()).collect();
424        if txids.is_empty() {
425            return Ok(());
426        }
427        self.send_msg(pb::subscribe_control::Msg::AddTransactions(pb::AddTransactions {
428            txids,
429            min_depths: Vec::new(),
430            auto_close_depth: auto_close.depth(),
431        }))
432        .await
433    }
434
435    /// Remove lifecycle watches on txids.
436    pub async fn remove_tx_lifecycle(
437        &self,
438        txids: impl IntoIterator<Item = [u8; 32]>,
439    ) -> Result<(), StreamError> {
440        let txids: Vec<Vec<u8>> = txids.into_iter().map(|t| t.to_vec()).collect();
441        if txids.is_empty() {
442            return Ok(());
443        }
444        self.send_msg(pb::subscribe_control::Msg::RemoveTransactions(pb::RemoveTransactions {
445            txids,
446            min_depths: Vec::new(),
447        }))
448        .await
449    }
450
451    /// Arm single-shot depth alarms over the **cross product** of `txids` and
452    /// `depths`: each txid fires `TxidDepthReached` once it is `depth`
453    /// confirmations deep, then self-evicts. Charges one unit per (txid, depth).
454    ///
455    /// Depths must be `>= 1`; depths `< 1` are dropped client-side. If that
456    /// leaves no valid depths (or no txids), this is a no-op — importantly, it
457    /// does **not** send an empty `min_depths`, which the server would otherwise
458    /// reinterpret as a *lifecycle* add rather than a depth alarm.
459    pub async fn add_depth_alarms(
460        &self,
461        txids: impl IntoIterator<Item = [u8; 32]>,
462        depths: impl IntoIterator<Item = u32>,
463    ) -> Result<(), StreamError> {
464        let txids: Vec<Vec<u8>> = txids.into_iter().map(|t| t.to_vec()).collect();
465        let min_depths: Vec<u32> = depths.into_iter().filter(|d| *d >= 1).collect();
466        if txids.is_empty() || min_depths.is_empty() {
467            return Ok(());
468        }
469        self.send_msg(pb::subscribe_control::Msg::AddTransactions(pb::AddTransactions {
470            txids,
471            min_depths,
472            auto_close_depth: 0,
473        }))
474        .await
475    }
476
477    /// Remove depth alarms over the cross product of `txids` and `depths`.
478    /// Depths `< 1` are dropped client-side; an all-invalid (or empty) call is a
479    /// no-op and never sends an empty `min_depths` (which would target lifecycle
480    /// watches instead).
481    pub async fn remove_depth_alarms(
482        &self,
483        txids: impl IntoIterator<Item = [u8; 32]>,
484        depths: impl IntoIterator<Item = u32>,
485    ) -> Result<(), StreamError> {
486        let txids: Vec<Vec<u8>> = txids.into_iter().map(|t| t.to_vec()).collect();
487        let min_depths: Vec<u32> = depths.into_iter().filter(|d| *d >= 1).collect();
488        if txids.is_empty() || min_depths.is_empty() {
489            return Ok(());
490        }
491        self.send_msg(pb::subscribe_control::Msg::RemoveTransactions(pb::RemoveTransactions {
492            txids,
493            min_depths,
494        }))
495        .await
496    }
497
498    /// Expand a public output descriptor into a watch-set over the window
499    /// `[start, start + gap_limit)`. The server retains the descriptor →
500    /// scripthashes membership, so the client owns gap-limit advancement by
501    /// re-sending with an advanced `start` (the server reconciles the slid
502    /// window) and can drop the whole window with
503    /// [`remove_descriptor`](Self::remove_descriptor).
504    pub async fn add_descriptor(
505        &self,
506        descriptor: impl Into<String>,
507        gap_limit: u32,
508        start: u32,
509    ) -> Result<(), StreamError> {
510        self.send_msg(pb::subscribe_control::Msg::AddDescriptor(pb::AddDescriptor {
511            descriptor: descriptor.into(),
512            gap_limit,
513            start,
514        }))
515        .await
516    }
517
518    /// Remove a descriptor previously added with [`add_descriptor`](Self::add_descriptor),
519    /// releasing every scripthash its window contributed whose last owner this
520    /// drops. A scripthash the descriptor shares with a direct `add_scripts` or
521    /// another descriptor stays watched. `descriptor` must byte-match the string
522    /// it was added with; removing an unknown descriptor is a no-op.
523    pub async fn remove_descriptor(
524        &self,
525        descriptor: impl Into<String>,
526    ) -> Result<(), StreamError> {
527        self.send_msg(pb::subscribe_control::Msg::RemoveDescriptor(pb::RemoveDescriptor {
528            descriptor: descriptor.into(),
529        }))
530        .await
531    }
532
533    /// Add privacy-preserving script-prefix buckets: each is a `bits`-bit prefix
534    /// of `sha256(scriptPubKey)`, carried as the top `ceil(bits/8)` bytes. The
535    /// server delivers every tx in the `2^-bits` bucket and the client filters
536    /// locally, so the server learns only the bucket. Charged by coarseness
537    /// (smaller `bits` costs more). `bits` must be in `1..=32` (the server
538    /// buckets on the top 32 bits of the scripthash; wider is meaningless and
539    /// silently dropped) and `prefix` exactly `ceil(bits/8)` bytes; the server
540    /// additionally enforces its configured
541    /// `[streamprefixminbits, streamprefixmaxbits]` range.
542    pub async fn add_script_prefixes(
543        &self,
544        prefixes: impl IntoIterator<Item = (Vec<u8>, u32)>,
545    ) -> Result<(), StreamError> {
546        let prefixes: Vec<pb::ScriptPrefix> = prefixes
547            .into_iter()
548            .map(|(prefix, bits)| validate_prefix(prefix, bits))
549            .collect::<Result<_, _>>()?;
550        if prefixes.is_empty() {
551            return Ok(());
552        }
553        self.send_msg(pb::subscribe_control::Msg::AddScriptPrefixes(pb::AddScriptPrefixes {
554            prefixes,
555        }))
556        .await
557    }
558
559    /// Remove script-prefix buckets, releasing their quota.
560    pub async fn remove_script_prefixes(
561        &self,
562        prefixes: impl IntoIterator<Item = (Vec<u8>, u32)>,
563    ) -> Result<(), StreamError> {
564        let prefixes: Vec<pb::ScriptPrefix> = prefixes
565            .into_iter()
566            .map(|(prefix, bits)| validate_prefix(prefix, bits))
567            .collect::<Result<_, _>>()?;
568        if prefixes.is_empty() {
569            return Ok(());
570        }
571        self.send_msg(pb::subscribe_control::Msg::RemoveScriptPrefixes(
572            pb::RemoveScriptPrefixes { prefixes },
573        ))
574        .await
575    }
576
577    /// Register BIP 352 scan-key watch targets (Tier 2). The node runs the ECDH
578    /// match server-side and pushes an
579    /// [`Event::SilentPaymentMatched`](crate::Event::SilentPaymentMatched) for
580    /// every output paying one of them. Up to 16 targets per connection; each
581    /// charges one watch-quota unit. Re-registering an existing target (same
582    /// `b_scan·G`) refreshes its labels.
583    ///
584    /// The `scan_secret` is a watch credential disclosed to the node — see
585    /// [`SilentPaymentTarget`]. With the `bitcoin` feature (the default) each
586    /// target's `scan_secret` and `spend_pubkey` are validated as curve values
587    /// up front (see [`SilentPaymentTarget::validate`]), so a malformed target is
588    /// a deterministic [`InvalidArgument`](StreamError::InvalidArgument) here
589    /// rather than a silent server-side skip that returns `Ok(())` while
590    /// installing no watch. Without the feature this helper moves bytes only —
591    /// validate the field widths yourself or let the server reject a malformed
592    /// target.
593    pub async fn add_silent_payments(
594        &self,
595        targets: impl IntoIterator<Item = SilentPaymentTarget>,
596    ) -> Result<(), StreamError> {
597        let targets: Vec<SilentPaymentTarget> = targets.into_iter().collect();
598        #[cfg(feature = "bitcoin")]
599        for t in &targets {
600            t.validate()?;
601        }
602        let targets: Vec<pb::SilentPaymentTarget> = targets.iter().map(|t| t.to_proto()).collect();
603        if targets.is_empty() {
604            return Ok(());
605        }
606        self.send_msg(pb::subscribe_control::Msg::AddSilentPayments(
607            pb::AddSilentPayments { targets },
608        ))
609        .await
610    }
611
612    /// Remove scan-key watch targets by their identity `b_scan·G` (33-byte
613    /// compressed, from [`SilentPaymentTarget::scan_pubkey`]), releasing quota.
614    pub async fn remove_silent_payments(
615        &self,
616        scan_pubkeys: impl IntoIterator<Item = [u8; 33]>,
617    ) -> Result<(), StreamError> {
618        let scan_pubkeys: Vec<Vec<u8>> = scan_pubkeys.into_iter().map(|k| k.to_vec()).collect();
619        if scan_pubkeys.is_empty() {
620            return Ok(());
621        }
622        self.send_msg(pb::subscribe_control::Msg::RemoveSilentPayments(
623            pb::RemoveSilentPayments { scan_pubkeys },
624        ))
625        .await
626    }
627
628    /// Adjust the live firehose category bitfield (see [`Categories`]). Applies
629    /// immediately; does not affect the watch-set.
630    pub async fn set_categories(&self, categories: u32) -> Result<(), StreamError> {
631        self.send_msg(pb::subscribe_control::Msg::SetCategories(pb::SetCategories {
632            categories,
633        }))
634        .await
635    }
636
637    /// Set per-stream delivery options. With `include_raw_tx = true`, subsequent
638    /// [`Event::ScriptMatched`](crate::Event::ScriptMatched) on this stream carry
639    /// the full serialized matching transaction in
640    /// [`raw_tx`](crate::Event::ScriptMatched); `false` restores the default
641    /// (empty). Applies immediately; does not affect the watch-set. Bandwidth-
642    /// heavy — the value fields (`amount`) already cover the common case, so
643    /// enable this only when you need the whole transaction.
644    ///
645    /// When driving a [`ResilientWatch`](crate::ResilientWatch), prefer its
646    /// [`set_watch_options`](crate::ResilientWatch::set_watch_options) so the
647    /// opt-in is re-applied across reconnects.
648    pub async fn set_watch_options(&self, include_raw_tx: bool) -> Result<(), StreamError> {
649        self.send_msg(pb::subscribe_control::Msg::SetWatchOptions(
650            pb::SetWatchOptions { include_raw_tx },
651        ))
652        .await
653    }
654
655    /// Mid-stream re-anchor: replay confirmed history forward from `cursor`, then
656    /// resume live, without tearing down the watch-set. Rate-limited per
657    /// principal; only one re-anchor drains at a time.
658    ///
659    /// `Ok(())` means the request was written to the control stream — **not**
660    /// that the re-anchor ran. The outcome arrives **in-band on the event
661    /// stream** as exactly one of (#439):
662    ///
663    /// - [`Event::CursorAccepted`](crate::Event::CursorAccepted) — admitted;
664    ///   confirmed-history replay follows it (watch for `clamped` to learn the
665    ///   server truncated the lower end and you must full-resync below
666    ///   `earliest_replayed`).
667    /// - [`Event::CursorRejected`](crate::Event::CursorRejected) — declined
668    ///   (rate-limited, concurrent re-anchor, empty cursor, or no block source);
669    ///   the live stream is unchanged. Retry, back off, or escalate to a full
670    ///   resnapshot per the [`reason`](crate::CursorRejectReason).
671    ///
672    /// A consumer that needs at-least-once delivery should drive its catch-up
673    /// state machine off these events rather than treating `Ok(())` as success.
674    pub async fn set_cursor(&self, cursor: Cursor) -> Result<(), StreamError> {
675        self.send_msg(pb::subscribe_control::Msg::SetCursor(pb::SetCursor {
676            cursor: Some(cursor),
677        }))
678        .await
679    }
680
681    /// Request a bounded historical rescan of the current watch-set over the
682    /// inclusive height range `[from_height, to_height]`. The outcome arrives
683    /// in-band on the event stream as [`Event`](crate::Event)`::RescanAccepted` /
684    /// `RescanRejected`; on accept, confirmed watch-matches for the range follow
685    /// in height order, terminated by `RescanComplete`.
686    ///
687    /// A side query: it does not move the durable cursor and runs independently
688    /// of the live tail or any in-flight re-anchor. The server span-caps the
689    /// range and admits at most one rescan at a time (a second is rejected
690    /// `ConcurrentRescan`). `Ok(())` means the request was sent, not that it was
691    /// admitted — drive off the in-band result.
692    pub async fn rescan(&self, from_height: u32, to_height: u32) -> Result<(), StreamError> {
693        self.send_msg(pb::subscribe_control::Msg::RescanBlocks(pb::RescanBlocks {
694            from_height,
695            to_height,
696        }))
697        .await
698    }
699}
700
701/// The maximum meaningful prefix width. The server buckets on the top 32 bits of
702/// `sha256(scriptPubKey)` (its mask saturates at 32) and its default
703/// `streamprefixmaxbits` is 32, so a `bits > 32` registration can never be more
704/// selective and is silently dropped server-side (the control path has no ack).
705/// We reject it client-side rather than let a caller believe a watch was
706/// installed. A server may further *lower* the max via `streamprefixmaxbits`;
707/// that bound is not advertised over the wire, so an over-precise (but ≤ 32)
708/// prefix can still be silently dropped — there is no client-side signal for it.
709pub(crate) const MAX_PREFIX_BITS: u32 = 32;
710
711/// Validate a prefix/bits pair before it reaches the wire.
712pub(crate) fn validate_prefix(
713    prefix: Vec<u8>,
714    bits: u32,
715) -> Result<pb::ScriptPrefix, StreamError> {
716    if !(1..=MAX_PREFIX_BITS).contains(&bits) {
717        return Err(StreamError::InvalidArgument(format!(
718            "prefix bits {bits} out of range 1..={MAX_PREFIX_BITS}"
719        )));
720    }
721    let want = bits.div_ceil(8) as usize;
722    if prefix.len() != want {
723        return Err(StreamError::InvalidArgument(format!(
724            "prefix for {bits} bits must be {want} bytes, got {}",
725            prefix.len()
726        )));
727    }
728    Ok(pb::ScriptPrefix { prefix, bits })
729}
730
731/// Whether `endpoint` carries the `https://` scheme.
732///
733/// tonic selects TLS from the URI scheme alone, so the scheme — not the
734/// builder's `tls` field — is what decides whether bytes are encrypted. A
735/// scheme-less endpoint (`host:port`, the common form) is plaintext.
736///
737/// Only the TLS-capable build consults this: without the `tls` feature the
738/// scheme carries no security meaning at all, which is the whole point of
739/// [`endpoint_is_encrypted`]. Kept compiled in both so the unit test that
740/// pins its behaviour runs in both.
741#[cfg_attr(not(feature = "tls"), allow(dead_code))]
742fn endpoint_is_https(endpoint: &str) -> bool {
743    endpoint
744        .split_once("://")
745        .is_some_and(|(scheme, _)| scheme.eq_ignore_ascii_case("https"))
746}
747
748/// Whether connecting to `endpoint` will actually encrypt the bytes.
749///
750/// This is *not* the same question as [`endpoint_is_https`], and conflating the
751/// two is how a credential leaks. tonic only honours the `https://` scheme when
752/// it is itself compiled with its `tls` feature: the scheme test and the whole
753/// TLS branch of its connector are `#[cfg(feature = "tls")]`, and it disables
754/// `enforce_http`. In a build without that feature an `https://` URI therefore
755/// opens an ordinary TCP connection and speaks cleartext h2c — no error, no
756/// downgrade warning. So the scheme is transport-truthful only in a TLS-capable
757/// build; in a build without one, nothing is, and this returns `false` for every
758/// endpoint.
759#[cfg(feature = "tls")]
760fn endpoint_is_encrypted(endpoint: &str) -> bool {
761    endpoint_is_https(endpoint)
762}
763
764#[cfg(not(feature = "tls"))]
765fn endpoint_is_encrypted(_endpoint: &str) -> bool {
766    false
767}
768
769/// Strip `user:password@` userinfo out of an endpoint before it is put into an
770/// error string. Refusals are normally logged, and a password in a URL is a
771/// credential too — an error whose whole purpose is to stop a credential
772/// reaching the wire must not spill a different one into the log.
773fn redact_endpoint_userinfo(endpoint: &str) -> String {
774    let Some((scheme, rest)) = endpoint.split_once("://") else {
775        // No scheme, so the whole string is an authority (`host:port`).
776        return match endpoint.rsplit_once('@') {
777            Some((_, host)) => format!("***@{host}"),
778            None => endpoint.to_string(),
779        };
780    };
781    // Userinfo lives in the authority only: everything before the first `/`.
782    // Splitting there keeps a later `@` in a path from being mistaken for it.
783    let (authority, path) = match rest.find('/') {
784        Some(i) => rest.split_at(i),
785        None => (rest, ""),
786    };
787    match authority.rsplit_once('@') {
788        Some((_, host)) => format!("{scheme}://***@{host}{path}"),
789        None => endpoint.to_string(),
790    }
791}
792
793/// The endpoint as it should appear in an [`StreamError::InsecureCredential`],
794/// naming the reason when the reason is not visible in the endpoint itself.
795#[cfg(feature = "tls")]
796fn insecure_credential_reason(endpoint: &str) -> String {
797    redact_endpoint_userinfo(endpoint)
798}
799
800#[cfg(not(feature = "tls"))]
801fn insecure_credential_reason(endpoint: &str) -> String {
802    format!(
803        "{} — this build of satd-events-client has the `tls` feature disabled, \
804         so no endpoint is encrypted here, https:// included",
805        redact_endpoint_userinfo(endpoint)
806    )
807}
808
809/// TLS settings assembled by the `StreamClientBuilder::tls*` methods and applied
810/// in [`connect`](StreamClientBuilder::connect). `Some(..)` on the builder means
811/// TLS is enabled; the fields refine trust and identity.
812#[cfg(feature = "tls")]
813#[derive(Clone, Default)]
814struct TlsSettings {
815    /// PEM CA (or self-signed leaf) to verify the server cert. `None` → trust the
816    /// bundled Mozilla webpki roots (public-CA servers).
817    ca_pem: Option<Vec<u8>>,
818    /// mTLS client identity: `(cert_pem, key_pem)`.
819    identity: Option<(Vec<u8>, Vec<u8>)>,
820    /// SNI / certificate domain-name override (e.g. when connecting by IP).
821    domain: Option<String>,
822}
823
824/// Builder for a [`StreamClient`].
825///
826/// `Debug` is hand-written to redact the bearer token (and never leak TLS key
827/// material) — never derive it here.
828#[derive(Clone)]
829pub struct StreamClientBuilder {
830    endpoint: String,
831    token: Option<String>,
832    /// Set only by [`StreamClientBuilder::insecure_bearer_token`]: the caller
833    /// has explicitly accepted sending the token over an unencrypted endpoint.
834    allow_insecure_token: bool,
835    keepalive: Option<(Duration, Duration)>,
836    allow_old_node: bool,
837    #[cfg(feature = "tls")]
838    tls: Option<TlsSettings>,
839}
840
841impl std::fmt::Debug for StreamClientBuilder {
842    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
843        let mut d = f.debug_struct("StreamClientBuilder");
844        d.field("endpoint", &self.endpoint)
845            .field("token", &self.token.as_ref().map(|_| "<redacted>"))
846            // Not the token, but worth seeing in a bug report: it says whether
847            // the plaintext waiver is in force.
848            .field("allow_insecure_token", &self.allow_insecure_token)
849            .field("keepalive", &self.keepalive)
850            .field("allow_old_node", &self.allow_old_node);
851        #[cfg(feature = "tls")]
852        d.field("tls", &self.tls.is_some());
853        d.finish()
854    }
855}
856
857impl StreamClientBuilder {
858    /// Attach a bearer token, sent as `authorization: Bearer <token>` metadata
859    /// on every RPC.
860    ///
861    /// The token is only honored when the server enforces auth
862    /// (`-eventsgrpcauth`); a no-auth (loopback-trust) server ignores it.
863    ///
864    /// **The endpoint must be encrypted.** [`connect`](Self::connect) returns
865    /// [`StreamError::InsecureCredential`] for a token combined with a
866    /// non-`https://` endpoint rather than putting it on the wire in the clear.
867    /// Pair this with [`tls`](Self::tls) or [`tls_ca_pem`](Self::tls_ca_pem),
868    /// or use [`insecure_bearer_token`](Self::insecure_bearer_token) to accept
869    /// the risk explicitly.
870    pub fn bearer_token(mut self, token: impl Into<String>) -> Self {
871        self.token = Some(token.into());
872        self.allow_insecure_token = false;
873        self
874    }
875
876    /// Like [`bearer_token`](Self::bearer_token), but permits sending the token
877    /// over an unencrypted endpoint.
878    ///
879    /// Only for loopback and test harnesses. Over anything else the token is
880    /// readable by every host on the path, and so is everything the stream
881    /// carries — including the BIP 352 scan keys a Tier 2 watch registers,
882    /// which disclose which outputs belong to the receiver.
883    ///
884    /// This is a separate method rather than a flag so that the unsafe choice
885    /// has to be named at the call site, and so that a later switch back to
886    /// [`bearer_token`](Self::bearer_token) cannot silently leave the waiver
887    /// behind.
888    pub fn insecure_bearer_token(mut self, token: impl Into<String>) -> Self {
889        self.token = Some(token.into());
890        self.allow_insecure_token = true;
891        self
892    }
893
894    /// Configure client-side HTTP/2 keepalive. Defaults match the server
895    /// (30s interval / 20s timeout) when [`keepalive_default`](Self::keepalive_default)
896    /// is used; this overrides with explicit seconds.
897    pub fn keepalive(mut self, interval_secs: u64, timeout_secs: u64) -> Self {
898        self.keepalive =
899            Some((Duration::from_secs(interval_secs), Duration::from_secs(timeout_secs)));
900        self
901    }
902
903    /// Use the server-matching keepalive defaults (30s interval / 20s timeout).
904    pub fn keepalive_default(self) -> Self {
905        self.keepalive(30, 20)
906    }
907
908    /// Accept a node two or more minor versions older than this SDK.
909    ///
910    /// By default [`subscribe`](StreamClient::subscribe) and
911    /// [`watch`](StreamClient::watch) refuse such a node with
912    /// [`StreamError::NodeTooOld`]. With this set they log the same warning as
913    /// for a node one minor version behind and carry on. Meant for a rolling
914    /// upgrade where the SDK side went first. Features added after the node's
915    /// release are still unavailable, and request fields it does not recognise
916    /// are still ignored.
917    ///
918    /// This does not bypass [`StreamError::SchemaMismatch`].
919    pub fn allow_old_node(mut self) -> Self {
920        self.allow_old_node = true;
921        self
922    }
923
924    /// Enable TLS for the connection, trusting the bundled Mozilla root CAs
925    /// (for a server with a publicly-trusted certificate). For a private or
926    /// self-signed CA — the usual case for a satd node — use
927    /// [`tls_ca_pem`](Self::tls_ca_pem) instead. Requires the `tls` feature
928    /// (on by default).
929    #[cfg(feature = "tls")]
930    #[cfg_attr(docsrs, doc(cfg(feature = "tls")))]
931    pub fn tls(mut self) -> Self {
932        self.tls.get_or_insert_with(TlsSettings::default);
933        self
934    }
935
936    /// Enable TLS and verify the server certificate against the PEM CA (or
937    /// self-signed leaf) in `pem` — the usual choice for a satd node serving its
938    /// own certificate. Replaces the bundled roots for this connection.
939    #[cfg(feature = "tls")]
940    #[cfg_attr(docsrs, doc(cfg(feature = "tls")))]
941    pub fn tls_ca_pem(mut self, pem: impl Into<Vec<u8>>) -> Self {
942        self.tls.get_or_insert_with(TlsSettings::default).ca_pem = Some(pem.into());
943        self
944    }
945
946    /// Enable mutual TLS: present this PEM client certificate + private key to a
947    /// server configured for mTLS (`-eventsgrpcmtls`). Combine with
948    /// [`tls_ca_pem`](Self::tls_ca_pem) to pin the server's CA — without it the
949    /// *server* certificate is verified against the bundled public Mozilla roots,
950    /// so a self-signed satd node (the usual mTLS case) will fail the handshake
951    /// unless you also call `tls_ca_pem`.
952    #[cfg(feature = "tls")]
953    #[cfg_attr(docsrs, doc(cfg(feature = "tls")))]
954    pub fn tls_client_identity(
955        mut self,
956        cert_pem: impl Into<Vec<u8>>,
957        key_pem: impl Into<Vec<u8>>,
958    ) -> Self {
959        self.tls.get_or_insert_with(TlsSettings::default).identity =
960            Some((cert_pem.into(), key_pem.into()));
961        self
962    }
963
964    /// Override the certificate domain name verified during the TLS handshake
965    /// (SNI). Use when the endpoint host differs from the certificate subject —
966    /// e.g. connecting by IP, or through a proxy. Enables TLS if not already.
967    #[cfg(feature = "tls")]
968    #[cfg_attr(docsrs, doc(cfg(feature = "tls")))]
969    pub fn tls_domain(mut self, domain: impl Into<String>) -> Self {
970        self.tls.get_or_insert_with(TlsSettings::default).domain = Some(domain.into());
971        self
972    }
973
974    /// Connect the transport and return a ready [`StreamClient`].
975    pub async fn connect(self) -> Result<StreamClient, StreamError> {
976        // Captured before the match below moves `self.token`; the credential
977        // check that reads it is ordered later on purpose (see there).
978        let has_token = self.token.is_some();
979        let auth = match self.token {
980            Some(t) => Some(
981                format!("Bearer {t}")
982                    .parse::<MetadataValue<Ascii>>()
983                    .map_err(|_| StreamError::InvalidToken)?,
984            ),
985            None => None,
986        };
987
988        // TLS is selected by tonic purely from the URI scheme, *not* from
989        // whether a `ClientTlsConfig` is attached: an `http://` (or scheme-less)
990        // endpoint silently connects in cleartext even with TLS configured,
991        // leaking the bearer token and the whole event stream while the caller
992        // believes the link is encrypted. Fail closed rather than downgrade.
993        #[cfg(feature = "tls")]
994        if self.tls.is_some() && !endpoint_is_https(&self.endpoint) {
995            return Err(StreamError::InvalidEndpoint(
996                "TLS was requested (tls / tls_ca_pem / tls_client_identity / tls_domain) but \
997                 the endpoint scheme is not https:// — refusing to connect in cleartext; use \
998                 an https:// endpoint"
999                    .to_string(),
1000            ));
1001        }
1002
1003        // A bearer token on an unencrypted endpoint crosses the network in the
1004        // clear, and so does everything the stream carries — including the BIP
1005        // 352 scan keys a Tier 2 watch registers. Nothing downstream catches
1006        // this: the token is attached as ordinary metadata, so tonic has no
1007        // credential to apply a transport-security requirement to, and a
1008        // remote-bound node with `eventsgrpcauth=1` and no TLS is a supported
1009        // server configuration. Both ends behave as configured while the
1010        // credential is on the wire (#521).
1011        //
1012        // The predicate is `endpoint_is_encrypted`, not the builder's `tls`
1013        // field and not the scheme on its own: tonic selects TLS from the URI
1014        // scheme, but only in a build that has its `tls` feature. Without that
1015        // feature an `https://` endpoint connects in cleartext rather than
1016        // failing, so this crate's own `tls` feature is part of the question.
1017        // See `endpoint_is_encrypted`.
1018        //
1019        // Ordered after the TLS-scheme check above so the more specific
1020        // diagnosis wins when a caller asked for TLS *and* gave an http:// URL,
1021        // matching the Go SDK, where the scheme conflict is likewise reported
1022        // first.
1023        if has_token && !self.allow_insecure_token && !endpoint_is_encrypted(&self.endpoint) {
1024            return Err(StreamError::InsecureCredential(insecure_credential_reason(
1025                &self.endpoint,
1026            )));
1027        }
1028
1029        let mut endpoint = Endpoint::from_shared(self.endpoint)
1030            .map_err(|e| StreamError::InvalidEndpoint(e.to_string()))?;
1031        if let Some((interval, timeout)) = self.keepalive {
1032            endpoint = endpoint
1033                .http2_keep_alive_interval(interval)
1034                .keep_alive_timeout(timeout)
1035                .keep_alive_while_idle(true);
1036        }
1037        #[cfg(feature = "tls")]
1038        if let Some(tls) = self.tls {
1039            ensure_crypto_provider();
1040            let mut cfg = tonic::transport::ClientTlsConfig::new();
1041            // A pinned CA verifies exactly that authority (the satd self-signed
1042            // case); otherwise fall back to the bundled public roots.
1043            cfg = match tls.ca_pem {
1044                Some(ca) => cfg.ca_certificate(tonic::transport::Certificate::from_pem(ca)),
1045                None => cfg.with_webpki_roots(),
1046            };
1047            if let Some((cert, key)) = tls.identity {
1048                cfg = cfg.identity(tonic::transport::Identity::from_pem(cert, key));
1049            }
1050            if let Some(domain) = tls.domain {
1051                cfg = cfg.domain_name(domain);
1052            }
1053            endpoint = endpoint.tls_config(cfg)?;
1054        }
1055
1056        let channel = endpoint.connect().await?;
1057        Ok(StreamClient {
1058            inner: NodeEventStreamClient::new(channel),
1059            auth,
1060            allow_old_node: self.allow_old_node,
1061            compat: Arc::default(),
1062        })
1063    }
1064}
1065
1066/// Install `ring` as the process-default rustls `CryptoProvider`, once. In this
1067/// workspace `tonic/tls` resolves to ring only, so rustls already auto-selects
1068/// it; this is belt-and-suspenders for a build that *also* compiled `aws-lc-rs`
1069/// (a downstream consumer that doesn't pin the provider), where the default
1070/// would otherwise be ambiguous and panic. Best-effort — if the application
1071/// already installed a provider, that one stays.
1072#[cfg(feature = "tls")]
1073fn ensure_crypto_provider() {
1074    use std::sync::Once;
1075    static ONCE: Once = Once::new();
1076    ONCE.call_once(|| {
1077        let _ = tokio_rustls::rustls::crypto::ring::default_provider().install_default();
1078    });
1079}
1080
1081/// An async client for the satd `satd.events.v1` streaming API.
1082///
1083/// `Debug` is hand-written to redact the auth credential — never derive it here.
1084#[derive(Clone)]
1085pub struct StreamClient {
1086    inner: NodeEventStreamClient<Channel>,
1087    auth: Option<MetadataValue<Ascii>>,
1088    allow_old_node: bool,
1089    /// Shared by clones, so the resilient layers (which hold a clone) update
1090    /// [`node_version`](Self::node_version) and warn at most once per node
1091    /// version for the whole client.
1092    compat: Arc<Mutex<CompatState>>,
1093}
1094
1095/// What the client last learned from a node's response headers.
1096#[derive(Debug, Default)]
1097struct CompatState {
1098    /// The raw `satd-version` header from the most recent stream.
1099    node_version: Option<String>,
1100    /// The node version string the old-node warning was last logged for
1101    /// (`Some(None)` for a node that sent no header).
1102    warned_for: Option<Option<String>>,
1103}
1104
1105impl std::fmt::Debug for StreamClient {
1106    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1107        f.debug_struct("StreamClient")
1108            .field("auth", &self.auth.as_ref().map(|_| "<redacted>"))
1109            .finish_non_exhaustive()
1110    }
1111}
1112
1113impl StreamClient {
1114    /// Start building a client for `endpoint` (e.g. `http://node:50051`, or
1115    /// `https://node:50051` with [`tls`](StreamClientBuilder::tls)).
1116    pub fn builder(endpoint: impl Into<String>) -> StreamClientBuilder {
1117        StreamClientBuilder {
1118            endpoint: endpoint.into(),
1119            token: None,
1120            allow_insecure_token: false,
1121            keepalive: None,
1122            allow_old_node: false,
1123            #[cfg(feature = "tls")]
1124            tls: None,
1125        }
1126    }
1127
1128    /// The version the node advertised on the most recently opened stream, e.g.
1129    /// `"0.6.0"`.
1130    ///
1131    /// `None` until a stream has been opened, and when the node predates the
1132    /// `satd-version` header (0.5.x and older).
1133    pub fn node_version(&self) -> Option<String> {
1134        self.compat.lock().unwrap_or_else(|e| e.into_inner()).node_version.clone()
1135    }
1136
1137    /// Apply the SDK ↔ node compatibility rule to a stream-opening response's
1138    /// headers (`STABILITY_POLICY.md` → "Streaming API & SDK compatibility").
1139    fn check_compat(&self, md: &MetadataMap) -> Result<(), StreamError> {
1140        let (raw, node) = compat::node_version_from(md);
1141        let schema = compat::schema_from(md);
1142        let sdk = compat::parse(compat::SDK_VERSION).expect("the crate version parses");
1143
1144        // Decide under the lock, log after releasing it: a `tracing` subscriber
1145        // is user code, and one that calls `node_version()` (or blocks) must not
1146        // deadlock or stall every other stream open on this client.
1147        let warn_for = {
1148            let mut state = self.compat.lock().unwrap_or_else(|e| e.into_inner());
1149            state.node_version = raw.clone();
1150
1151            if schema != satd_events_proto::SCHEMA_VERSION {
1152                return Err(StreamError::SchemaMismatch {
1153                    node: schema,
1154                    sdk: satd_events_proto::SCHEMA_VERSION,
1155                });
1156            }
1157
1158            let warn = match compat::classify(sdk, node) {
1159                Compat::Ok => false,
1160                Compat::OneBehind => true,
1161                Compat::TooOld if self.allow_old_node => true,
1162                Compat::TooOld => {
1163                    return Err(StreamError::NodeTooOld {
1164                        node: compat::display_node_version(raw.as_deref()),
1165                        sdk: compat::SDK_VERSION,
1166                    });
1167                }
1168            };
1169            // Once per observed version: a reconnect loop against the same node
1170            // stays quiet, a node upgraded (or downgraded) in between warns again.
1171            if warn && state.warned_for.as_ref() != Some(&raw) {
1172                state.warned_for = Some(raw.clone());
1173                Some(compat::display_node_version(raw.as_deref()))
1174            } else {
1175                None
1176            }
1177        };
1178
1179        if let Some(shown) = warn_for {
1180            tracing::warn!(
1181                target: "satd_events_client::compat",
1182                node_version = %shown,
1183                sdk_version = compat::SDK_VERSION,
1184                "satd node {} is older than this SDK ({}); features added after the node's \
1185                 release are unavailable and request fields it does not recognise are ignored. \
1186                 Upgrade the node — connections are refused once it is two minor versions behind.",
1187                shown,
1188                compat::SDK_VERSION,
1189            );
1190        }
1191        Ok(())
1192    }
1193
1194    /// Wrap a message in a request carrying the configured auth metadata.
1195    fn authed<T>(&self, msg: T) -> tonic::Request<T> {
1196        let mut req = tonic::Request::new(msg);
1197        if let Some(value) = &self.auth {
1198            req.metadata_mut().insert("authorization", value.clone());
1199        }
1200        req
1201    }
1202
1203    /// Open a server-streaming firehose. Requires the `stream:subscribe`
1204    /// capability when the server enforces auth.
1205    pub async fn subscribe(
1206        &mut self,
1207        opts: SubscribeOptions,
1208    ) -> Result<EventStream, StreamError> {
1209        let req = self.authed(opts.into_request());
1210        let resp = self.inner.subscribe(req).await?;
1211        self.check_compat(resp.metadata())?;
1212        Ok(EventStream::new(resp.into_inner()))
1213    }
1214
1215    /// Open a reconnect-and-replay-aware firehose. Unlike [`subscribe`](Self::subscribe),
1216    /// the returned [`ResilientSubscription`](crate::ResilientSubscription)
1217    /// reconnects with backoff, persists and replays the resume cursor, recovers
1218    /// from `Lagged` per the [`LagPolicy`](crate::LagPolicy), and surfaces
1219    /// replay-truncation gaps. Connects lazily on the first
1220    /// [`next`](crate::ResilientSubscription::next).
1221    pub fn resilient_subscribe(
1222        &self,
1223        opts: SubscribeOptions,
1224        config: crate::ResilientConfig,
1225    ) -> crate::ResilientSubscription {
1226        crate::ResilientSubscription::new(self.clone(), opts, config)
1227    }
1228
1229    /// Open a reconnect-and-replay-aware bidirectional watch. Unlike
1230    /// [`watch`](Self::watch), the returned [`ResilientWatch`](crate::ResilientWatch)
1231    /// mirrors the watch-set and re-registers it on reconnect, re-anchors off the
1232    /// deterministic `set_cursor` results, and persists the resume cursor.
1233    /// Connects lazily on the first [`next`](crate::ResilientWatch::next).
1234    pub fn resilient_watch(&self, config: crate::ResilientWatchConfig) -> crate::ResilientWatch {
1235        crate::ResilientWatch::new(self.clone(), config)
1236    }
1237
1238    /// Open a bidirectional watch stream, returning a [`WatchHandle`] to send
1239    /// control messages and an [`EventStream`] of matches + firehose.
1240    pub async fn watch(&mut self) -> Result<(WatchHandle, EventStream), StreamError> {
1241        let (tx, rx) = mpsc::channel::<pb::SubscribeControl>(CONTROL_BUFFER);
1242        let req = self.authed(ReceiverStream::new(rx));
1243        let resp = self.inner.watch(req).await?;
1244        self.check_compat(resp.metadata())?;
1245        Ok((WatchHandle { tx }, EventStream::new(resp.into_inner())))
1246    }
1247}
1248
1249#[cfg(test)]
1250impl WatchHandle {
1251    /// A handle wired to an inspectable receiver — for unit tests in sibling
1252    /// modules (e.g. `ResilientWatch::reload`) that assert the control messages a
1253    /// layer sends. No server needed.
1254    pub(crate) fn for_test() -> (Self, mpsc::Receiver<pb::SubscribeControl>) {
1255        let (tx, rx) = mpsc::channel(64);
1256        (WatchHandle { tx }, rx)
1257    }
1258}
1259
1260#[cfg(test)]
1261impl StreamClient {
1262    /// Build a client over a lazily-connected channel to a dummy endpoint, for
1263    /// unit tests of layers (e.g. [`ResilientSubscription`](crate::ResilientSubscription))
1264    /// that need a `StreamClient` value but drive their logic via injected events
1265    /// and never actually poll the transport. `connect_lazy` performs no I/O, so
1266    /// no server is required; it must be called from within a Tokio runtime.
1267    pub(crate) fn for_test() -> Self {
1268        let channel = Endpoint::from_static("http://127.0.0.1:1").connect_lazy();
1269        StreamClient {
1270            inner: NodeEventStreamClient::new(channel),
1271            auth: None,
1272            allow_old_node: false,
1273            compat: Arc::default(),
1274        }
1275    }
1276}
1277
1278#[cfg(test)]
1279mod tests {
1280    use super::*;
1281    use pb::subscribe_control::Msg;
1282
1283    /// A handle wired to a receiver we can inspect — no server needed.
1284    fn handle() -> (WatchHandle, mpsc::Receiver<pb::SubscribeControl>) {
1285        let (tx, rx) = mpsc::channel(16);
1286        (WatchHandle { tx }, rx)
1287    }
1288
1289    fn next(rx: &mut mpsc::Receiver<pb::SubscribeControl>) -> Msg {
1290        rx.try_recv().expect("a control message was sent").msg.expect("msg set")
1291    }
1292
1293    #[test]
1294    fn only_an_https_scheme_counts_as_encrypted() {
1295        assert!(endpoint_is_https("https://node.example:50051"));
1296        assert!(endpoint_is_https("HTTPS://node.example:50051"));
1297        assert!(!endpoint_is_https("http://node.example:50051"));
1298        // The common scheme-less form. tonic treats it as plaintext, so this
1299        // must not be mistaken for encrypted.
1300        assert!(!endpoint_is_https("node.example:50051"));
1301        assert!(!endpoint_is_https("127.0.0.1:50051"));
1302    }
1303
1304    /// The scheme decides only in a build that can actually do TLS. tonic gates
1305    /// its whole https branch on its own `tls` feature and turns `enforce_http`
1306    /// off, so without that feature an `https://` URI connects in cleartext h2c
1307    /// instead of failing — which would have let a token past the gate below in
1308    /// exactly the configuration the gate advertises as safe.
1309    #[test]
1310    fn encryption_requires_the_tls_feature_and_not_just_the_scheme() {
1311        assert_eq!(endpoint_is_encrypted("https://node.example:50051"), cfg!(feature = "tls"));
1312        assert!(!endpoint_is_encrypted("http://node.example:50051"));
1313        assert!(!endpoint_is_encrypted("node.example:50051"));
1314    }
1315
1316    /// The refusal is normally logged, so it must not spill a password that was
1317    /// embedded in the endpoint. A later `@` in the path is not userinfo.
1318    #[test]
1319    fn the_refusal_does_not_echo_url_userinfo() {
1320        assert_eq!(
1321            redact_endpoint_userinfo("http://user:hunter2@node.example:50051"),
1322            "http://***@node.example:50051"
1323        );
1324        assert_eq!(
1325            redact_endpoint_userinfo("http://user:hunter2@node.example:50051/a@b"),
1326            "http://***@node.example:50051/a@b"
1327        );
1328        assert_eq!(redact_endpoint_userinfo("user:hunter2@node.example:50051"), "***@node.example:50051");
1329        // Untouched when there is nothing to redact.
1330        assert_eq!(
1331            redact_endpoint_userinfo("https://node.example:50051/path"),
1332            "https://node.example:50051/path"
1333        );
1334        assert_eq!(redact_endpoint_userinfo("node.example:50051"), "node.example:50051");
1335    }
1336
1337    #[tokio::test]
1338    async fn a_password_in_the_endpoint_is_not_quoted_back() {
1339        let err = StreamClient::builder("http://user:hunter2@node.example:50051")
1340            .bearer_token("SECRET")
1341            .connect()
1342            .await
1343            .expect_err("a bearer token over plaintext must be refused");
1344        assert!(!err.to_string().contains("hunter2"), "leaked the URL password: {err}");
1345        assert!(!err.to_string().contains("SECRET"), "leaked the token: {err}");
1346    }
1347
1348    /// The #521 guard. The token is attached as ordinary metadata, so there is
1349    /// no `PerRPCCredentials` for tonic to apply a transport-security
1350    /// requirement to, and a remote-bound node with `eventsgrpcauth=1` and no
1351    /// TLS is a supported server configuration — nothing else stops the
1352    /// credential reaching the wire in the clear.
1353    #[tokio::test]
1354    async fn a_bearer_token_over_plaintext_is_refused() {
1355        for endpoint in ["http://127.0.0.1:1", "http://node.example:50051", "node.example:50051"] {
1356            let err = StreamClient::builder(endpoint)
1357                .bearer_token("SECRET")
1358                .connect()
1359                .await
1360                .expect_err("a bearer token over plaintext must be refused");
1361            assert!(
1362                matches!(err, StreamError::InsecureCredential(_)),
1363                "{endpoint}: got {err:?}"
1364            );
1365            assert!(
1366                !err.to_string().contains("SECRET"),
1367                "{endpoint}: the error must not quote the token: {err}"
1368            );
1369        }
1370    }
1371
1372    /// Reaching the transport is what proves the gate did not fire. Asserting
1373    /// merely "not `InsecureCredential`" would also hold if `connect()` started
1374    /// failing for some unrelated wrong reason, so name the errors a connect to
1375    /// a dead port is allowed to produce.
1376    fn assert_reached_the_transport(err: &StreamError, ctx: &str) {
1377        assert!(
1378            matches!(err, StreamError::Connect(_) | StreamError::Transport(_)),
1379            "{ctx}: expected a transport/connect error, got {err:?}"
1380        );
1381    }
1382
1383    /// A plaintext connection with no token is the default local setup and is
1384    /// unaffected: it gets as far as the transport (and fails there, since
1385    /// nothing is listening on port 1).
1386    #[tokio::test]
1387    async fn plaintext_without_a_token_is_unaffected() {
1388        let err = StreamClient::builder("http://127.0.0.1:1")
1389            .connect()
1390            .await
1391            .expect_err("nothing is listening on port 1");
1392        assert_reached_the_transport(&err, "no token");
1393    }
1394
1395    /// The named waiver gets past the gate — it too fails at the transport,
1396    /// which is the proof that the credential check no longer short-circuits.
1397    #[tokio::test]
1398    async fn the_named_waiver_permits_a_token_over_plaintext() {
1399        let err = StreamClient::builder("http://127.0.0.1:1")
1400            .insecure_bearer_token("SECRET")
1401            .connect()
1402            .await
1403            .expect_err("nothing is listening on port 1");
1404        assert_reached_the_transport(&err, "waiver");
1405    }
1406
1407    /// A later `bearer_token` must clear an earlier waiver rather than inherit
1408    /// it — the reason the waiver is a token-carrying method and not a flag.
1409    #[tokio::test]
1410    async fn the_waiver_does_not_leak_into_a_subsequent_bearer_token() {
1411        let err = StreamClient::builder("http://127.0.0.1:1")
1412            .insecure_bearer_token("SECRET")
1413            .bearer_token("SECRET")
1414            .connect()
1415            .await
1416            .expect_err("the waiver must not survive a later bearer_token");
1417        assert!(
1418            matches!(err, StreamError::InsecureCredential(_)),
1419            "got {err:?}"
1420        );
1421    }
1422
1423    /// An `https://` endpoint is accepted with a token whether or not a `tls*`
1424    /// method was called — tonic selects TLS from the scheme, so the scheme is
1425    /// what decides.
1426    #[cfg(feature = "tls")]
1427    #[tokio::test]
1428    async fn https_permits_a_token() {
1429        let err = StreamClient::builder("https://127.0.0.1:1")
1430            .tls()
1431            .bearer_token("SECRET")
1432            .connect()
1433            .await
1434            .expect_err("nothing is listening on port 1");
1435        assert!(
1436            !matches!(err, StreamError::InsecureCredential(_)),
1437            "got {err:?}"
1438        );
1439    }
1440
1441    #[test]
1442    fn subscribe_options_map_tweak_knobs_only_when_set() {
1443        // A default subscription sends no tweak knobs (byte-identical to one
1444        // built before they existed); each flag maps to Some(true) when set.
1445        let default = SubscribeOptions { categories: Categories::TWEAKS, ..Default::default() }
1446            .into_request();
1447        assert_eq!(default.mempool_tweaks, None);
1448        assert_eq!(default.tweak_outputs, None);
1449        assert_eq!(default.tweak_unspent_only, None);
1450
1451        let full = SubscribeOptions {
1452            categories: Categories::TWEAKS,
1453            mempool_tweaks: true,
1454            tweak_outputs: true,
1455            tweaks_only: true,
1456            tweak_unspent_only: true,
1457            ..Default::default()
1458        }
1459        .into_request();
1460        assert_eq!(full.mempool_tweaks, Some(true));
1461        assert_eq!(full.tweak_outputs, Some(true));
1462        assert_eq!(full.tweaks_only, Some(true));
1463        assert_eq!(full.tweak_unspent_only, Some(true));
1464    }
1465
1466    #[tokio::test]
1467    async fn add_scripts_mixed_floors_emits_parallel_min_values() {
1468        let (h, mut rx) = handle();
1469        h.add_scripts([([1u8; 32], Some(5_000)), ([2u8; 32], None)]).await.unwrap();
1470        match next(&mut rx) {
1471            Msg::AddScripts(a) => {
1472                assert_eq!(a.scripthashes.len(), 2);
1473                // Unfloored entry becomes a 0 floor to keep the vec parallel.
1474                assert_eq!(a.min_values, vec![5_000, 0]);
1475            }
1476            other => panic!("expected AddScripts, got {other:?}"),
1477        }
1478    }
1479
1480    #[tokio::test]
1481    async fn add_scripts_no_floors_emits_empty_min_values() {
1482        let (h, mut rx) = handle();
1483        h.add_scripts([([1u8; 32], None), ([2u8; 32], None)]).await.unwrap();
1484        match next(&mut rx) {
1485            Msg::AddScripts(a) => assert!(a.min_values.is_empty()),
1486            other => panic!("expected AddScripts, got {other:?}"),
1487        }
1488    }
1489
1490    #[tokio::test]
1491    async fn empty_inputs_send_nothing() {
1492        let (h, mut rx) = handle();
1493        h.add_scripts([]).await.unwrap();
1494        h.add_outpoints([]).await.unwrap();
1495        h.add_depth_alarms([[1u8; 32]], []).await.unwrap(); // empty depths
1496        h.add_depth_alarms([], [3]).await.unwrap(); // empty txids
1497        // depths < 1 are filtered out; an all-invalid call must NOT send an
1498        // empty min_depths (the server would treat that as a lifecycle add).
1499        h.add_depth_alarms([[1u8; 32]], [0]).await.unwrap();
1500        assert!(rx.try_recv().is_err(), "no message should have been sent");
1501    }
1502
1503    #[tokio::test]
1504    async fn lifecycle_vs_depth_alarms_dispatch_on_min_depths() {
1505        let (h, mut rx) = handle();
1506        h.add_tx_lifecycle([[7u8; 32]], AutoClose::AtDepth(6)).await.unwrap();
1507        match next(&mut rx) {
1508            Msg::AddTransactions(a) => {
1509                assert!(a.min_depths.is_empty(), "lifecycle => empty min_depths");
1510                assert_eq!(a.auto_close_depth, 6);
1511            }
1512            other => panic!("expected AddTransactions, got {other:?}"),
1513        }
1514
1515        h.add_depth_alarms([[7u8; 32], [8u8; 32]], [3, 6]).await.unwrap();
1516        match next(&mut rx) {
1517            Msg::AddTransactions(a) => {
1518                assert_eq!(a.txids.len(), 2);
1519                assert_eq!(a.min_depths, vec![3, 6]); // cross product, server-side
1520                assert_eq!(a.auto_close_depth, 0);
1521            }
1522            other => panic!("expected AddTransactions, got {other:?}"),
1523        }
1524    }
1525
1526    #[tokio::test]
1527    async fn prefix_validation_rejects_bad_length_and_bits() {
1528        let (h, mut rx) = handle();
1529        // 16 bits needs 2 bytes; give 1.
1530        let err = h.add_script_prefixes([(vec![0xab], 16)]).await.unwrap_err();
1531        assert!(matches!(err, StreamError::InvalidArgument(_)));
1532        // bits out of range (too small).
1533        let err = h.add_script_prefixes([(vec![], 0)]).await.unwrap_err();
1534        assert!(matches!(err, StreamError::InvalidArgument(_)));
1535        // bits above the server's 32-bit ceiling: rejected client-side rather
1536        // than sent and silently dropped by the server (5 bytes = ceil(33/8)).
1537        let err = h.add_script_prefixes([(vec![0u8; 5], 33)]).await.unwrap_err();
1538        assert!(matches!(err, StreamError::InvalidArgument(_)));
1539        // Nothing reached the wire.
1540        assert!(rx.try_recv().is_err());
1541
1542        // Valid boundary: exactly 32 bits, 4 bytes.
1543        h.add_script_prefixes([(vec![0xde, 0xad, 0xbe, 0xef], 32)]).await.unwrap();
1544        match next(&mut rx) {
1545            Msg::AddScriptPrefixes(a) => assert_eq!(a.prefixes[0].bits, 32),
1546            other => panic!("expected AddScriptPrefixes, got {other:?}"),
1547        }
1548
1549        // Valid: 16 bits, 2 bytes.
1550        h.add_script_prefixes([(vec![0xab, 0xcd], 16)]).await.unwrap();
1551        match next(&mut rx) {
1552            Msg::AddScriptPrefixes(a) => {
1553                assert_eq!(a.prefixes.len(), 1);
1554                assert_eq!(a.prefixes[0].bits, 16);
1555                assert_eq!(a.prefixes[0].prefix, vec![0xab, 0xcd]);
1556            }
1557            other => panic!("expected AddScriptPrefixes, got {other:?}"),
1558        }
1559    }
1560
1561    #[tokio::test]
1562    async fn set_cursor_wraps_cursor() {
1563        let (h, mut rx) = handle();
1564        let cursor = Cursor { height: 9, tx_index: 0, mempool_seq: 1, instance_id: 2 };
1565        h.set_cursor(cursor).await.unwrap();
1566        match next(&mut rx) {
1567            Msg::SetCursor(s) => assert_eq!(s.cursor, Some(cursor)),
1568            other => panic!("expected SetCursor, got {other:?}"),
1569        }
1570    }
1571
1572    /// The builder's `Debug` must show only that TLS is enabled — never the CA
1573    /// bytes, the client key, or the bearer token.
1574    #[cfg(feature = "tls")]
1575    #[test]
1576    fn tls_builder_debug_redacts_secrets() {
1577        let b = StreamClient::builder("https://node:50051")
1578            .bearer_token("SUPER_SECRET_TOKEN")
1579            .tls_ca_pem(b"-----BEGIN CERTIFICATE-----SECRET_CA_BYTES".to_vec())
1580            .tls_client_identity(b"CLIENT_CERT".to_vec(), b"CLIENT_PRIVATE_KEY".to_vec());
1581        let dbg = format!("{b:?}");
1582        assert!(dbg.contains("tls: true"), "expected tls: true, got {dbg}");
1583        assert!(dbg.contains("<redacted>"), "token should be redacted: {dbg}");
1584        assert!(!dbg.contains("SUPER_SECRET_TOKEN"));
1585        assert!(!dbg.contains("SECRET_CA_BYTES"));
1586        assert!(!dbg.contains("CLIENT_PRIVATE_KEY"));
1587    }
1588
1589    /// The TLS connect path assembles a rustls config and installs the `ring`
1590    /// provider without panicking; against a dead port it fails cleanly (a
1591    /// transport error, not `InvalidEndpoint`/`InvalidToken`). Exercises the
1592    /// whole `tls()` → `ClientTlsConfig::with_webpki_roots` → `tls_config`
1593    /// assembly end to end, no server required.
1594    #[cfg(feature = "tls")]
1595    #[tokio::test]
1596    async fn tls_connect_assembles_and_fails_cleanly() {
1597        let err = StreamClient::builder("https://127.0.0.1:1")
1598            .tls()
1599            .tls_domain("node.example")
1600            .connect()
1601            .await
1602            .expect_err("connect to a dead port must fail");
1603        assert!(
1604            !matches!(err, StreamError::InvalidEndpoint(_) | StreamError::InvalidToken),
1605            "expected a transport/connect error, got {err:?}",
1606        );
1607    }
1608
1609    /// TLS requested over a plaintext `http://` endpoint must fail closed at
1610    /// `connect()` rather than silently connecting in cleartext — tonic selects
1611    /// TLS from the URI scheme alone, so without this guard the bearer token and
1612    /// event stream would leak on the wire. Covers `http://` and a scheme-less
1613    /// endpoint; the `https://` happy path is exercised above.
1614    #[cfg(feature = "tls")]
1615    #[tokio::test]
1616    async fn tls_over_non_https_endpoint_is_refused() {
1617        for ep in ["http://127.0.0.1:1", "127.0.0.1:1"] {
1618            let err = StreamClient::builder(ep)
1619                .tls_ca_pem(b"-----BEGIN CERTIFICATE-----".to_vec())
1620                .bearer_token("SECRET")
1621                .connect()
1622                .await
1623                .expect_err("TLS over non-https must be refused");
1624            match err {
1625                StreamError::InvalidEndpoint(msg) => assert!(
1626                    msg.contains("https"),
1627                    "expected an https-scheme error for {ep}, got {msg}"
1628                ),
1629                other => panic!("expected InvalidEndpoint for {ep}, got {other:?}"),
1630            }
1631        }
1632    }
1633}
1634
1635/// The node-version check against an in-process tonic server that sends
1636/// whatever version headers a test asks for.
1637#[cfg(test)]
1638mod version_check_tests {
1639    use super::*;
1640    use std::sync::atomic::{AtomicUsize, Ordering};
1641
1642    use satd_events_proto::v1::node_event_stream_server::{
1643        NodeEventStream, NodeEventStreamServer,
1644    };
1645    use tokio_stream::Stream;
1646
1647    use crate::compat::{Version, SDK_VERSION};
1648    use crate::{ResilientConfig, ResilientWatchConfig};
1649
1650    type EventResult = Result<pb::NodeEvent, tonic::Status>;
1651    type BoxStream = std::pin::Pin<Box<dyn Stream<Item = EventResult> + Send>>;
1652
1653    /// A node stand-in. `version` can be changed between calls to model a node
1654    /// upgraded underneath a client.
1655    #[derive(Clone)]
1656    struct FakeNode {
1657        version: Arc<Mutex<Option<String>>>,
1658        schema: Option<&'static str>,
1659        /// When set, every stream delivers one heartbeat with this schema.
1660        event_schema: Option<u32>,
1661        opens: Arc<AtomicUsize>,
1662    }
1663
1664    impl FakeNode {
1665        fn new(version: Option<&str>) -> Self {
1666            FakeNode {
1667                version: Arc::new(Mutex::new(version.map(str::to_owned))),
1668                schema: Some("1"),
1669                event_schema: None,
1670                opens: Arc::default(),
1671            }
1672        }
1673
1674        fn respond(&self) -> tonic::Response<BoxStream> {
1675            self.opens.fetch_add(1, Ordering::SeqCst);
1676            let events: Vec<EventResult> = self
1677                .event_schema
1678                .map(|schema_version| {
1679                    Ok(pb::NodeEvent {
1680                        schema_version,
1681                        body: Some(pb::node_event::Body::Heartbeat(pb::Heartbeat {
1682                            uptime_ns: 1,
1683                        })),
1684                        ..Default::default()
1685                    })
1686                })
1687                .into_iter()
1688                .collect();
1689            let stream = tokio_stream::StreamExt::chain(
1690                tokio_stream::iter(events),
1691                tokio_stream::pending(),
1692            );
1693            let mut resp = tonic::Response::new(Box::pin(stream) as BoxStream);
1694            let md = resp.metadata_mut();
1695            if let Some(v) = self.version.lock().unwrap().as_deref() {
1696                md.insert(compat::VERSION_HEADER, v.parse().unwrap());
1697            }
1698            if let Some(s) = self.schema {
1699                md.insert(compat::SCHEMA_HEADER, s.parse().unwrap());
1700            }
1701            resp
1702        }
1703    }
1704
1705    #[tonic::async_trait]
1706    impl NodeEventStream for FakeNode {
1707        type SubscribeStream = BoxStream;
1708        type WatchStream = BoxStream;
1709
1710        async fn subscribe(
1711            &self,
1712            _: tonic::Request<pb::SubscribeRequest>,
1713        ) -> Result<tonic::Response<BoxStream>, tonic::Status> {
1714            Ok(self.respond())
1715        }
1716
1717        async fn watch(
1718            &self,
1719            _: tonic::Request<tonic::Streaming<pb::SubscribeControl>>,
1720        ) -> Result<tonic::Response<BoxStream>, tonic::Status> {
1721            Ok(self.respond())
1722        }
1723    }
1724
1725    async fn serve(fake: FakeNode) -> String {
1726        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
1727        let addr = listener.local_addr().unwrap();
1728        let incoming =
1729            tonic::transport::server::TcpIncoming::from_listener(listener, true, None).unwrap();
1730        tokio::spawn(
1731            tonic::transport::Server::builder()
1732                .add_service(NodeEventStreamServer::new(fake))
1733                .serve_with_incoming(incoming),
1734        );
1735        format!("http://{addr}")
1736    }
1737
1738    async fn client_for(fake: &FakeNode) -> StreamClient {
1739        StreamClient::builder(serve(fake.clone()).await).connect().await.expect("connect")
1740    }
1741
1742    fn sdk() -> Version {
1743        compat::parse(SDK_VERSION).unwrap()
1744    }
1745
1746    /// A node version `n` minor versions behind this build's SDK, derived so the
1747    /// tests keep meaning the same thing after every version bump. `None` when
1748    /// the SDK's minor is too small for that.
1749    fn minors_behind(n: u32) -> Option<String> {
1750        let sdk = sdk();
1751        (sdk.minor >= n).then(|| format!("{}.{}.0", sdk.major, sdk.minor - n))
1752    }
1753
1754    /// A node version the SDK refuses: two minors behind, or failing that a
1755    /// major behind.
1756    fn too_old() -> String {
1757        minors_behind(2).unwrap_or_else(|| format!("{}.9.0", sdk().major - 1))
1758    }
1759
1760    thread_local! {
1761        /// Where the test subscriber writes this thread's events.
1762        static THREAD_LOGS: std::cell::RefCell<Vec<u8>> =
1763            const { std::cell::RefCell::new(Vec::new()) };
1764    }
1765
1766    struct ThreadLogs;
1767
1768    thread_local! {
1769        /// Set by a test that wants every event checked against its client's
1770        /// compat lock. Per-thread, so it describes only the test that armed
1771        /// it even while other tests are logging on their own threads.
1772        static PROBE_COMPAT: std::cell::RefCell<Option<Arc<Mutex<CompatState>>>> =
1773            const { std::cell::RefCell::new(None) };
1774        /// One entry per event seen while the probe is armed: was the compat
1775        /// lock free when it was logged?
1776        static PROBE_FREE: std::cell::RefCell<Vec<bool>> =
1777            const { std::cell::RefCell::new(Vec::new()) };
1778    }
1779
1780    /// Records, for each event on an arming thread, whether the client's
1781    /// compat lock was free. Part of the one global subscriber rather than a
1782    /// scoped one for the reason given on [`Logs`].
1783    struct ProbeLock;
1784
1785    impl<S: tracing::Subscriber> tracing_subscriber::Layer<S> for ProbeLock {
1786        fn on_event(
1787            &self,
1788            _: &tracing::Event<'_>,
1789            _: tracing_subscriber::layer::Context<'_, S>,
1790        ) {
1791            PROBE_COMPAT.with(|p| {
1792                if let Some(compat) = p.borrow().as_ref() {
1793                    let free = compat.try_lock().is_ok();
1794                    PROBE_FREE.with(|f| f.borrow_mut().push(free));
1795                }
1796            });
1797        }
1798    }
1799
1800    /// Arms [`ProbeLock`] for this thread and disarms it on drop.
1801    struct Probe;
1802
1803    impl Probe {
1804        fn arm(compat: Arc<Mutex<CompatState>>) -> Self {
1805            PROBE_FREE.with(|f| f.borrow_mut().clear());
1806            PROBE_COMPAT.with(|p| *p.borrow_mut() = Some(compat));
1807            Probe
1808        }
1809
1810        fn seen(&self) -> Vec<bool> {
1811            PROBE_FREE.with(|f| f.borrow().clone())
1812        }
1813    }
1814
1815    impl Drop for Probe {
1816        fn drop(&mut self) {
1817            PROBE_COMPAT.with(|p| *p.borrow_mut() = None);
1818        }
1819    }
1820
1821    impl std::io::Write for ThreadLogs {
1822        fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
1823            THREAD_LOGS.with(|l| l.borrow_mut().extend_from_slice(buf));
1824            Ok(buf.len())
1825        }
1826        fn flush(&mut self) -> std::io::Result<()> {
1827            Ok(())
1828        }
1829    }
1830
1831    /// Captures `tracing` output for the duration of a test.
1832    ///
1833    /// One process-wide subscriber, installed once, writing into a per-thread
1834    /// buffer -- deliberately not a scoped `set_default` per test. Whether a
1835    /// `warn!` is evaluated at all is decided by tracing's process-wide
1836    /// callsite interest and max-level hint, both of which a scoped
1837    /// subscriber on *any* thread moves. Two of these tests running at once
1838    /// could therefore leave one with an empty capture, which is how
1839    /// `allow_old_node_downgrades_the_refusal_to_a_warning` failed in CI
1840    /// asserting one warning against none. libtest gives each test its own
1841    /// thread, so a per-thread buffer keeps them apart without a lock.
1842    struct Logs;
1843
1844    impl Logs {
1845        fn capture() -> Self {
1846            static INIT: std::sync::Once = std::sync::Once::new();
1847            INIT.call_once(|| {
1848                use tracing_subscriber::layer::SubscriberExt as _;
1849                use tracing_subscriber::util::SubscriberInitExt as _;
1850                let _ = tracing_subscriber::registry()
1851                    .with(
1852                        tracing_subscriber::fmt::layer()
1853                            .with_ansi(false)
1854                            .with_writer(|| ThreadLogs),
1855                    )
1856                    .with(ProbeLock)
1857                    .with(tracing_subscriber::filter::LevelFilter::TRACE)
1858                    .try_init();
1859            });
1860            THREAD_LOGS.with(|l| l.borrow_mut().clear());
1861            Logs
1862        }
1863
1864        fn lines(&self) -> Vec<String> {
1865            THREAD_LOGS.with(|l| {
1866                String::from_utf8(l.borrow().clone())
1867                    .unwrap()
1868                    .lines()
1869                    .map(str::to_owned)
1870                    .collect()
1871            })
1872        }
1873
1874        fn warnings(&self) -> Vec<String> {
1875            self.lines().into_iter().filter(|l| l.contains("WARN")).collect()
1876        }
1877    }
1878
1879    #[tokio::test]
1880    async fn same_version_node_opens_silently() {
1881        let logs = Logs::capture();
1882        let fake = FakeNode::new(Some(SDK_VERSION));
1883        let mut client = client_for(&fake).await;
1884        client.subscribe(SubscribeOptions::default()).await.expect("subscribe");
1885        client.watch().await.expect("watch");
1886        assert!(logs.warnings().is_empty(), "{:?}", logs.warnings());
1887    }
1888
1889    #[tokio::test]
1890    async fn newer_node_opens_silently() {
1891        let logs = Logs::capture();
1892        let sdk = sdk();
1893        let fake = FakeNode::new(Some(&format!("{}.{}.0", sdk.major, sdk.minor + 3)));
1894        let mut client = client_for(&fake).await;
1895        client.subscribe(SubscribeOptions::default()).await.expect("subscribe");
1896        assert!(logs.warnings().is_empty(), "{:?}", logs.warnings());
1897    }
1898
1899    /// A node that sends no headers is taken to be 0.5; what that means depends
1900    /// on this build's version, so the expectation is derived from the rule.
1901    #[tokio::test]
1902    async fn headerless_node_is_treated_as_0_5() {
1903        let mut fake = FakeNode::new(None);
1904        fake.schema = None;
1905        let mut client = client_for(&fake).await;
1906        let res = client.subscribe(SubscribeOptions::default()).await;
1907        match compat::classify(sdk(), compat::HEADERLESS_NODE) {
1908            Compat::TooOld => assert!(matches!(res, Err(StreamError::NodeTooOld { .. }))),
1909            _ => assert!(res.is_ok(), "{:?}", res.err()),
1910        }
1911        assert_eq!(client.node_version(), None);
1912    }
1913
1914    #[tokio::test]
1915    async fn two_minors_behind_is_refused() {
1916        let fake = FakeNode::new(Some(&too_old()));
1917        let mut client = client_for(&fake).await;
1918        let err = client.subscribe(SubscribeOptions::default()).await.err().expect("refused");
1919        match &err {
1920            StreamError::NodeTooOld { node, sdk } => {
1921                assert_eq!(node, &too_old());
1922                assert_eq!(*sdk, SDK_VERSION);
1923            }
1924            other => panic!("expected NodeTooOld, got {other:?}"),
1925        }
1926        assert!(!err.is_retryable());
1927        let err = client.watch().await.err().expect("watch refused too");
1928        assert!(matches!(err, StreamError::NodeTooOld { .. }), "{err:?}");
1929    }
1930
1931    #[tokio::test]
1932    async fn allow_old_node_downgrades_the_refusal_to_a_warning() {
1933        let logs = Logs::capture();
1934        let fake = FakeNode::new(Some(&too_old()));
1935        let mut client = StreamClient::builder(serve(fake.clone()).await)
1936            .allow_old_node()
1937            .connect()
1938            .await
1939            .unwrap();
1940        client.subscribe(SubscribeOptions::default()).await.expect("allowed");
1941        assert_eq!(logs.warnings().len(), 1, "{:?}", logs.warnings());
1942    }
1943
1944    #[tokio::test]
1945    async fn schema_header_mismatch_is_refused_even_with_allow_old_node() {
1946        let mut fake = FakeNode::new(Some(SDK_VERSION));
1947        fake.schema = Some("2");
1948        let mut client = StreamClient::builder(serve(fake.clone()).await)
1949            .allow_old_node()
1950            .connect()
1951            .await
1952            .unwrap();
1953        let err = client.subscribe(SubscribeOptions::default()).await.err().expect("refused");
1954        assert!(matches!(err, StreamError::SchemaMismatch { node: 2, sdk: 1 }), "{err:?}");
1955        assert!(!err.is_retryable());
1956        let err = client.watch().await.err().expect("watch refused too");
1957        assert!(matches!(err, StreamError::SchemaMismatch { node: 2, sdk: 1 }), "{err:?}");
1958    }
1959
1960    /// A node that predates the headers still stamps `schema_version` on every
1961    /// event; the first one is checked.
1962    #[tokio::test]
1963    async fn first_event_schema_mismatch_is_refused() {
1964        let mut fake = FakeNode::new(Some(SDK_VERSION));
1965        fake.event_schema = Some(2);
1966        let mut client = client_for(&fake).await;
1967        let mut stream = client.subscribe(SubscribeOptions::default()).await.expect("opens");
1968        let err = stream.message().await.expect_err("refused");
1969        assert!(matches!(err, StreamError::SchemaMismatch { node: 2, sdk: 1 }), "{err:?}");
1970
1971        // The matching schema passes through as an ordinary event.
1972        let mut fake = FakeNode::new(Some(SDK_VERSION));
1973        fake.event_schema = Some(1);
1974        let mut client = client_for(&fake).await;
1975        let (_handle, mut stream) = client.watch().await.expect("opens");
1976        let ev = stream.message().await.expect("event").expect("not closed");
1977        assert!(matches!(ev, Event::Heartbeat { .. }), "{ev:?}");
1978    }
1979
1980    #[tokio::test]
1981    async fn one_behind_warns_once_per_node_version() {
1982        let logs = Logs::capture();
1983        // At a `.0` minor there is no one-behind in the same major; the
1984        // downgraded refusal logs the same warning.
1985        let (behind, allow) = match minors_behind(1) {
1986            Some(v) => (v, false),
1987            None => (too_old(), true),
1988        };
1989        let fake = FakeNode::new(Some(&behind));
1990        let mut builder = StreamClient::builder(serve(fake.clone()).await);
1991        if allow {
1992            builder = builder.allow_old_node();
1993        }
1994        let mut client = builder.connect().await.unwrap();
1995
1996        client.subscribe(SubscribeOptions::default()).await.expect("subscribe");
1997        client.subscribe(SubscribeOptions::default()).await.expect("subscribe again");
1998        // A clone (as the resilient layers hold) shares the state.
1999        client.clone().watch().await.expect("watch");
2000        let warnings = logs.warnings();
2001        assert_eq!(warnings.len(), 1, "{warnings:?}");
2002        assert!(warnings[0].contains(&format!("node_version={behind}")), "{warnings:?}");
2003        assert!(warnings[0].contains(&format!("sdk_version=\"{SDK_VERSION}\"")), "{warnings:?}");
2004        assert!(warnings[0].contains("is older than this SDK"), "{warnings:?}");
2005
2006        // The node changes version (a rolling downgrade, say): warn again.
2007        *fake.version.lock().unwrap() = Some(format!("{behind}-other"));
2008        client.subscribe(SubscribeOptions::default()).await.expect("subscribe");
2009        client.subscribe(SubscribeOptions::default()).await.expect("subscribe");
2010        assert_eq!(logs.warnings().len(), 2, "{:?}", logs.warnings());
2011    }
2012
2013    /// The warning is logged with the compat lock released. A subscriber is user
2014    /// code: one that reads `node_version()` from inside the event would
2015    /// otherwise deadlock, and a slow one would stall every stream open.
2016    #[tokio::test]
2017    async fn warning_is_logged_without_holding_the_compat_lock() {
2018        let _logs = Logs::capture();
2019        let (behind, allow) = match minors_behind(1) {
2020            Some(v) => (v, false),
2021            None => (too_old(), true),
2022        };
2023        let fake = FakeNode::new(Some(&behind));
2024        let mut builder = StreamClient::builder(serve(fake.clone()).await);
2025        if allow {
2026            builder = builder.allow_old_node();
2027        }
2028        let mut client = builder.connect().await.unwrap();
2029
2030        let probe = Probe::arm(client.compat.clone());
2031        client.subscribe(SubscribeOptions::default()).await.expect("subscribe");
2032        let seen = probe.seen();
2033        assert!(!seen.is_empty(), "the warning was not logged");
2034        assert!(seen.iter().all(|f| *f), "an event was logged under the compat lock: {seen:?}");
2035    }
2036
2037    #[tokio::test]
2038    async fn node_version_is_recorded_once_a_stream_opens() {
2039        let fake = FakeNode::new(Some(SDK_VERSION));
2040        let client = client_for(&fake).await;
2041        assert_eq!(client.node_version(), None);
2042        client.clone().subscribe(SubscribeOptions::default()).await.expect("subscribe");
2043        assert_eq!(client.node_version().as_deref(), Some(SDK_VERSION));
2044    }
2045
2046    #[tokio::test]
2047    async fn resilient_subscribe_surfaces_node_too_old_without_retrying() {
2048        let fake = FakeNode::new(Some(&too_old()));
2049        let client = client_for(&fake).await;
2050        let mut sub =
2051            client.resilient_subscribe(SubscribeOptions::default(), ResilientConfig::default());
2052        let err = tokio::time::timeout(Duration::from_secs(5), sub.next())
2053            .await
2054            .expect("no backoff loop")
2055            .expect_err("refused");
2056        assert!(matches!(err, StreamError::NodeTooOld { .. }), "{err:?}");
2057        assert_eq!(fake.opens.load(Ordering::SeqCst), 1);
2058    }
2059
2060    #[tokio::test]
2061    async fn resilient_watch_surfaces_node_too_old_without_retrying() {
2062        let fake = FakeNode::new(Some(&too_old()));
2063        let client = client_for(&fake).await;
2064        let mut watch = client.resilient_watch(ResilientWatchConfig::default());
2065        let err = tokio::time::timeout(Duration::from_secs(5), watch.next())
2066            .await
2067            .expect("no backoff loop")
2068            .expect_err("refused");
2069        assert!(matches!(err, StreamError::NodeTooOld { .. }), "{err:?}");
2070        assert_eq!(fake.opens.load(Ordering::SeqCst), 1);
2071    }
2072}