Skip to main content

satd_events_client/
resilient_watch.rs

1//! Reconnect, watch-set replay, and re-anchor layer over [`StreamClient::watch`].
2//!
3//! [`ResilientSubscription`](crate::ResilientSubscription) wraps the one-way
4//! `Subscribe` firehose; [`ResilientWatch`] is its twin for the bidirectional
5//! `Watch` stream. It exists because the two recovery stories differ:
6//!
7//! - **The watch-set is per-connection.** The server holds no principal-keyed
8//!   state — when a `Watch` stream drops, its server-side watch-set and quota
9//!   leases are torn down with it. A reconnect therefore starts blank, so the
10//!   SDK has to **mirror** every add/remove the caller makes and **re-register**
11//!   the whole set on the new stream before anything matches again.
12//! - **Re-anchor is in-band and deterministic (#439/#441).** Once the watch-set
13//!   is back, a single `set_cursor` replays confirmed history; the server
14//!   answers with exactly one [`Event::CursorAccepted`] (replaying — watch
15//!   `clamped` for an authoritative replay-window gap) or
16//!   [`Event::CursorRejected`]. [`ResilientWatch`] drives its catch-up off those
17//!   deterministic results instead of inferring a gap from the event flow.
18//!
19//! What it handles for the caller:
20//!
21//! - **Reconnect with backoff** — a transport error or clean server close
22//!   triggers an exponential-backoff reconnect (the shared [`Backoff`]).
23//! - **Watch-set replay** — the mirrored watch-set (scripts + floors, outpoints,
24//!   tx lifecycles, depth alarms, descriptors, prefixes, category filter) is
25//!   re-sent on every (re)connect, in place of a manual re-add. Integrators whose
26//!   watch-set has a durable source-of-truth outside the wrapper can instead
27//!   install a [`watch_set_loader`](ResilientWatchConfig::watch_set_loader) that
28//!   rebuilds the canonical set from that truth on every (re)connect — closing
29//!   the restart-loses-mirror and truth-drift-during-disconnect gaps.
30//! - **Re-anchor in place** — after replay it `set_cursor`s to the persisted
31//!   high-water and resumes confirmed replay. Transient rejects
32//!   (`RateLimited`, `ConcurrentReanchor`) are backed off and retried in place;
33//!   terminal ones (`NoSource`, …) and the `clamped` accept are surfaced so the
34//!   caller can escalate to a full resnapshot — the exception, not the rule.
35//! - **Cursor persistence** — confirmed cursors are committed-on-poll to a
36//!   shared [`CursorStore`], so a resume survives reconnects and restarts.
37//! - **Refused adds** — when the server answers an add with
38//!   [`Event::WatchAddRejected`] for its rate limit, the items stay in the mirror
39//!   and are re-sent after the server's retry hint, within the backoff budget;
40//!   the event is absorbed until the budget runs out. Any other refusal (quota,
41//!   cap, permission, malformed), or a rate limit past the budget, drops the
42//!   items from the mirror (a refused descriptor slide falls back to the window
43//!   it replaced) and hands the event to the caller, so a reconnect re-registers
44//!   only what the server holds.
45
46use std::collections::{BTreeMap, BTreeSet, HashMap};
47use std::future::Future;
48use std::pin::Pin;
49use std::sync::{Arc, Mutex};
50
51use satd_events_proto::v1 as pb;
52use satd_events_proto::v1::subscribe_control::Msg;
53
54use crate::client::{
55    validate_prefix, AutoClose, EventStream, SilentPaymentTarget, StreamClient, WatchHandle,
56};
57use crate::error::StreamError;
58use crate::event::{Cursor, CursorRejectReason, Event, WatchAddRejectReason, WatchAddRejected};
59use crate::resilience::{Backoff, CursorStore, NoopCursorStore};
60
61/// A boxed integrator error returned by a watch-set loader.
62type BoxError = Box<dyn std::error::Error + Send + Sync>;
63
64/// The stored watch-set loader closure: takes a fresh [`WatchSetBuilder`],
65/// populates it (typically from a durable source-of-truth), and resolves to
66/// `Ok(())` once the canonical set is declared. Boxed so the loader can be any
67/// async closure the integrator supplies.
68type WatchSetLoaderFn =
69    dyn Fn(WatchSetBuilder) -> Pin<Box<dyn Future<Output = Result<(), BoxError>> + Send>>
70        + Send
71        + Sync;
72
73/// A scripthash (`sha256(scriptPubKey)`).
74type Scripthash = [u8; 32];
75/// A txid (32 raw bytes, internal order).
76type Txid = [u8; 32];
77
78/// A client-side mirror of the watch-set registered on a `Watch` stream, so it
79/// can be re-registered verbatim after a reconnect.
80///
81/// Each kind is stored as its **net** set (adds minus removes), keyed so a
82/// re-assertion overwrites rather than duplicates — exactly the server's own
83/// semantics. [`control_messages`](Self::control_messages) renders the net set
84/// back into the [`SubscribeControl`](pb::SubscribeControl) `Add*` messages that
85/// reconstruct it.
86#[derive(Debug, Default, Clone)]
87pub(crate) struct WatchSetMirror {
88    /// Scripthash → optional `min_value` floor (a re-assert updates the floor).
89    scripts: BTreeMap<Scripthash, Option<u64>>,
90    /// Watched outpoints (`txid`, `vout`).
91    outpoints: BTreeSet<(Txid, u32)>,
92    /// Txid → lifecycle auto-close policy.
93    tx_lifecycles: BTreeMap<Txid, AutoClose>,
94    /// Single-shot depth alarms, as flattened `(txid, depth)` pairs.
95    depth_alarms: BTreeSet<(Txid, u32)>,
96    /// Descriptor → its latest `(gap_limit, start)` window.
97    descriptors: BTreeMap<String, (u32, u32)>,
98    /// Descriptor → the windows its earlier adds asked for, oldest first, so a
99    /// slide the server refuses can fall back to the window it still holds.
100    /// Bounded by [`DESCRIPTOR_HISTORY`].
101    descriptor_history: BTreeMap<String, Vec<(u32, u32)>>,
102    /// Script-prefix buckets, as `(bits, prefix)` (validated on insert).
103    prefixes: BTreeSet<(u32, Vec<u8>)>,
104    /// BIP 352 scan-key targets, keyed by identity `b_scan·G` (33 bytes) — the
105    /// removal key, and how a same-key re-register (refreshed labels) dedups. The
106    /// value's `Debug` redacts the scan secret. Re-registered across reconnects,
107    /// which is exactly the D3 custody model (the scan key is disclosed anew each
108    /// time — never persisted server-side).
109    silent_payments: BTreeMap<[u8; 33], SilentPaymentTarget>,
110    /// The live category filter, if the caller set one.
111    categories: Option<u32>,
112    /// The raw-tx opt-in (SetWatchOptions), if the caller set one. Replayed on
113    /// reconnect so `include_raw_tx` survives a stream tear-down.
114    include_raw_tx: Option<bool>,
115}
116
117impl WatchSetMirror {
118    fn add_scripts(&mut self, items: &[(Scripthash, Option<u64>)]) {
119        for (h, floor) in items {
120            self.scripts.insert(*h, *floor);
121        }
122    }
123
124    fn remove_scripts(&mut self, hashes: &[Scripthash]) {
125        for h in hashes {
126            self.scripts.remove(h);
127        }
128    }
129
130    fn add_outpoints(&mut self, items: &[(Txid, u32)]) {
131        self.outpoints.extend(items.iter().copied());
132    }
133
134    fn remove_outpoints(&mut self, items: &[(Txid, u32)]) {
135        for op in items {
136            self.outpoints.remove(op);
137        }
138    }
139
140    fn add_tx_lifecycle(&mut self, txids: &[Txid], auto_close: AutoClose) {
141        for t in txids {
142            self.tx_lifecycles.insert(*t, auto_close);
143        }
144    }
145
146    fn remove_tx_lifecycle(&mut self, txids: &[Txid]) {
147        for t in txids {
148            self.tx_lifecycles.remove(t);
149        }
150    }
151
152    fn add_depth_alarms(&mut self, pairs: &[(Txid, u32)]) {
153        self.depth_alarms.extend(pairs.iter().copied());
154    }
155
156    fn remove_depth_alarms(&mut self, pairs: &[(Txid, u32)]) {
157        for p in pairs {
158            self.depth_alarms.remove(p);
159        }
160    }
161
162    fn add_descriptor(&mut self, descriptor: String, gap_limit: u32, start: u32) {
163        let window = (gap_limit, start);
164        match self.descriptors.insert(descriptor.clone(), window) {
165            Some(prev) if prev != window => {
166                let history = self.descriptor_history.entry(descriptor).or_default();
167                history.retain(|w| *w != window);
168                history.push(prev);
169                if history.len() > DESCRIPTOR_HISTORY {
170                    history.remove(0);
171                }
172            }
173            Some(_) => {}
174            None => {
175                self.descriptor_history.remove(&descriptor);
176            }
177        }
178    }
179
180    fn remove_descriptor(&mut self, descriptor: &str) {
181        self.descriptors.remove(descriptor);
182        self.descriptor_history.remove(descriptor);
183    }
184
185    /// The part of the mirror a refusal names, for re-sending it: only items the
186    /// caller still holds (a removal since the refusal wins), with their current
187    /// metadata (floors, auto-close, labels). A descriptor is included only at
188    /// the window the refusal named; a later slide already replaced it.
189    fn subset_for(&self, r: &WatchAddRejected) -> WatchSetMirror {
190        let fixed = |b: &Vec<u8>| <[u8; 32]>::try_from(b.as_slice()).ok();
191        let mut out = WatchSetMirror::default();
192        for sh in r.scripthashes.iter().filter_map(fixed) {
193            if let Some(floor) = self.scripts.get(&sh) {
194                out.scripts.insert(sh, *floor);
195            }
196        }
197        for (t, v) in &r.outpoints {
198            if let Some(op) = fixed(t).map(|t| (t, *v)).filter(|op| self.outpoints.contains(op)) {
199                out.outpoints.insert(op);
200            }
201        }
202        for t in r.txids.iter().filter_map(fixed) {
203            if let Some(close) = self.tx_lifecycles.get(&t) {
204                out.tx_lifecycles.insert(t, *close);
205            }
206        }
207        for (t, d) in &r.depth_alarms {
208            if let Some(alarm) = fixed(t).map(|t| (t, *d)).filter(|a| self.depth_alarms.contains(a)) {
209                out.depth_alarms.insert(alarm);
210            }
211        }
212        for (prefix, bits) in &r.prefixes {
213            for (b, p) in &self.prefixes {
214                if b == bits && mask_prefix(p, *b) == *prefix {
215                    out.prefixes.insert((*b, p.clone()));
216                }
217            }
218        }
219        for id in r.scan_pubkeys.iter().filter_map(|b| <[u8; 33]>::try_from(b.as_slice()).ok()) {
220            if let Some(t) = self.silent_payments.get(&id) {
221                out.silent_payments.insert(id, t.clone());
222            }
223        }
224        if let Some(d) = &r.descriptor
225            && self.descriptors.get(&d.descriptor) == Some(&(d.gap_limit, d.start))
226        {
227            out.descriptors.insert(d.descriptor.clone(), (d.gap_limit, d.start));
228        }
229        out
230    }
231
232    /// Drop what the server refused to register, so the mirror holds only what
233    /// the server holds. Items are named exactly as the server echoes them.
234    fn forget_rejected(&mut self, r: &WatchAddRejected) {
235        let fixed = |b: &Vec<u8>| <[u8; 32]>::try_from(b.as_slice()).ok();
236        let scripts: Vec<Scripthash> = r.scripthashes.iter().filter_map(fixed).collect();
237        self.remove_scripts(&scripts);
238        let outpoints: Vec<(Txid, u32)> =
239            r.outpoints.iter().filter_map(|(t, v)| Some((fixed(t)?, *v))).collect();
240        self.remove_outpoints(&outpoints);
241        let txids: Vec<Txid> = r.txids.iter().filter_map(fixed).collect();
242        self.remove_tx_lifecycle(&txids);
243        let alarms: Vec<(Txid, u32)> =
244            r.depth_alarms.iter().filter_map(|(t, d)| Some((fixed(t)?, *d))).collect();
245        self.remove_depth_alarms(&alarms);
246        // The server echoes a prefix masked to its bits; the mirror holds it as
247        // the caller wrote it.
248        for (prefix, bits) in &r.prefixes {
249            self.prefixes.retain(|(b, p)| !(b == bits && mask_prefix(p, *b) == *prefix));
250        }
251        let ids: Vec<[u8; 33]> =
252            r.scan_pubkeys.iter().filter_map(|b| <[u8; 33]>::try_from(b.as_slice()).ok()).collect();
253        self.remove_silent_payments(&ids);
254        if let Some(d) = &r.descriptor {
255            let refused = (d.gap_limit, d.start);
256            if self.descriptors.get(&d.descriptor) == Some(&refused) {
257                // The node refused the latest window. It handles slides in
258                // order, so what it keeps is the latest earlier window that was
259                // not itself refused.
260                let prev = self.descriptor_history.get_mut(&d.descriptor).and_then(Vec::pop);
261                match prev {
262                    Some(prev) if d.kept => {
263                        self.descriptors.insert(d.descriptor.clone(), prev);
264                    }
265                    // Kept, but the mirror never saw the earlier window (a
266                    // loader-built set): leave the requested one to replay.
267                    None if d.kept => {}
268                    _ => {
269                        self.descriptors.remove(&d.descriptor);
270                        self.descriptor_history.remove(&d.descriptor);
271                    }
272                }
273            } else if let Some(history) = self.descriptor_history.get_mut(&d.descriptor) {
274                // An earlier slide was refused while a later one is pending: that
275                // window cannot be the one the node keeps.
276                history.retain(|w| *w != refused);
277            }
278            if self.descriptor_history.get(&d.descriptor).is_some_and(Vec::is_empty) {
279                self.descriptor_history.remove(&d.descriptor);
280            }
281        }
282    }
283
284    fn add_prefixes(&mut self, items: &[pb::ScriptPrefix]) {
285        for sp in items {
286            self.prefixes.insert((sp.bits, sp.prefix.clone()));
287        }
288    }
289
290    fn remove_prefixes(&mut self, items: &[pb::ScriptPrefix]) {
291        for sp in items {
292            self.prefixes.remove(&(sp.bits, sp.prefix.clone()));
293        }
294    }
295
296    // Only populated via the `bitcoin`-gated entry points (which derive the
297    // scan_pubkey key); without that feature the SP map simply stays empty.
298    #[cfg(feature = "bitcoin")]
299    fn add_silent_payments(&mut self, entries: &[([u8; 33], SilentPaymentTarget)]) {
300        for (scan_pubkey, target) in entries {
301            self.silent_payments.insert(*scan_pubkey, target.clone());
302        }
303    }
304
305    fn remove_silent_payments(&mut self, scan_pubkeys: &[[u8; 33]]) {
306        for k in scan_pubkeys {
307            self.silent_payments.remove(k);
308        }
309    }
310
311    fn set_categories(&mut self, categories: u32) {
312        self.categories = Some(categories);
313    }
314
315    fn set_watch_options(&mut self, include_raw_tx: bool) {
316        self.include_raw_tx = Some(include_raw_tx);
317    }
318
319    /// Render the net watch-set into the control messages that reconstruct it on
320    /// a fresh stream. The category filter goes first (so it is in effect before
321    /// matches flow); the rest are grouped to match the wire shapes the
322    /// [`WatchHandle`] helpers emit:
323    /// - scripts in one `AddScripts` (parallel `min_values`, or empty when no
324    ///   floor is set on any script);
325    /// - lifecycles grouped by `auto_close_depth` (empty `min_depths`);
326    /// - depth alarms grouped by txid (non-empty `min_depths` — the server
327    ///   dispatches on that), one message per txid;
328    /// - one `AddDescriptor` per descriptor; prefixes in one `AddScriptPrefixes`.
329    fn control_messages(&self) -> Vec<Msg> {
330        let mut out = Vec::new();
331
332        if let Some(c) = self.categories {
333            out.push(Msg::SetCategories(pb::SetCategories { categories: c }));
334        }
335
336        // Re-apply the raw-tx opt-in before matches flow, so replayed and live
337        // ScriptMatched carry raw_tx exactly as before the reconnect.
338        if let Some(include_raw_tx) = self.include_raw_tx {
339            out.push(Msg::SetWatchOptions(pb::SetWatchOptions { include_raw_tx }));
340        }
341
342        if !self.scripts.is_empty() {
343            let scripthashes: Vec<Vec<u8>> = self.scripts.keys().map(|h| h.to_vec()).collect();
344            // Parallel `min_values` only when some script carries a floor (a 0
345            // stands in for the unfloored entries to keep the vecs parallel);
346            // empty otherwise, matching `WatchHandle::add_scripts`.
347            let min_values = if self.scripts.values().any(Option::is_some) {
348                self.scripts.values().map(|f| f.unwrap_or(0)).collect()
349            } else {
350                Vec::new()
351            };
352            out.push(Msg::AddScripts(pb::AddScripts { scripthashes, min_values }));
353        }
354
355        if !self.outpoints.is_empty() {
356            let outpoints = self
357                .outpoints
358                .iter()
359                .map(|(t, v)| pb::Outpoint { txid: t.to_vec(), vout: *v })
360                .collect();
361            out.push(Msg::AddOutpoints(pb::AddOutpoints { outpoints }));
362        }
363
364        if !self.tx_lifecycles.is_empty() {
365            let mut by_depth: BTreeMap<u32, Vec<Vec<u8>>> = BTreeMap::new();
366            for (txid, ac) in &self.tx_lifecycles {
367                let depth = match ac {
368                    AutoClose::Never => 0,
369                    AutoClose::AtDepth(d) => *d,
370                };
371                by_depth.entry(depth).or_default().push(txid.to_vec());
372            }
373            for (auto_close_depth, txids) in by_depth {
374                out.push(Msg::AddTransactions(pb::AddTransactions {
375                    txids,
376                    min_depths: Vec::new(),
377                    auto_close_depth,
378                }));
379            }
380        }
381
382        if !self.depth_alarms.is_empty() {
383            let mut by_txid: BTreeMap<Txid, Vec<u32>> = BTreeMap::new();
384            for (txid, depth) in &self.depth_alarms {
385                by_txid.entry(*txid).or_default().push(*depth);
386            }
387            for (txid, min_depths) in by_txid {
388                out.push(Msg::AddTransactions(pb::AddTransactions {
389                    txids: vec![txid.to_vec()],
390                    min_depths,
391                    auto_close_depth: 0,
392                }));
393            }
394        }
395
396        for (descriptor, (gap_limit, start)) in &self.descriptors {
397            out.push(Msg::AddDescriptor(pb::AddDescriptor {
398                descriptor: descriptor.clone(),
399                gap_limit: *gap_limit,
400                start: *start,
401            }));
402        }
403
404        if !self.prefixes.is_empty() {
405            let prefixes = self
406                .prefixes
407                .iter()
408                .map(|(bits, prefix)| pb::ScriptPrefix { prefix: prefix.clone(), bits: *bits })
409                .collect();
410            out.push(Msg::AddScriptPrefixes(pb::AddScriptPrefixes { prefixes }));
411        }
412
413        if !self.silent_payments.is_empty() {
414            let targets = self.silent_payments.values().map(|t| t.to_proto()).collect();
415            out.push(Msg::AddSilentPayments(pb::AddSilentPayments { targets }));
416        }
417
418        out
419    }
420
421    /// Render the whole net watch-set as a single `SetWatchSet` snapshot — the
422    /// atomic-replace payload [`reload`](ResilientWatch::reload) sends. Unlike
423    /// [`control_messages`](Self::control_messages) (a fresh-stream replay) or a
424    /// `reconcile_to` delta, this is the COMPLETE desired membership in one
425    /// message: the server reconciles it under its lock by effective coverage, so
426    /// there is no client-computed Add*/Remove* ordering to strand coverage or
427    /// over-charge at quota.
428    // `..Default::default()` (below) is redundant against the current workspace
429    // proto but retained for forward-compat if the schema grows fields.
430    #[allow(clippy::needless_update)]
431    fn to_set_watch_set(&self) -> pb::SetWatchSet {
432        let scripthashes: Vec<Vec<u8>> = self.scripts.keys().map(|h| h.to_vec()).collect();
433        let min_values = if self.scripts.values().any(Option::is_some) {
434            self.scripts.values().map(|f| f.unwrap_or(0)).collect()
435        } else {
436            Vec::new()
437        };
438        pb::SetWatchSet {
439            categories: self.categories.unwrap_or(0),
440            scripthashes,
441            min_values,
442            outpoints: self
443                .outpoints
444                .iter()
445                .map(|(t, v)| pb::Outpoint { txid: t.to_vec(), vout: *v })
446                .collect(),
447            descriptors: self
448                .descriptors
449                .iter()
450                .map(|(d, (gap_limit, start))| pb::AddDescriptor {
451                    descriptor: d.clone(),
452                    gap_limit: *gap_limit,
453                    start: *start,
454                })
455                .collect(),
456            prefixes: self
457                .prefixes
458                .iter()
459                .map(|(bits, prefix)| pb::ScriptPrefix { prefix: prefix.clone(), bits: *bits })
460                .collect(),
461            lifecycles: self
462                .tx_lifecycles
463                .iter()
464                .map(|(t, ac)| pb::WatchLifecycle {
465                    txid: t.to_vec(),
466                    auto_close_depth: match ac {
467                        AutoClose::Never => 0,
468                        AutoClose::AtDepth(d) => *d,
469                    },
470                })
471                .collect(),
472            depth_alarms: self
473                .depth_alarms
474                .iter()
475                .map(|(t, d)| pb::WatchDepthAlarm { txid: t.to_vec(), depth: *d })
476                .collect(),
477            silent_payments: self.silent_payments.values().map(|t| t.to_proto()).collect(),
478            // `..Default::default()` for the same reason as SubscribeRequest in
479            // client.rs: this published crate must compile against both the pinned
480            // released proto and the newer in-workspace schema — any field a
481            // still-newer proto adds defaults to empty here without being named.
482            ..Default::default()
483        }
484    }
485
486    /// Compute the minimal control messages that transform `self` (the current
487    /// net watch-set) into `target`, plus item counts — the delta a
488    /// [`reload`](ResilientWatch::reload) applies on the live wire. Only the
489    /// differences are emitted: items present-and-equal in both are left alone,
490    /// so the stream is never cleared and rebuilt. `Remove*`/`RemoveDescriptor`
491    /// lead (after any category change) so a removal frees its quota unit before
492    /// the replacement `Add*` claims it — a swap at quota would otherwise have its
493    /// net-new adds silently rejected while the removes still applied. See the
494    /// ordering note at the tail of this function for the full rationale. A
495    /// descriptor whose window changed is one `AddDescriptor` (the server
496    /// reconciles the slid window); a descriptor that disappeared is a
497    /// `RemoveDescriptor`.
498    fn reconcile_to(&self, target: &WatchSetMirror) -> (Vec<Msg>, ReloadCounts) {
499        let mut lead = Vec::new();
500        let mut adds = Vec::new();
501        let mut removes = Vec::new();
502        let mut c = ReloadCounts::default();
503
504        // Category filter (not a watch *item*, so it does not move the counts).
505        // `None` and `Some(0)` both mean "all categories" — the wire spells "all"
506        // as `SetCategories { categories: 0 }` (both carriers map 0 to the
507        // default mask — every category except the explicit-only ones, `tweaks`
508        // and `status`, so an older client never starts receiving a body it has
509        // no parser for).
510        // Compare the *effective* masks, not the raw `Option`: a reload that
511        // relaxes the filter (`Some(n)` → truth has no filter) must emit an
512        // explicit `SetCategories { 0 }` reset, or the mirror would adopt "no
513        // filter" while the live server stayed filtered until the next reconnect,
514        // silently dropping the now-in-scope events. A no-op `None` ↔ `Some(0)`
515        // change emits nothing.
516        let target_mask = target.categories.unwrap_or(0);
517        let live_mask = self.categories.unwrap_or(0);
518        if target_mask != live_mask {
519            lead.push(Msg::SetCategories(pb::SetCategories { categories: target_mask }));
520        }
521
522        // Scripts: new or changed-floor → AddScripts; vanished → RemoveScripts.
523        {
524            let mut changed: Vec<(Scripthash, Option<u64>)> = Vec::new();
525            for (h, floor) in &target.scripts {
526                match self.scripts.get(h) {
527                    Some(f) if f == floor => c.unchanged += 1,
528                    _ => changed.push((*h, *floor)),
529                }
530            }
531            let gone: Vec<Scripthash> =
532                self.scripts.keys().filter(|h| !target.scripts.contains_key(*h)).copied().collect();
533            if !changed.is_empty() {
534                c.added += changed.len();
535                let scripthashes = changed.iter().map(|(h, _)| h.to_vec()).collect();
536                let min_values = if changed.iter().any(|(_, f)| f.is_some()) {
537                    changed.iter().map(|(_, f)| f.unwrap_or(0)).collect()
538                } else {
539                    Vec::new()
540                };
541                adds.push(Msg::AddScripts(pb::AddScripts { scripthashes, min_values }));
542            }
543            if !gone.is_empty() {
544                c.removed += gone.len();
545                removes.push(Msg::RemoveScripts(pb::RemoveScripts {
546                    scripthashes: gone.iter().map(|h| h.to_vec()).collect(),
547                }));
548            }
549        }
550
551        // Outpoints.
552        {
553            let add: Vec<_> = target.outpoints.difference(&self.outpoints).copied().collect();
554            let gone: Vec<_> = self.outpoints.difference(&target.outpoints).copied().collect();
555            c.unchanged += target.outpoints.intersection(&self.outpoints).count();
556            if !add.is_empty() {
557                c.added += add.len();
558                adds.push(Msg::AddOutpoints(pb::AddOutpoints {
559                    outpoints: add.iter().map(|(t, v)| pb::Outpoint { txid: t.to_vec(), vout: *v }).collect(),
560                }));
561            }
562            if !gone.is_empty() {
563                c.removed += gone.len();
564                removes.push(Msg::RemoveOutpoints(pb::RemoveOutpoints {
565                    outpoints: gone.iter().map(|(t, v)| pb::Outpoint { txid: t.to_vec(), vout: *v }).collect(),
566                }));
567            }
568        }
569
570        // Tx lifecycles: new or changed auto-close → AddTransactions (grouped by
571        // depth, empty min_depths); vanished → RemoveTransactions (lifecycle).
572        {
573            let mut by_depth: BTreeMap<u32, Vec<Vec<u8>>> = BTreeMap::new();
574            for (txid, ac) in &target.tx_lifecycles {
575                match self.tx_lifecycles.get(txid) {
576                    Some(old) if old == ac => c.unchanged += 1,
577                    _ => {
578                        let depth = match ac {
579                            AutoClose::Never => 0,
580                            AutoClose::AtDepth(d) => *d,
581                        };
582                        by_depth.entry(depth).or_default().push(txid.to_vec());
583                        c.added += 1;
584                    }
585                }
586            }
587            for (auto_close_depth, txids) in by_depth {
588                adds.push(Msg::AddTransactions(pb::AddTransactions {
589                    txids,
590                    min_depths: Vec::new(),
591                    auto_close_depth,
592                }));
593            }
594            let gone: Vec<Vec<u8>> = self
595                .tx_lifecycles
596                .keys()
597                .filter(|t| !target.tx_lifecycles.contains_key(*t))
598                .map(|t| t.to_vec())
599                .collect();
600            if !gone.is_empty() {
601                c.removed += gone.len();
602                removes.push(Msg::RemoveTransactions(pb::RemoveTransactions {
603                    txids: gone,
604                    min_depths: Vec::new(),
605                }));
606            }
607        }
608
609        // Depth alarms, grouped per txid (non-empty min_depths marks the kind).
610        {
611            let add: Vec<_> = target.depth_alarms.difference(&self.depth_alarms).copied().collect();
612            let gone: Vec<_> = self.depth_alarms.difference(&target.depth_alarms).copied().collect();
613            c.unchanged += target.depth_alarms.intersection(&self.depth_alarms).count();
614            if !add.is_empty() {
615                c.added += add.len();
616                let mut by_txid: BTreeMap<Txid, Vec<u32>> = BTreeMap::new();
617                for (t, d) in add {
618                    by_txid.entry(t).or_default().push(d);
619                }
620                for (txid, min_depths) in by_txid {
621                    adds.push(Msg::AddTransactions(pb::AddTransactions {
622                        txids: vec![txid.to_vec()],
623                        min_depths,
624                        auto_close_depth: 0,
625                    }));
626                }
627            }
628            if !gone.is_empty() {
629                c.removed += gone.len();
630                let mut by_txid: BTreeMap<Txid, Vec<u32>> = BTreeMap::new();
631                for (t, d) in gone {
632                    by_txid.entry(t).or_default().push(d);
633                }
634                for (txid, min_depths) in by_txid {
635                    removes.push(Msg::RemoveTransactions(pb::RemoveTransactions {
636                        txids: vec![txid.to_vec()],
637                        min_depths,
638                    }));
639                }
640            }
641        }
642
643        // Descriptors: new or changed window → AddDescriptor (server reconciles);
644        // vanished → RemoveDescriptor.
645        {
646            for (d, win) in &target.descriptors {
647                match self.descriptors.get(d) {
648                    Some(old) if old == win => c.unchanged += 1,
649                    _ => {
650                        c.added += 1;
651                        adds.push(Msg::AddDescriptor(pb::AddDescriptor {
652                            descriptor: d.clone(),
653                            gap_limit: win.0,
654                            start: win.1,
655                        }));
656                    }
657                }
658            }
659            for d in self.descriptors.keys() {
660                if !target.descriptors.contains_key(d) {
661                    c.removed += 1;
662                    removes.push(Msg::RemoveDescriptor(pb::RemoveDescriptor { descriptor: d.clone() }));
663                }
664            }
665        }
666
667        // Prefix buckets.
668        {
669            let add: Vec<_> = target.prefixes.difference(&self.prefixes).cloned().collect();
670            let gone: Vec<_> = self.prefixes.difference(&target.prefixes).cloned().collect();
671            c.unchanged += target.prefixes.intersection(&self.prefixes).count();
672            if !add.is_empty() {
673                c.added += add.len();
674                adds.push(Msg::AddScriptPrefixes(pb::AddScriptPrefixes {
675                    prefixes: add.iter().map(|(bits, p)| pb::ScriptPrefix { prefix: p.clone(), bits: *bits }).collect(),
676                }));
677            }
678            if !gone.is_empty() {
679                c.removed += gone.len();
680                removes.push(Msg::RemoveScriptPrefixes(pb::RemoveScriptPrefixes {
681                    prefixes: gone.iter().map(|(bits, p)| pb::ScriptPrefix { prefix: p.clone(), bits: *bits }).collect(),
682                }));
683            }
684        }
685
686        // Silent-payment scan-key targets, keyed by identity `b_scan·G`. New key,
687        // or same key with changed labels/spend key → AddSilentPayments (the
688        // server refreshes labels on re-register); vanished key → Remove.
689        {
690            let mut changed: Vec<pb::SilentPaymentTarget> = Vec::new();
691            for (id, t) in &target.silent_payments {
692                match self.silent_payments.get(id) {
693                    Some(cur)
694                        if cur.labels == t.labels && cur.spend_pubkey == t.spend_pubkey =>
695                    {
696                        c.unchanged += 1;
697                    }
698                    _ => changed.push(t.to_proto()),
699                }
700            }
701            let gone: Vec<Vec<u8>> = self
702                .silent_payments
703                .keys()
704                .filter(|id| !target.silent_payments.contains_key(*id))
705                .map(|id| id.to_vec())
706                .collect();
707            if !changed.is_empty() {
708                c.added += changed.len();
709                adds.push(Msg::AddSilentPayments(pb::AddSilentPayments { targets: changed }));
710            }
711            if !gone.is_empty() {
712                c.removed += gone.len();
713                removes.push(Msg::RemoveSilentPayments(pb::RemoveSilentPayments {
714                    scan_pubkeys: gone,
715                }));
716            }
717        }
718
719        // Order: category filter, then **Remove* before Add***. A removal frees
720        // its quota unit, so a reload that swaps N watches for N disjoint ones
721        // fits even when the tenant is exactly at quota. The opposite order
722        // (Add* first) is a false promise at quota: the server has no per-message
723        // ack and silently drops a quota-over add without closing the stream, so
724        // the net-new adds would be rejected, the removes would then apply, and
725        // the mirror would adopt a target the server never registered — a
726        // permanent silent divergence until the next reconnect. Remove-first
727        // cannot regress coverage of any still-wanted item: `reconcile_to` only
728        // removes items *absent* from the target, and no concrete watch key is
729        // ever both removed and added (removes are self-keys-not-in-target, adds
730        // are target-keys). The lone exception is a single scripthash that
731        // changes *mechanism* across the reload (a direct add becoming
732        // descriptor-covered, or vice versa) — invisible to this key-level diff,
733        // so it briefly unwatches between the RemoveScripts and the AddDescriptor.
734        // That sub-millisecond gap on a rare manual-reload transition is the
735        // deliberate trade for eliminating the permanent-divergence failure.
736        lead.extend(removes);
737        lead.extend(adds);
738        (lead, c)
739    }
740}
741
742/// Item counts from a [`reconcile_to`](WatchSetMirror::reconcile_to) diff.
743#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
744struct ReloadCounts {
745    added: usize,
746    removed: usize,
747    unchanged: usize,
748}
749
750/// The canonical watch-set declared by a [`watch_set_loader`] on every
751/// (re)connect. It is a thin recording facade over a fresh [`WatchSetMirror`]:
752/// each `add_*` / [`set_categories`](Self::set_categories) call records interest
753/// the way the matching [`ResilientWatch`] method would, so the populated set
754/// renders to exactly the same control messages a manual replay would. The
755/// wrapper adopts the builder's set as the mirror once the loader returns, then
756/// re-registers it and re-anchors as usual.
757///
758/// Only declarative `add_*` methods are exposed (no `remove_*`): the loader
759/// builds a complete set from the integrator's source-of-truth into an empty
760/// mirror, where a removal has nothing to act on. Methods are synchronous —
761/// they only record — so a loader does its own `await`ing against its truth
762/// (DB, config, upstream service) between calls.
763///
764/// [`watch_set_loader`]: ResilientWatchConfig::watch_set_loader
765pub struct WatchSetBuilder {
766    mirror: Arc<Mutex<WatchSetMirror>>,
767}
768
769impl WatchSetBuilder {
770    fn with(&self, f: impl FnOnce(&mut WatchSetMirror)) {
771        // The mutex is only contended if a loader clones the builder across
772        // tasks; a poisoned lock means a prior loader panicked mid-build — recover
773        // the inner mirror rather than propagating the panic into the reconnect.
774        let mut m = self.mirror.lock().unwrap_or_else(|p| p.into_inner());
775        f(&mut m);
776    }
777
778    /// Declare scripthashes (each `sha256(scriptPubKey)`) with an optional
779    /// per-script `min_value` floor. See [`ResilientWatch::add_scripts`].
780    pub fn add_scripts(&self, items: impl IntoIterator<Item = (Scripthash, Option<u64>)>) {
781        let items: Vec<_> = items.into_iter().collect();
782        self.with(|m| m.add_scripts(&items));
783    }
784
785    /// Declare outpoints (`txid:vout`). See [`ResilientWatch::add_outpoints`].
786    pub fn add_outpoints(&self, outpoints: impl IntoIterator<Item = (Txid, u32)>) {
787        let items: Vec<_> = outpoints.into_iter().collect();
788        self.with(|m| m.add_outpoints(&items));
789    }
790
791    /// Declare lifecycle watches on txids. See [`ResilientWatch::add_tx_lifecycle`].
792    pub fn add_tx_lifecycle(&self, txids: impl IntoIterator<Item = Txid>, auto_close: AutoClose) {
793        let txids: Vec<_> = txids.into_iter().collect();
794        if txids.is_empty() {
795            return;
796        }
797        self.with(|m| m.add_tx_lifecycle(&txids, auto_close));
798    }
799
800    /// Arm single-shot depth alarms over the cross product of `txids` and
801    /// `depths` (depths `< 1` dropped). See [`ResilientWatch::add_depth_alarms`].
802    pub fn add_depth_alarms(
803        &self,
804        txids: impl IntoIterator<Item = Txid>,
805        depths: impl IntoIterator<Item = u32>,
806    ) {
807        let txids: Vec<_> = txids.into_iter().collect();
808        let depths: Vec<u32> = depths.into_iter().filter(|d| *d >= 1).collect();
809        if txids.is_empty() || depths.is_empty() {
810            return;
811        }
812        let pairs = cross_product(&txids, &depths);
813        self.with(|m| m.add_depth_alarms(&pairs));
814    }
815
816    /// Declare a public output descriptor's `(gap_limit, start)` watch window.
817    /// See [`ResilientWatch::add_descriptor`].
818    pub fn add_descriptor(&self, descriptor: impl Into<String>, gap_limit: u32, start: u32) {
819        let descriptor = descriptor.into();
820        self.with(|m| m.add_descriptor(descriptor, gap_limit, start));
821    }
822
823    /// Declare script-prefix buckets (validated client-side, same as the live
824    /// path). An invalid `(prefix, bits)` aborts the load with
825    /// [`StreamError::InvalidArgument`], which the loader propagates as a
826    /// (retryable) loader failure. See [`ResilientWatch::add_script_prefixes`].
827    pub fn add_script_prefixes(
828        &self,
829        prefixes: impl IntoIterator<Item = (Vec<u8>, u32)>,
830    ) -> Result<(), StreamError> {
831        let validated = validate_prefixes(prefixes)?;
832        if validated.is_empty() {
833            return Ok(());
834        }
835        self.with(|m| m.add_prefixes(&validated));
836        Ok(())
837    }
838
839    /// Declare BIP 352 scan-key watch targets. Each target's identity `b_scan·G`
840    /// is derived once (hence the `bitcoin` feature and the fallible return) and
841    /// recorded so it re-registers on every reconnect — the D3 custody model.
842    /// See [`ResilientWatch::add_silent_payments`].
843    #[cfg(feature = "bitcoin")]
844    #[cfg_attr(docsrs, doc(cfg(feature = "bitcoin")))]
845    pub fn add_silent_payments(
846        &self,
847        targets: impl IntoIterator<Item = SilentPaymentTarget>,
848    ) -> Result<(), StreamError> {
849        let entries = sp_mirror_entries(targets)?;
850        if entries.is_empty() {
851            return Ok(());
852        }
853        self.with(|m| m.add_silent_payments(&entries));
854        Ok(())
855    }
856
857    /// Declare the live category filter. See [`ResilientWatch::set_categories`].
858    pub fn set_categories(&self, categories: u32) {
859        self.with(|m| m.set_categories(categories));
860    }
861
862    /// Declare the raw-tx opt-in. See
863    /// [`ResilientWatch::set_watch_options`]. A loader that wants
864    /// `raw_tx` on ScriptMatched must re-declare this each rebuild, exactly like
865    /// [`set_categories`](Self::set_categories) — the loader is canonical.
866    pub fn set_watch_options(&self, include_raw_tx: bool) {
867        self.with(|m| m.set_watch_options(include_raw_tx));
868    }
869}
870
871/// Knobs for [`StreamClient::resilient_watch`]. Reuses the [`Backoff`] and
872/// [`CursorStore`] from the [`ResilientSubscription`](crate::ResilientSubscription)
873/// resilience layer.
874pub struct ResilientWatchConfig {
875    /// Reconnect (and re-anchor-retry) backoff schedule.
876    pub backoff: Backoff,
877    /// Where the resume cursor is persisted across reconnects and restarts.
878    pub cursor_store: Arc<dyn CursorStore>,
879    /// Initial resume anchor used on the first connect when the store is empty.
880    pub from_cursor: Option<Cursor>,
881    /// Optional canonical watch-set loader. When set, it is run on every
882    /// (re)connect to rebuild the watch-set from the integrator's
883    /// source-of-truth (see [`watch_set_loader`](Self::watch_set_loader)).
884    pub(crate) watch_set_loader: Option<Arc<WatchSetLoaderFn>>,
885}
886
887impl Default for ResilientWatchConfig {
888    fn default() -> Self {
889        ResilientWatchConfig {
890            backoff: Backoff::default(),
891            cursor_store: Arc::new(NoopCursorStore),
892            from_cursor: None,
893            watch_set_loader: None,
894        }
895    }
896}
897
898impl std::fmt::Debug for ResilientWatchConfig {
899    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
900        f.debug_struct("ResilientWatchConfig")
901            .field("backoff", &self.backoff)
902            .field("cursor_store", &"<dyn CursorStore>")
903            .field("from_cursor", &self.from_cursor)
904            .field("watch_set_loader", &self.watch_set_loader.as_ref().map(|_| "<loader>"))
905            .finish()
906    }
907}
908
909impl ResilientWatchConfig {
910    /// Start from the defaults (forever-retry backoff, no persistence, no
911    /// initial cursor).
912    pub fn new() -> Self {
913        Self::default()
914    }
915
916    /// Persist the resume cursor through `store`.
917    pub fn cursor_store(mut self, store: Arc<dyn CursorStore>) -> Self {
918        self.cursor_store = store;
919        self
920    }
921
922    /// Override the reconnect / re-anchor-retry backoff.
923    pub fn backoff(mut self, backoff: Backoff) -> Self {
924        self.backoff = backoff;
925        self
926    }
927
928    /// Seed the first connect's resume anchor (used only when the store has no
929    /// persisted cursor).
930    pub fn from_cursor(mut self, cursor: Cursor) -> Self {
931        self.from_cursor = Some(cursor);
932        self
933    }
934
935    /// Install a canonical watch-set loader for integrators whose watch-set has
936    /// a durable source-of-truth (a DB, a config file, an upstream service)
937    /// outside the wrapper.
938    ///
939    /// Without a loader, [`ResilientWatch`] re-registers its in-memory mirror of
940    /// the `add_*` / `remove_*` calls made through it — correct when the
941    /// watch-set is built once at startup and never drifts, but the mirror is
942    /// empty after a process restart and goes stale if the truth changes while
943    /// the stream is down. With a loader, the mirror is treated as a *cache* of
944    /// the external truth:
945    ///
946    /// - The loader runs once after **every** successful (re)connect, **before**
947    ///   the consumer's event stream resumes — so the first events pumped after
948    ///   a reconnect already land on a fully-populated subscription. It receives
949    ///   a fresh [`WatchSetBuilder`] and declares the canonical set into it
950    ///   (typically by querying its truth with its own `await`s between calls).
951    /// - On return, the builder's set **replaces** the mirror: it is canonical.
952    ///   In-process `add_*` / `remove_*` calls still mutate the mirror and send
953    ///   live, but the next reconnect re-derives the set from the loader, so the
954    ///   integrator's truth — not the accumulated in-process edits — is the
955    ///   record across reconnects (this is what closes the restart-loses-mirror
956    ///   and truth-drift-during-disconnect gaps).
957    /// - A loader error is **transient**: it maps to
958    ///   [`StreamError::WatchSetLoader`], which the reconnect loop backs off and
959    ///   retries on the next connect rather than surfacing — a momentary failure
960    ///   of the integrator's truth must not crash a consumer whose contract is
961    ///   at-least-once. A *permanent* loader error (a config typo, a closure that
962    ///   always fails) is indistinguishable from a transient one and so is retried
963    ///   indefinitely: with the default backoff (`max_retries: None`) the stream
964    ///   never resumes and [`next`](ResilientWatch::next) simply never yields.
965    ///   Set [`Backoff::max_retries`](crate::Backoff::max_retries) if you need a
966    ///   permanently-failing loader to surface a terminal error instead of
967    ///   retrying forever. The loader should return `Err` rather than panic — a
968    ///   panic unwinds through the reconnect and aborts the current `next()`; it
969    ///   is not caught.
970    ///
971    /// The resume cursor is independent of the watch-set: it still comes from the
972    /// [`cursor_store`](Self::cursor_store) / [`from_cursor`](Self::from_cursor)
973    /// and the re-anchor runs after the loaded set is registered, exactly as
974    /// without a loader.
975    ///
976    /// ```no_run
977    /// # use satd_events_client::{ResilientWatchConfig, WatchSetBuilder};
978    /// # async fn rows() -> Result<Vec<([u8; 32], Option<u64>)>, Box<dyn std::error::Error + Send + Sync>> { Ok(vec![]) }
979    /// let config = ResilientWatchConfig::new().watch_set_loader(|builder: WatchSetBuilder| async move {
980    ///     // Query the durable source-of-truth and declare the canonical set.
981    ///     let scripts = rows().await?;
982    ///     builder.add_scripts(scripts);
983    ///     Ok(())
984    /// });
985    /// # let _ = config;
986    /// ```
987    pub fn watch_set_loader<F, Fut>(mut self, loader: F) -> Self
988    where
989        F: Fn(WatchSetBuilder) -> Fut + Send + Sync + 'static,
990        Fut: Future<Output = Result<(), BoxError>> + Send + 'static,
991    {
992        self.watch_set_loader = Some(Arc::new(move |b| Box::pin(loader(b))));
993        self
994    }
995}
996
997/// The outcome of [`ResilientWatch::reload`].
998#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
999pub struct ReloadSummary {
1000    /// Watch items added or changed (a changed floor / descriptor window / tx
1001    /// auto-close counts as added).
1002    pub added: usize,
1003    /// Watch items removed.
1004    pub removed: usize,
1005    /// Watch items present and identical in both the old and reloaded set.
1006    pub unchanged: usize,
1007    /// Whether the delta was sent on the live stream now (`true`), or deferred to
1008    /// the next reconnect's loader because the stream was down — or dropped
1009    /// mid-reload (`false`). When `false` the mirror still reflects the reloaded
1010    /// set, so the pending reconnect re-registers it from the same loader.
1011    pub applied: bool,
1012}
1013
1014/// Why a [`ResilientWatch::reload`] could not run.
1015#[derive(Debug, thiserror::Error)]
1016pub enum ReloadError {
1017    /// `reload` requires a [`watch_set_loader`](ResilientWatchConfig::watch_set_loader);
1018    /// none was configured.
1019    #[error("reload requires a watch_set_loader, but none is configured")]
1020    NoLoader,
1021    /// The loader itself returned an error. Unlike a connect-time loader failure
1022    /// (which is retried on reconnect), an explicit `reload` surfaces it so the
1023    /// caller decides whether to retry.
1024    #[error("watch-set loader failed: {0}")]
1025    Loader(#[source] BoxError),
1026}
1027
1028/// A transient-reject re-anchor retry deferred out of [`handle_event`] so it can
1029/// be driven — and cancelled — safely from [`ResilientWatch::next`]. Recording
1030/// the retry as data (rather than sleeping + re-sending inline) is what makes
1031/// `next` cancel-safe: the budget counter is charged once when the retry is
1032/// recorded, and the pending slot is only cleared after the re-send has been
1033/// attempted, so a cancelled `next` re-enters and completes the *same* retry
1034/// instead of burning the counter without the re-send reaching the wire.
1035///
1036/// Only the backoff *deadline* is captured — the drive re-sends the *live*
1037/// `desired_cursor`, so an intervening [`set_cursor`](ResilientWatch::set_cursor)
1038/// (or a connect that re-anchors from `resume`) is honoured rather than
1039/// clobbered by a stale snapshot.
1040///
1041/// [`handle_event`]: ResilientWatch::handle_event
1042#[derive(Clone, Copy)]
1043struct PendingReanchor {
1044    /// When the backoff sleep ends. Stored as an absolute deadline (not a
1045    /// duration) so a cancelled-then-resumed drive waits out the *remaining*
1046    /// backoff rather than restarting it.
1047    deadline: tokio::time::Instant,
1048}
1049
1050/// An add the server refused for its rate limit, waiting to be re-sent from the
1051/// mirror (see [`ResilientWatch::handle_event`]). Kept in `self` so a cancelled
1052/// [`next`](ResilientWatch::next) resumes it.
1053struct PendingAddRetry {
1054    /// When the retry is due: the server's `retry_after_secs` or the backoff
1055    /// delay for its attempt, whichever is later.
1056    deadline: tokio::time::Instant,
1057    /// The refusal, naming what to re-send.
1058    rejected: WatchAddRejected,
1059}
1060
1061/// One identity per item a refusal names, for the per-item retry budget. The
1062/// first byte is the kind, so items of different kinds never collide.
1063fn retry_keys(r: &WatchAddRejected) -> Vec<Vec<u8>> {
1064    let tagged = |tag: u8, bytes: &[u8]| {
1065        let mut k = Vec::with_capacity(1 + bytes.len());
1066        k.push(tag);
1067        k.extend_from_slice(bytes);
1068        k
1069    };
1070    let mut keys = Vec::new();
1071    keys.extend(r.scripthashes.iter().map(|b| tagged(1, b)));
1072    keys.extend(r.outpoints.iter().map(|(t, v)| tagged(2, &[t.as_slice(), &v.to_be_bytes()].concat())));
1073    keys.extend(r.txids.iter().map(|b| tagged(3, b)));
1074    keys.extend(r.depth_alarms.iter().map(|(t, d)| tagged(4, &[t.as_slice(), &d.to_be_bytes()].concat())));
1075    keys.extend(r.descriptor.iter().map(|d| tagged(5, d.descriptor.as_bytes())));
1076    keys.extend(r.prefixes.iter().map(|(p, bits)| tagged(6, &[p.as_slice(), &bits.to_be_bytes()].concat())));
1077    keys.extend(r.scan_pubkeys.iter().map(|b| tagged(7, b)));
1078    keys
1079}
1080
1081/// A `Watch` stream that reconnects, re-registers its watch-set, and re-anchors
1082/// off the deterministic [`Event::CursorAccepted`] / [`Event::CursorRejected`]
1083/// results on the caller's behalf.
1084///
1085/// Construct it with [`StreamClient::resilient_watch`]. Register interest with
1086/// the typed `add_*` / `remove_*` / [`set_categories`](Self::set_categories)
1087/// methods (which both update the mirror and, while connected, send live), then
1088/// drive it by calling [`next`](Self::next) in a loop. The model is
1089/// single-task, like [`ResilientSubscription`](crate::ResilientSubscription):
1090/// interleave watch-set edits with [`next`](Self::next) calls from one task
1091/// (the [`descriptor_wallet`] usage shape — react to a match, then adjust the
1092/// watch-set). When the watch-set has a durable source-of-truth, configure a
1093/// [`watch_set_loader`](ResilientWatchConfig::watch_set_loader) and call
1094/// [`reload`](Self::reload) to realign the live stream with that truth on demand.
1095///
1096/// [`descriptor_wallet`]: https://github.com/epochbtc/satd/blob/master/satd-events-client/examples/descriptor_wallet.rs
1097pub struct ResilientWatch {
1098    client: StreamClient,
1099    config: ResilientWatchConfig,
1100    mirror: WatchSetMirror,
1101    /// Live control handle + event stream when connected; `None` when between
1102    /// connections (edits land in the mirror and replay on the next connect).
1103    handle: Option<WatchHandle>,
1104    stream: Option<EventStream>,
1105    /// Confirmed high-water cursor; the anchor a (re)connect re-anchors to.
1106    resume: Option<Cursor>,
1107    /// The cursor most recently requested via `set_cursor` (or the connect-time
1108    /// re-anchor), re-sent if a transient reject asks us to retry.
1109    desired_cursor: Option<Cursor>,
1110    /// High-water armed for commit on the next poll (commit-on-poll).
1111    commit_next: Option<Cursor>,
1112    /// The cursor last written to the store (skips redundant writes).
1113    committed: Option<Cursor>,
1114    /// Whether `seed_resume` has run (resume may legitimately stay `None`).
1115    seeded: bool,
1116    /// Consecutive reconnects that produced no event; drives backoff + give-up.
1117    reconnect_attempts: u32,
1118    /// Consecutive transient re-anchor rejects; drives the in-place retry backoff.
1119    reanchor_attempts: u32,
1120    /// A deferred transient-reject retry awaiting its backoff + re-send, driven by
1121    /// [`next`](Self::next). `Some` between recording the retry (in `handle_event`)
1122    /// and completing it; keeping it in `self` rather than on `next`'s stack is
1123    /// what lets a cancelled `next` resume the retry (see [`PendingReanchor`]).
1124    pending_reanchor: Option<PendingReanchor>,
1125    /// Rate-limited adds awaiting their re-send, driven by [`next`](Self::next).
1126    pending_add_retries: Vec<PendingAddRetry>,
1127    /// Retries spent per refused item ([`retry_keys`]), against the backoff
1128    /// budget. Cleared on reconnect, which re-sends the whole mirror anyway.
1129    add_retry_attempts: HashMap<Vec<u8>, u32>,
1130    /// The most recent retryable error, surfaced if `max_retries` is exhausted.
1131    last_error: Option<StreamError>,
1132}
1133
1134impl ResilientWatch {
1135    pub(crate) fn new(client: StreamClient, config: ResilientWatchConfig) -> Self {
1136        ResilientWatch {
1137            client,
1138            config,
1139            mirror: WatchSetMirror::default(),
1140            handle: None,
1141            stream: None,
1142            resume: None,
1143            desired_cursor: None,
1144            commit_next: None,
1145            committed: None,
1146            seeded: false,
1147            reconnect_attempts: 0,
1148            reanchor_attempts: 0,
1149            pending_reanchor: None,
1150            pending_add_retries: Vec::new(),
1151            add_retry_attempts: HashMap::new(),
1152            last_error: None,
1153        }
1154    }
1155
1156    // --- watch-set registration (mirror + live send) --------------------------
1157
1158    /// Add scripthashes (each `sha256(scriptPubKey)`) with an optional per-script
1159    /// `min_value` floor. See [`WatchHandle::add_scripts`].
1160    pub async fn add_scripts(
1161        &mut self,
1162        items: impl IntoIterator<Item = (Scripthash, Option<u64>)>,
1163    ) -> Result<(), StreamError> {
1164        let items: Vec<_> = items.into_iter().collect();
1165        if items.is_empty() {
1166            return Ok(());
1167        }
1168        self.mirror.add_scripts(&items);
1169        let res = match &self.handle {
1170            Some(h) => Some(h.add_scripts(items).await),
1171            None => None,
1172        };
1173        self.after_send(res)
1174    }
1175
1176    /// Remove scripthashes from the watch-set. See [`WatchHandle::remove_scripts`].
1177    pub async fn remove_scripts(
1178        &mut self,
1179        hashes: impl IntoIterator<Item = Scripthash>,
1180    ) -> Result<(), StreamError> {
1181        let hashes: Vec<_> = hashes.into_iter().collect();
1182        if hashes.is_empty() {
1183            return Ok(());
1184        }
1185        self.mirror.remove_scripts(&hashes);
1186        let res = match &self.handle {
1187            Some(h) => Some(h.remove_scripts(hashes).await),
1188            None => None,
1189        };
1190        self.after_send(res)
1191    }
1192
1193    /// Add outpoints (`txid:vout`). See [`WatchHandle::add_outpoints`].
1194    pub async fn add_outpoints(
1195        &mut self,
1196        outpoints: impl IntoIterator<Item = (Txid, u32)>,
1197    ) -> Result<(), StreamError> {
1198        let items: Vec<_> = outpoints.into_iter().collect();
1199        if items.is_empty() {
1200            return Ok(());
1201        }
1202        self.mirror.add_outpoints(&items);
1203        let res = match &self.handle {
1204            Some(h) => Some(h.add_outpoints(items).await),
1205            None => None,
1206        };
1207        self.after_send(res)
1208    }
1209
1210    /// Remove outpoints. See [`WatchHandle::remove_outpoints`].
1211    pub async fn remove_outpoints(
1212        &mut self,
1213        outpoints: impl IntoIterator<Item = (Txid, u32)>,
1214    ) -> Result<(), StreamError> {
1215        let items: Vec<_> = outpoints.into_iter().collect();
1216        if items.is_empty() {
1217            return Ok(());
1218        }
1219        self.mirror.remove_outpoints(&items);
1220        let res = match &self.handle {
1221            Some(h) => Some(h.remove_outpoints(items).await),
1222            None => None,
1223        };
1224        self.after_send(res)
1225    }
1226
1227    /// Add lifecycle watches on txids. See [`WatchHandle::add_tx_lifecycle`].
1228    pub async fn add_tx_lifecycle(
1229        &mut self,
1230        txids: impl IntoIterator<Item = Txid>,
1231        auto_close: AutoClose,
1232    ) -> Result<(), StreamError> {
1233        let txids: Vec<_> = txids.into_iter().collect();
1234        if txids.is_empty() {
1235            return Ok(());
1236        }
1237        self.mirror.add_tx_lifecycle(&txids, auto_close);
1238        let res = match &self.handle {
1239            Some(h) => Some(h.add_tx_lifecycle(txids, auto_close).await),
1240            None => None,
1241        };
1242        self.after_send(res)
1243    }
1244
1245    /// Remove lifecycle watches. See [`WatchHandle::remove_tx_lifecycle`].
1246    pub async fn remove_tx_lifecycle(
1247        &mut self,
1248        txids: impl IntoIterator<Item = Txid>,
1249    ) -> Result<(), StreamError> {
1250        let txids: Vec<_> = txids.into_iter().collect();
1251        if txids.is_empty() {
1252            return Ok(());
1253        }
1254        self.mirror.remove_tx_lifecycle(&txids);
1255        let res = match &self.handle {
1256            Some(h) => Some(h.remove_tx_lifecycle(txids).await),
1257            None => None,
1258        };
1259        self.after_send(res)
1260    }
1261
1262    /// Arm single-shot depth alarms over the cross product of `txids` and
1263    /// `depths` (depths `< 1` dropped). See [`WatchHandle::add_depth_alarms`].
1264    pub async fn add_depth_alarms(
1265        &mut self,
1266        txids: impl IntoIterator<Item = Txid>,
1267        depths: impl IntoIterator<Item = u32>,
1268    ) -> Result<(), StreamError> {
1269        let txids: Vec<_> = txids.into_iter().collect();
1270        let depths: Vec<u32> = depths.into_iter().filter(|d| *d >= 1).collect();
1271        if txids.is_empty() || depths.is_empty() {
1272            return Ok(());
1273        }
1274        let pairs = cross_product(&txids, &depths);
1275        self.mirror.add_depth_alarms(&pairs);
1276        let res = match &self.handle {
1277            Some(h) => Some(h.add_depth_alarms(txids, depths).await),
1278            None => None,
1279        };
1280        self.after_send(res)
1281    }
1282
1283    /// Remove depth alarms over the cross product of `txids` and `depths`.
1284    /// See [`WatchHandle::remove_depth_alarms`].
1285    pub async fn remove_depth_alarms(
1286        &mut self,
1287        txids: impl IntoIterator<Item = Txid>,
1288        depths: impl IntoIterator<Item = u32>,
1289    ) -> Result<(), StreamError> {
1290        let txids: Vec<_> = txids.into_iter().collect();
1291        let depths: Vec<u32> = depths.into_iter().filter(|d| *d >= 1).collect();
1292        if txids.is_empty() || depths.is_empty() {
1293            return Ok(());
1294        }
1295        let pairs = cross_product(&txids, &depths);
1296        self.mirror.remove_depth_alarms(&pairs);
1297        let res = match &self.handle {
1298            Some(h) => Some(h.remove_depth_alarms(txids, depths).await),
1299            None => None,
1300        };
1301        self.after_send(res)
1302    }
1303
1304    /// Register a public output descriptor's watch window. The latest
1305    /// `(gap_limit, start)` per descriptor is what replays on reconnect — advance
1306    /// `start` to slide the window (the server reconciles the slid window). See
1307    /// [`WatchHandle::add_descriptor`].
1308    pub async fn add_descriptor(
1309        &mut self,
1310        descriptor: impl Into<String>,
1311        gap_limit: u32,
1312        start: u32,
1313    ) -> Result<(), StreamError> {
1314        let descriptor = descriptor.into();
1315        self.mirror.add_descriptor(descriptor.clone(), gap_limit, start);
1316        let res = match &self.handle {
1317            Some(h) => Some(h.add_descriptor(descriptor, gap_limit, start).await),
1318            None => None,
1319        };
1320        self.after_send(res)
1321    }
1322
1323    /// Remove a descriptor previously added with [`add_descriptor`](Self::add_descriptor),
1324    /// dropping it from the mirror so it is not replayed on reconnect and (while
1325    /// connected) releasing the scripthashes its window contributed whose last
1326    /// owner this drops. See [`WatchHandle::remove_descriptor`].
1327    pub async fn remove_descriptor(
1328        &mut self,
1329        descriptor: impl Into<String>,
1330    ) -> Result<(), StreamError> {
1331        let descriptor = descriptor.into();
1332        self.mirror.remove_descriptor(&descriptor);
1333        let res = match &self.handle {
1334            Some(h) => Some(h.remove_descriptor(descriptor).await),
1335            None => None,
1336        };
1337        self.after_send(res)
1338    }
1339
1340    /// Add script-prefix buckets (validated client-side). See
1341    /// [`WatchHandle::add_script_prefixes`].
1342    pub async fn add_script_prefixes(
1343        &mut self,
1344        prefixes: impl IntoIterator<Item = (Vec<u8>, u32)>,
1345    ) -> Result<(), StreamError> {
1346        let validated = validate_prefixes(prefixes)?;
1347        if validated.is_empty() {
1348            return Ok(());
1349        }
1350        self.mirror.add_prefixes(&validated);
1351        let res = match &self.handle {
1352            Some(h) => Some(
1353                h.send_control(pb::SubscribeControl {
1354                    msg: Some(Msg::AddScriptPrefixes(pb::AddScriptPrefixes {
1355                        prefixes: validated,
1356                    })),
1357                })
1358                .await,
1359            ),
1360            None => None,
1361        };
1362        self.after_send(res)
1363    }
1364
1365    /// Remove script-prefix buckets. See [`WatchHandle::remove_script_prefixes`].
1366    pub async fn remove_script_prefixes(
1367        &mut self,
1368        prefixes: impl IntoIterator<Item = (Vec<u8>, u32)>,
1369    ) -> Result<(), StreamError> {
1370        let validated = validate_prefixes(prefixes)?;
1371        if validated.is_empty() {
1372            return Ok(());
1373        }
1374        self.mirror.remove_prefixes(&validated);
1375        let res = match &self.handle {
1376            Some(h) => Some(
1377                h.send_control(pb::SubscribeControl {
1378                    msg: Some(Msg::RemoveScriptPrefixes(pb::RemoveScriptPrefixes {
1379                        prefixes: validated,
1380                    })),
1381                })
1382                .await,
1383            ),
1384            None => None,
1385        };
1386        self.after_send(res)
1387    }
1388
1389    /// Register BIP 352 scan-key watch targets (Tier 2). Recorded in the mirror
1390    /// and **re-registered on every reconnect** — the scan key is disclosed anew
1391    /// each time and never persisted server-side, which is exactly the intended
1392    /// custody model. Matches arrive as
1393    /// [`Event::SilentPaymentMatched`](crate::Event::SilentPaymentMatched).
1394    /// Requires the `bitcoin` feature (default) to derive each target's identity.
1395    /// See [`WatchHandle::add_silent_payments`].
1396    #[cfg(feature = "bitcoin")]
1397    #[cfg_attr(docsrs, doc(cfg(feature = "bitcoin")))]
1398    pub async fn add_silent_payments(
1399        &mut self,
1400        targets: impl IntoIterator<Item = SilentPaymentTarget>,
1401    ) -> Result<(), StreamError> {
1402        let entries = sp_mirror_entries(targets)?;
1403        if entries.is_empty() {
1404            return Ok(());
1405        }
1406        // Mirror the server's per-connection target cap. Count only NET-NEW
1407        // identities (re-asserting a held identity to refresh its labels is free,
1408        // exactly as the carrier reconciles); reject an add that would exceed the
1409        // cap instead of letting the server silently shed it — which the mirror
1410        // would otherwise record and replay on every reconnect, forever.
1411        let held = self.mirror.silent_payments.len();
1412        let net_new = entries
1413            .iter()
1414            .filter(|(id, _)| !self.mirror.silent_payments.contains_key(id))
1415            .count();
1416        if held + net_new > crate::client::MAX_SP_TARGETS_PER_CONNECTION {
1417            return Err(StreamError::InvalidArgument(format!(
1418                "silent-payment target cap exceeded: {held} held + {net_new} new > {}",
1419                crate::client::MAX_SP_TARGETS_PER_CONNECTION
1420            )));
1421        }
1422        self.mirror.add_silent_payments(&entries);
1423        let targets: Vec<pb::SilentPaymentTarget> =
1424            entries.iter().map(|(_, t)| t.to_proto()).collect();
1425        let res = match &self.handle {
1426            Some(h) => Some(
1427                h.send_control(pb::SubscribeControl {
1428                    msg: Some(Msg::AddSilentPayments(pb::AddSilentPayments { targets })),
1429                })
1430                .await,
1431            ),
1432            None => None,
1433        };
1434        self.after_send(res)
1435    }
1436
1437    /// Remove scan-key watch targets by their identity `b_scan·G` (33 bytes),
1438    /// releasing quota. See [`WatchHandle::remove_silent_payments`].
1439    pub async fn remove_silent_payments(
1440        &mut self,
1441        scan_pubkeys: impl IntoIterator<Item = [u8; 33]>,
1442    ) -> Result<(), StreamError> {
1443        let scan_pubkeys: Vec<[u8; 33]> = scan_pubkeys.into_iter().collect();
1444        if scan_pubkeys.is_empty() {
1445            return Ok(());
1446        }
1447        self.mirror.remove_silent_payments(&scan_pubkeys);
1448        let res = match &self.handle {
1449            Some(h) => Some(
1450                h.send_control(pb::SubscribeControl {
1451                    msg: Some(Msg::RemoveSilentPayments(pb::RemoveSilentPayments {
1452                        scan_pubkeys: scan_pubkeys.iter().map(|k| k.to_vec()).collect(),
1453                    })),
1454                })
1455                .await,
1456            ),
1457            None => None,
1458        };
1459        self.after_send(res)
1460    }
1461
1462    /// Adjust the live firehose category bitfield (see
1463    /// [`Categories`](crate::Categories)). Replayed on reconnect.
1464    pub async fn set_categories(&mut self, categories: u32) -> Result<(), StreamError> {
1465        self.mirror.set_categories(categories);
1466        let res = match &self.handle {
1467            Some(h) => Some(h.set_categories(categories).await),
1468            None => None,
1469        };
1470        self.after_send(res)
1471    }
1472
1473    /// Set the per-stream raw-tx opt-in (see
1474    /// [`StreamControls::set_watch_options`](crate::WatchControls::set_watch_options)).
1475    /// With `include_raw_tx = true`, [`Event::ScriptMatched`](crate::Event::ScriptMatched)
1476    /// carry the full serialized tx in `raw_tx`. Recorded in the mirror and
1477    /// re-applied on every reconnect, so the opt-in survives a stream tear-down.
1478    pub async fn set_watch_options(&mut self, include_raw_tx: bool) -> Result<(), StreamError> {
1479        self.mirror.set_watch_options(include_raw_tx);
1480        let res = match &self.handle {
1481            Some(h) => Some(h.set_watch_options(include_raw_tx).await),
1482            None => None,
1483        };
1484        self.after_send(res)
1485    }
1486
1487    /// Request a mid-stream re-anchor to `cursor` (replay confirmed history from
1488    /// it). The outcome arrives in-band on [`next`](Self::next) as
1489    /// [`Event::CursorAccepted`] / [`Event::CursorRejected`]; transient rejects
1490    /// are retried in place automatically.
1491    pub async fn set_cursor(&mut self, cursor: Cursor) -> Result<(), StreamError> {
1492        self.desired_cursor = Some(cursor);
1493        self.reanchor_attempts = 0;
1494        // This live send is the fresh re-anchor; it supersedes any deferred
1495        // transient-reject retry, so drop the pending slot rather than let `next`
1496        // fire a redundant second `set_cursor` for the same anchor afterwards.
1497        self.pending_reanchor = None;
1498        let res = match &self.handle {
1499            Some(h) => Some(h.set_cursor(cursor).await),
1500            None => None,
1501        };
1502        self.after_send(res)
1503    }
1504
1505    /// Request a bounded historical rescan of the current watch-set over the
1506    /// inclusive height range `[from_height, to_height]` (see
1507    /// [`WatchHandle::rescan`](crate::WatchHandle::rescan)). The ack and any
1508    /// matches arrive in-band on [`next`](Self::next): [`Event::RescanAccepted`]
1509    /// → confirmed matches (height order) → [`Event::RescanComplete`], or
1510    /// [`Event::RescanRejected`].
1511    ///
1512    /// Unlike [`set_cursor`](Self::set_cursor), a rescan touches **no** resilient
1513    /// state: it is a side query, orthogonal to the watch-set mirror and the
1514    /// resume cursor, so it is neither replayed on reconnect nor retried in
1515    /// place. If the stream drops mid-rescan, re-issue it after reconnect. With
1516    /// the stream currently down this is a no-op returning `Ok(())`.
1517    pub async fn rescan(&mut self, from_height: u32, to_height: u32) -> Result<(), StreamError> {
1518        let res = match &self.handle {
1519            Some(h) => Some(h.rescan(from_height, to_height).await),
1520            None => None,
1521        };
1522        self.after_send(res)
1523    }
1524
1525    /// Re-run the configured [`watch_set_loader`](ResilientWatchConfig::watch_set_loader),
1526    /// diff the freshly-loaded canonical set against the live watch-set, and apply
1527    /// **only the delta** on the wire — realigning the subscription with the
1528    /// integrator's source-of-truth without dropping and rebuilding the stream
1529    /// (which would throw away the backoff / cursor re-anchor plumbing).
1530    ///
1531    /// This is the on-demand companion to the connect-time loader (which fires on
1532    /// every reconnect, [`watch_set_loader`](ResilientWatchConfig::watch_set_loader)):
1533    /// use it when the durable truth changes *while the stream is up* and you want
1534    /// it pushed now — a bulk import or migration that wrote truth outside the
1535    /// hot-add path, an admin/external rotation, or an operator "make the wire
1536    /// match truth" reconciliation.
1537    ///
1538    /// Semantics:
1539    ///
1540    /// - **Diff, not blast.** Only added/changed/removed items are sent;
1541    ///   identical items are untouched. `Remove*` / `RemoveDescriptor` lead so a
1542    ///   removal frees its quota unit before the replacement `Add*` claims it —
1543    ///   which is what lets a swap of N watches for N disjoint ones apply cleanly
1544    ///   even when the tenant is exactly at quota (the server silently drops a
1545    ///   quota-over add, so an add-first order would leave the mirror claiming
1546    ///   watches the server never registered). A descriptor whose window changed
1547    ///   is re-asserted (the server reconciles); a vanished descriptor is dropped
1548    ///   with `RemoveDescriptor`.
1549    /// - **Atomic w.r.t. concurrent edits.** `&mut self` serializes `reload`
1550    ///   against `add_*` / `remove_*` / [`next`](Self::next), so no hot-add can
1551    ///   interleave with the diff.
1552    /// - **The reloaded set becomes the mirror.** Subsequent reconnects replay it
1553    ///   (and re-run the loader), exactly as for the connect-time path.
1554    /// - **Disconnected (or dropped mid-reload) defers, never errors.** With the
1555    ///   stream down there is nothing to apply now; the mirror is still updated to
1556    ///   the reloaded set and the next reconnect's loader re-registers it from
1557    ///   truth. [`ReloadSummary::applied`] reports which happened.
1558    ///
1559    /// Returns [`ReloadError::NoLoader`] if no loader is configured, or
1560    /// [`ReloadError::Loader`] if the loader itself fails (surfaced, not retried —
1561    /// an explicit `reload` lets the caller decide).
1562    pub async fn reload(&mut self) -> Result<ReloadSummary, ReloadError> {
1563        let loader = self.config.watch_set_loader.clone().ok_or(ReloadError::NoLoader)?;
1564        // Build the canonical set from the integrator's truth into a fresh mirror.
1565        let shared = Arc::new(Mutex::new(WatchSetMirror::default()));
1566        loader(WatchSetBuilder { mirror: shared.clone() })
1567            .await
1568            .map_err(ReloadError::Loader)?;
1569        let loaded = std::mem::take(&mut *shared.lock().unwrap_or_else(|p| p.into_inner()));
1570
1571        // Counts (added/removed/unchanged) for the summary — advisory; the
1572        // server's WatchSetResult carries the authoritative counts by effective
1573        // coverage. The delta messages are discarded: reload no longer applies a
1574        // client-computed Add*/Remove* delta (whose ordering could strand
1575        // coverage or over-charge at quota — the Rounds 2/3 failures). It sends
1576        // ONE SetWatchSet and lets the server reconcile atomically under its lock.
1577        let (_delta, counts) = self.mirror.reconcile_to(&loaded);
1578
1579        // Push the whole desired set as a single atomic replace if connected; a
1580        // send failure means the stream is gone, so defer to the reconnect loader
1581        // rather than erroring. The deterministic WatchSetResult
1582        // (accepted/rejected) arrives in-band on [`next`](Self::next).
1583        let mut applied = false;
1584        if let Some(h) = &self.handle {
1585            let msg = Msg::SetWatchSet(loaded.to_set_watch_set());
1586            let set_res = h.send_control(pb::SubscribeControl { msg: Some(msg) }).await;
1587            // SetWatchSet carries the category filter but NOT the raw-tx opt-in,
1588            // so reconcile it separately — and in BOTH directions. The reconnect
1589            // path renders it via `control_messages` on a fresh (default-off)
1590            // stream, but this atomic-replace path runs against a LIVE stream: if
1591            // the reloaded truth drops an opt-in the stream still has, we must
1592            // explicitly turn it off, or the server keeps serializing full txs
1593            // against the reloaded set until an incidental reconnect. Compare the
1594            // pre-reload mirror's effective value (None == off) to truth and send
1595            // only on a real change. Both awaits complete while `h` is borrowed;
1596            // `teardown` (needs `&mut self`) runs only after.
1597            let old_raw_tx = self.mirror.include_raw_tx.unwrap_or(false);
1598            let new_raw_tx = loaded.include_raw_tx.unwrap_or(false);
1599            let opt_res = match &set_res {
1600                Ok(()) if old_raw_tx != new_raw_tx => Some(h.set_watch_options(new_raw_tx).await),
1601                _ => None,
1602            };
1603            if set_res.is_ok() && !matches!(opt_res, Some(Err(_))) {
1604                applied = true;
1605            } else {
1606                self.teardown();
1607            }
1608        }
1609
1610        // Adopt the reloaded set as the canonical mirror regardless: a deferred
1611        // reload is rebuilt from the same loader on the next reconnect.
1612        self.mirror = loaded;
1613
1614        Ok(ReloadSummary {
1615            added: counts.added,
1616            removed: counts.removed,
1617            unchanged: counts.unchanged,
1618            applied,
1619        })
1620    }
1621
1622    // --- driving the stream ---------------------------------------------------
1623
1624    /// The resume cursor the next reconnect would re-anchor to.
1625    pub fn resume_cursor(&self) -> Option<&Cursor> {
1626        self.resume.as_ref()
1627    }
1628
1629    /// Persist the most-recently-delivered event's cursor now (rather than on the
1630    /// implicit ack at the next [`next`](Self::next)). Call before a clean
1631    /// shutdown. Idempotent; a failing store `save` is surfaced.
1632    pub fn commit(&mut self) -> Result<(), StreamError> {
1633        self.commit_due()
1634    }
1635
1636    /// Yield the next event, reconnecting, re-registering the watch-set, and
1637    /// re-anchoring underneath as needed.
1638    ///
1639    /// Loops internally: a transport failure becomes a backoff + reconnect +
1640    /// watch-set replay + `set_cursor` re-anchor, a transient re-anchor reject
1641    /// becomes a backoff + in-place retry, and only a real event (including the
1642    /// deterministic `CursorAccepted` / terminal `CursorRejected`, which the
1643    /// caller may act on) returns.
1644    ///
1645    /// # Cancel safety
1646    ///
1647    /// `next` is cancel-safe: dropping the future — e.g. losing a
1648    /// `tokio::select!` race to a command arriving on another branch — never
1649    /// leaves `self` holding a charged-but-unsent retry or a half-applied cursor,
1650    /// so it is always safe to call `next` again afterwards. An interleaving
1651    /// caller (an actor that drives `next` in a `select!` alongside a command
1652    /// channel) can therefore cancel it freely.
1653    ///
1654    /// It suspends at four kinds of await, each of which a cancel leaves either
1655    /// resumable or idempotent on re-entry:
1656    ///
1657    /// - The reconnect **backoff sleep** holds no uncommitted state — the
1658    ///   `reconnect_attempts` bump happened on a prior iteration, so a cancel just
1659    ///   re-sleeps the (recomputed) delay next time.
1660    /// - **`connect()`** only commits the new `handle` / `stream` at its very end;
1661    ///   a cancel partway drops the half-open stream and re-enters `connect` from
1662    ///   scratch, which fully rebuilds from `resume` (the resume/commit state it
1663    ///   touches is either unchanged or re-derived on the retry).
1664    /// - The transport **`message()` poll** is cancel-safe (its decoder state
1665    ///   lives on the retained `stream`, not on the dropped future).
1666    /// - The deferred transient-reject **backoff + re-send** is recorded as
1667    ///   internal state and driven from here, so a cancel mid-backoff or
1668    ///   mid-re-send resumes the *same* retry on the next call — with its budget
1669    ///   charged exactly once — rather than burning the counter without the
1670    ///   re-send reaching the wire.
1671    pub async fn next(&mut self) -> Result<Event, StreamError> {
1672        self.commit_due()?;
1673        loop {
1674            // Finish any deferred transient-reject retry before touching the
1675            // stream. Resumable and cancel-safe (see `drive_pending_reanchor`).
1676            if self.pending_reanchor.is_some() {
1677                self.drive_pending_reanchor().await?;
1678                continue;
1679            }
1680            if self.stream.is_none() {
1681                if self.reconnect_attempts > 0 {
1682                    if let Some(max) = self.config.backoff.max_retries
1683                        && self.reconnect_attempts > max
1684                    {
1685                        return Err(self.last_error.take().unwrap_or(StreamError::ControlClosed));
1686                    }
1687                    let delay = self.config.backoff.delay_for(self.reconnect_attempts - 1);
1688                    tokio::time::sleep(delay).await;
1689                }
1690                match self.connect().await {
1691                    Ok(()) => {}
1692                    Err(e) if is_reconnectable(&e) => {
1693                        self.teardown();
1694                        self.reconnect_attempts = self.reconnect_attempts.saturating_add(1);
1695                        self.last_error = Some(e);
1696                        continue;
1697                    }
1698                    Err(e) => return Err(e),
1699                }
1700            }
1701
1702            // A rate-limited add whose retry is due goes out before the next read.
1703            let now = tokio::time::Instant::now();
1704            if let Some(i) = self.pending_add_retries.iter().position(|p| p.deadline <= now) {
1705                self.send_add_retry(i).await?;
1706                continue;
1707            }
1708            let next_retry = self.pending_add_retries.iter().map(|p| p.deadline).min();
1709            let stream = self.stream.as_mut().expect("connected");
1710            // Race the read against the earliest retry so a pending retry does not
1711            // hold up live events. `message` is cancel-safe: a read the timer
1712            // preempts loses nothing.
1713            let msg = match next_retry {
1714                Some(deadline) => tokio::select! {
1715                    m = stream.message() => m,
1716                    _ = tokio::time::sleep_until(deadline) => continue,
1717                },
1718                None => stream.message().await,
1719            };
1720            match msg {
1721                Ok(Some(ev)) => {
1722                    self.reconnect_attempts = 0;
1723                    self.last_error = None;
1724                    let cur = self.stream.as_ref().and_then(|s| s.cursor().copied());
1725                    if let Some(out) = self.handle_event(ev, cur).await? {
1726                        self.arm_commit();
1727                        return Ok(out);
1728                    }
1729                    // Handled internally (a transient re-anchor retry): loop.
1730                }
1731                Ok(None) => {
1732                    self.teardown();
1733                    self.reconnect_attempts = self.reconnect_attempts.saturating_add(1);
1734                }
1735                Err(e) if e.is_retryable() => {
1736                    self.teardown();
1737                    self.reconnect_attempts = self.reconnect_attempts.saturating_add(1);
1738                    self.last_error = Some(e);
1739                }
1740                Err(e) => return Err(e),
1741            }
1742        }
1743    }
1744
1745    /// Process one inbound event. Advances the confirmed high-water, and drives
1746    /// the re-anchor catch-up off the deterministic results: a transient reject
1747    /// (`RateLimited` / `ConcurrentReanchor`) is backed off and re-sent in place
1748    /// (returning `Ok(None)` so [`next`](Self::next) loops); everything else —
1749    /// including `CursorAccepted` (watch `clamped`) and a terminal reject — is
1750    /// handed to the caller.
1751    async fn handle_event(
1752        &mut self,
1753        ev: Event,
1754        cur: Option<Cursor>,
1755    ) -> Result<Option<Event>, StreamError> {
1756        if let Some(c) = cur {
1757            self.resume = Some(c);
1758        }
1759
1760        // A rate-limited add is transient: keep its items in the mirror and
1761        // re-send them once the limit allows, absorbing the event, until an item
1762        // has spent the backoff budget. Then it is handled like any other
1763        // refusal below: dropped from the mirror and surfaced.
1764        if let Event::WatchAddRejected(r) = &ev
1765            && r.reason == WatchAddRejectReason::RateLimited
1766        {
1767            let keys = retry_keys(r);
1768            let attempt =
1769                keys.iter().filter_map(|k| self.add_retry_attempts.get(k)).copied().max().unwrap_or(0);
1770            let exhausted = self.config.backoff.max_retries.is_some_and(|max| attempt >= max);
1771            if !exhausted {
1772                for k in keys {
1773                    self.add_retry_attempts.insert(k, attempt.saturating_add(1));
1774                }
1775                let delay = self
1776                    .config
1777                    .backoff
1778                    .delay_for(attempt)
1779                    .max(std::time::Duration::from_secs(r.retry_after_secs.into()));
1780                self.pending_add_retries.push(PendingAddRetry {
1781                    deadline: tokio::time::Instant::now() + delay,
1782                    rejected: r.clone(),
1783                });
1784                return Ok(None);
1785            }
1786            for k in keys {
1787                self.add_retry_attempts.remove(&k);
1788            }
1789        }
1790
1791        // One-shot watches the server auto-evicts when their terminal event
1792        // fires: prune the mirror to match, so a reconnect does not re-register
1793        // an already-fired watch (which would duplicate the terminal
1794        // notification and burn watch quota on a completed txid). The server
1795        // emits the *requested* threshold as `depth` (its alarm identity), so it
1796        // is the exact `(txid, depth)` key to drop; a finalize evicts the whole
1797        // lifecycle watch for the txid.
1798        match &ev {
1799            // The server refused an add: it does not hold these items, so a
1800            // reconnect must not re-register them.
1801            Event::WatchAddRejected(r) => self.mirror.forget_rejected(r),
1802            Event::TxidDepthReached { txid, depth, .. } => {
1803                if let Ok(t) = <[u8; 32]>::try_from(txid.as_slice()) {
1804                    self.mirror.remove_depth_alarms(&[(t, *depth)]);
1805                }
1806            }
1807            Event::TxidFinalized { txid, .. } => {
1808                if let Ok(t) = <[u8; 32]>::try_from(txid.as_slice()) {
1809                    self.mirror.remove_tx_lifecycle(&[t]);
1810                }
1811            }
1812            _ => {}
1813        }
1814
1815        let transient_reject = matches!(
1816            &ev,
1817            Event::CursorRejected {
1818                reason: CursorRejectReason::RateLimited | CursorRejectReason::ConcurrentReanchor,
1819                ..
1820            }
1821        );
1822        if transient_reject {
1823            // Bounded in-place retry: back off and re-send the same re-anchor. If
1824            // we exhaust the retry budget, surface the reject so the caller can
1825            // escalate rather than spinning forever.
1826            if let Some(max) = self.config.backoff.max_retries
1827                && self.reanchor_attempts >= max
1828            {
1829                self.reanchor_attempts = 0;
1830                // Surfacing the reject ends the retry: drop any pending re-send so
1831                // `next` does not fire one more `set_cursor` after we told the
1832                // caller the budget is exhausted.
1833                self.pending_reanchor = None;
1834                return Ok(Some(ev));
1835            }
1836            // Record the retry as resumable state and return; `next` drives the
1837            // backoff + re-send via `drive_pending_reanchor`. Doing the charge here
1838            // (synchronously, no await between the budget read and the counter
1839            // bump) and the awaits there is what makes `next` cancel-safe: a
1840            // cancelled drive resumes this same retry instead of burning the
1841            // counter without the re-send landing. The deadline is absolute so a
1842            // resumed drive waits out only the remaining backoff. Only the deadline
1843            // is captured — the drive re-sends the live `desired_cursor`.
1844            let delay = self.config.backoff.delay_for(self.reanchor_attempts);
1845            self.reanchor_attempts = self.reanchor_attempts.saturating_add(1);
1846            self.pending_reanchor =
1847                Some(PendingReanchor { deadline: tokio::time::Instant::now() + delay });
1848            return Ok(None);
1849        }
1850
1851        if let Event::CursorAccepted { from, .. } = &ev {
1852            // The server has committed to replaying from this anchor. Adopt it as
1853            // the resume / re-anchor point *now* — not only once the first
1854            // replayed confirmed event advances the high-water. If the stream
1855            // drops after this ack but before that first replayed event, the
1856            // reconnect re-anchors from `resume`; leaving it at the stale
1857            // high-water would silently skip the requested catch-up window and
1858            // break at-least-once coverage. (`cur` is None for this control
1859            // frame, so the high-water line above left `resume` untouched; we set
1860            // it to the accepted anchor here. A clamped accept re-clamps the same
1861            // way on reconnect, so re-anchoring from `from` stays correct.)
1862            if let Some(c) = from {
1863                self.resume = Some(*c);
1864                self.desired_cursor = Some(*c);
1865            }
1866            // The re-anchor landed: reset the transient-retry counter.
1867            self.reanchor_attempts = 0;
1868        }
1869        Ok(Some(ev))
1870    }
1871
1872    /// Drive a deferred transient-reject retry ([`PendingReanchor`], recorded by
1873    /// `handle_event`) to completion: wait out the remaining backoff, then re-send
1874    /// the current re-anchor. Cancel-safe: the pending slot is cleared only *after*
1875    /// the re-send await resolves, so a cancellation before then leaves the retry
1876    /// recorded for the next [`next`](Self::next) to resume — the budget was
1877    /// already charged when the retry was recorded, so a resume never re-charges
1878    /// it. There is no await between the send resolving and the slot clearing, so a
1879    /// cancel either drops the future before the send lands (the slot survives and
1880    /// the next `next` sends once) or after it lands and the slot is already
1881    /// cleared — the same re-send never goes out twice. The cursor is read *live*
1882    /// from `desired_cursor`, not snapshotted, so a re-anchor changed between the
1883    /// record and the drive is honoured; a no-op while disconnected (no
1884    /// `desired_cursor` / no `handle`) still clears the slot.
1885    async fn drive_pending_reanchor(&mut self) -> Result<(), StreamError> {
1886        let Some(pending) = self.pending_reanchor else {
1887            return Ok(());
1888        };
1889        tokio::time::sleep_until(pending.deadline).await;
1890        let res = match (self.desired_cursor, &self.handle) {
1891            (Some(c), Some(h)) => Some(h.set_cursor(c).await),
1892            _ => None,
1893        };
1894        // Only now that the re-send has been attempted is the retry done; a
1895        // cancellation before this line leaves `pending_reanchor` set to resume.
1896        self.pending_reanchor = None;
1897        self.after_send(res)
1898    }
1899
1900    /// Open a fresh `Watch` stream, re-register the mirrored watch-set, and
1901    /// re-anchor to the resume cursor. A control-send failure here means the new
1902    /// stream is already unusable — surfaced as an error for [`next`](Self::next)
1903    /// to back off and retry.
1904    async fn connect(&mut self) -> Result<(), StreamError> {
1905        self.seed_resume()?;
1906        let (handle, stream) = self.client.watch().await?;
1907        // When a loader is configured it is canonical: rebuild the watch-set from
1908        // the integrator's source-of-truth into a fresh mirror, then adopt it.
1909        // This runs before the watch-set is registered (below) and before any
1910        // event is pumped, so the first events after a reconnect land on a fully
1911        // populated subscription. A loader error is a transient reconnect-level
1912        // condition (see `is_reconnectable`), so a freshly-opened stream we will
1913        // not use is dropped here and the next attempt retries the load.
1914        if let Some(loader) = self.config.watch_set_loader.clone() {
1915            let shared = Arc::new(Mutex::new(WatchSetMirror::default()));
1916            loader(WatchSetBuilder { mirror: shared.clone() })
1917                .await
1918                .map_err(StreamError::WatchSetLoader)?;
1919            // Take the loaded set regardless of whether the loader stashed a
1920            // builder clone; the builder only records, so nothing races here.
1921            self.mirror = std::mem::take(&mut *shared.lock().unwrap_or_else(|p| p.into_inner()));
1922        }
1923        for msg in self.mirror.control_messages() {
1924            handle.send_control(pb::SubscribeControl { msg: Some(msg) }).await?;
1925        }
1926        self.desired_cursor = self.resume;
1927        self.reanchor_attempts = 0;
1928        // `teardown` already dropped any deferred retry against the old stream;
1929        // this fresh stream re-anchors from `resume` right here.
1930        debug_assert!(self.pending_reanchor.is_none());
1931        if let Some(c) = self.resume {
1932            handle.set_cursor(c).await?;
1933        }
1934        self.handle = Some(handle);
1935        self.stream = Some(stream);
1936        Ok(())
1937    }
1938
1939    /// Establish the resume anchor on the first connect (no I/O): store, else the
1940    /// configured `from_cursor`. A cursor read back from the store is already
1941    /// durably committed, so it also seeds `committed` to elide a redundant first
1942    /// write.
1943    fn seed_resume(&mut self) -> Result<(), StreamError> {
1944        if !self.seeded {
1945            let loaded = self.config.cursor_store.load()?;
1946            if let Some(c) = loaded {
1947                self.committed.get_or_insert(c);
1948            }
1949            self.resume = self.resume.or(loaded).or(self.config.from_cursor);
1950            self.seeded = true;
1951        }
1952        Ok(())
1953    }
1954
1955    /// Drop the live connection so the next [`next`](Self::next) reconnects.
1956    fn teardown(&mut self) {
1957        self.handle = None;
1958        self.stream = None;
1959        // The reconnect re-sends the whole mirror, rate-limited items included,
1960        // so their pending retries and budgets start over.
1961        self.pending_add_retries.clear();
1962        self.add_retry_attempts.clear();
1963        // A deferred re-anchor retry is meaningless once the stream it targeted is
1964        // gone — the reconnect re-anchors from `resume` itself. Drop it so `next`
1965        // does not sleep out a stale backoff against a dead handle before
1966        // reconnecting.
1967        self.pending_reanchor = None;
1968    }
1969
1970    /// Re-send the items of the `i`th pending rate-limited add that the mirror
1971    /// still holds. The entry is removed only after the sends resolve, so a
1972    /// cancelled `next` re-sends it; a duplicate add is an idempotent re-assert.
1973    async fn send_add_retry(&mut self, i: usize) -> Result<(), StreamError> {
1974        let msgs = self.mirror.subset_for(&self.pending_add_retries[i].rejected).control_messages();
1975        let res = match &self.handle {
1976            Some(h) => {
1977                let mut res = Ok(());
1978                for msg in msgs {
1979                    res = h.send_control(pb::SubscribeControl { msg: Some(msg) }).await;
1980                    if res.is_err() {
1981                        break;
1982                    }
1983                }
1984                Some(res)
1985            }
1986            None => None,
1987        };
1988        self.pending_add_retries.remove(i);
1989        self.after_send(res)
1990    }
1991
1992    /// Resolve a live control-send result: a `ControlClosed` means the stream
1993    /// died, so drop it (the edit is safe in the mirror and replays on
1994    /// reconnect); any other error propagates; success and the disconnected
1995    /// (`None`) case are no-ops.
1996    fn after_send(&mut self, res: Option<Result<(), StreamError>>) -> Result<(), StreamError> {
1997        match res {
1998            Some(Err(StreamError::ControlClosed)) => {
1999                self.teardown();
2000                Ok(())
2001            }
2002            Some(Err(e)) => Err(e),
2003            _ => Ok(()),
2004        }
2005    }
2006
2007    /// Arm the just-delivered event's high-water for commit on the next poll.
2008    fn arm_commit(&mut self) {
2009        self.commit_next = self.resume;
2010    }
2011
2012    /// Commit-on-poll flush: persist the armed high-water if it differs from the
2013    /// store's current value.
2014    fn commit_due(&mut self) -> Result<(), StreamError> {
2015        let Some(c) = self.commit_next else { return Ok(()) };
2016        if self.committed == Some(c) {
2017            self.commit_next = None;
2018            return Ok(());
2019        }
2020        // The arm stays armed until the write lands — see the identical note on
2021        // `ResilientSubscription::commit_due`. Clearing it first makes a retried
2022        // commit report success after a store failure it never recovered from.
2023        self.config.cursor_store.save(c)?;
2024        if self.commit_next == Some(c) {
2025            self.commit_next = None;
2026        }
2027        self.committed = Some(c);
2028        Ok(())
2029    }
2030}
2031
2032/// Earlier windows kept per descriptor for a refused slide to fall back to. A
2033/// caller slides one step at a time and the server answers in order, so a few
2034/// are plenty; the oldest is dropped past this.
2035const DESCRIPTOR_HISTORY: usize = 8;
2036
2037/// `prefix` with the bits past `bits` cleared, as the server echoes it.
2038fn mask_prefix(prefix: &[u8], bits: u32) -> Vec<u8> {
2039    let mut out = prefix.to_vec();
2040    let rem = bits % 8;
2041    if rem != 0
2042        && let Some(last) = out.get_mut((bits / 8) as usize)
2043    {
2044        *last &= 0xffu8 << (8 - rem);
2045    }
2046    out
2047}
2048
2049/// `txids × depths` as flattened pairs.
2050fn cross_product(txids: &[Txid], depths: &[u32]) -> Vec<(Txid, u32)> {
2051    let mut out = Vec::with_capacity(txids.len() * depths.len());
2052    for t in txids {
2053        for d in depths {
2054            out.push((*t, *d));
2055        }
2056    }
2057    out
2058}
2059
2060/// Validate a batch of `(prefix, bits)` pairs up front (so a bad one is rejected
2061/// before any mirror mutation), reusing the same check as [`WatchHandle`].
2062fn validate_prefixes(
2063    prefixes: impl IntoIterator<Item = (Vec<u8>, u32)>,
2064) -> Result<Vec<pb::ScriptPrefix>, StreamError> {
2065    prefixes
2066        .into_iter()
2067        .map(|(prefix, bits)| validate_prefix(prefix, bits))
2068        .collect()
2069}
2070
2071/// Validate each scan-key target and derive its identity `b_scan·G` (the mirror
2072/// key and the removal identity), **deduplicating by identity** so the same set
2073/// drives both the mirror mutation and the wire message.
2074///
2075/// Both curve values are validated ([`SilentPaymentTarget::validate`]), so a
2076/// malformed `scan_secret` *or* `spend_pubkey` is a deterministic
2077/// [`StreamError::InvalidArgument`] here rather than a silent server-side skip.
2078///
2079/// Dedup is last-wins and happens once, before the caller mutates the mirror or
2080/// transmits: without it, a batch carrying the same identity twice would send
2081/// both duplicates on the wire (where the server keeps the *first*) while the
2082/// mirror's map keeps the *last* — so a later reconnect replay would install a
2083/// different spend key/labels than the live server currently holds. Collapsing
2084/// to one target per identity up front keeps the initial install, the mirror,
2085/// and every reconnect in agreement.
2086#[cfg(feature = "bitcoin")]
2087fn sp_mirror_entries(
2088    targets: impl IntoIterator<Item = SilentPaymentTarget>,
2089) -> Result<Vec<([u8; 33], SilentPaymentTarget)>, StreamError> {
2090    let mut deduped: BTreeMap<[u8; 33], SilentPaymentTarget> = BTreeMap::new();
2091    for t in targets {
2092        let id = t.validate()?;
2093        deduped.insert(id, t); // last-wins on a repeated identity
2094    }
2095    Ok(deduped.into_iter().collect())
2096}
2097
2098/// Whether a `connect()` failure should be retried by reconnecting. Adds
2099/// `ControlClosed` and `WatchSetLoader` to the transport-retryable set: a
2100/// freshly-opened stream that rejects the watch-set replay is a transient
2101/// failure to reconnect through, and a watch-set loader that fails to reach its
2102/// source-of-truth should be retried on the next connect rather than crashing
2103/// the consumer — neither is a caller-facing error.
2104fn is_reconnectable(e: &StreamError) -> bool {
2105    e.is_retryable() || matches!(e, StreamError::ControlClosed | StreamError::WatchSetLoader(_))
2106}
2107
2108#[cfg(test)]
2109mod tests {
2110    use super::*;
2111    use std::sync::Mutex;
2112
2113    // --- watch-set mirror -----------------------------------------------------
2114
2115    #[test]
2116    fn mirror_scripts_floor_overwrite_and_remove() {
2117        let mut m = WatchSetMirror::default();
2118        m.add_scripts(&[([1u8; 32], Some(5_000)), ([2u8; 32], None)]);
2119        // Re-assert updates the floor in place (no duplicate).
2120        m.add_scripts(&[([1u8; 32], Some(9_000))]);
2121        m.remove_scripts(&[[2u8; 32]]);
2122
2123        let msgs = m.control_messages();
2124        assert_eq!(msgs.len(), 1);
2125        match &msgs[0] {
2126            Msg::AddScripts(a) => {
2127                assert_eq!(a.scripthashes, vec![[1u8; 32].to_vec()]);
2128                // One script, floored → parallel min_values of length 1.
2129                assert_eq!(a.min_values, vec![9_000]);
2130            }
2131            other => panic!("expected AddScripts, got {other:?}"),
2132        }
2133    }
2134
2135    #[test]
2136    fn mirror_unfloored_scripts_emit_empty_min_values() {
2137        let mut m = WatchSetMirror::default();
2138        m.add_scripts(&[([1u8; 32], None), ([2u8; 32], None)]);
2139        match &m.control_messages()[0] {
2140            Msg::AddScripts(a) => assert!(a.min_values.is_empty(), "no floor → empty min_values"),
2141            other => panic!("expected AddScripts, got {other:?}"),
2142        }
2143    }
2144
2145    #[test]
2146    fn mirror_lifecycle_grouped_by_autoclose_depth() {
2147        let mut m = WatchSetMirror::default();
2148        m.add_tx_lifecycle(&[[1u8; 32], [2u8; 32]], AutoClose::AtDepth(6));
2149        m.add_tx_lifecycle(&[[3u8; 32]], AutoClose::Never);
2150        let groups: Vec<_> = m
2151            .control_messages()
2152            .into_iter()
2153            .filter_map(|msg| match msg {
2154                Msg::AddTransactions(a) => Some((a.auto_close_depth, a.min_depths, a.txids.len())),
2155                _ => None,
2156            })
2157            .collect();
2158        // One group at depth 0 (Never, 1 txid), one at depth 6 (2 txids); both
2159        // lifecycle (empty min_depths).
2160        assert!(groups.contains(&(0, vec![], 1)));
2161        assert!(groups.contains(&(6, vec![], 2)));
2162    }
2163
2164    #[test]
2165    fn mirror_depth_alarms_grouped_by_txid_with_min_depths() {
2166        let mut m = WatchSetMirror::default();
2167        m.add_depth_alarms(&[([7u8; 32], 3), ([7u8; 32], 6)]);
2168        let alarms: Vec<_> = m
2169            .control_messages()
2170            .into_iter()
2171            .filter_map(|msg| match msg {
2172                Msg::AddTransactions(a) => Some((a.txids.len(), a.min_depths, a.auto_close_depth)),
2173                _ => None,
2174            })
2175            .collect();
2176        // One per-txid message carrying both depths; non-empty min_depths marks
2177        // it a depth alarm (not a lifecycle), auto_close_depth 0.
2178        assert_eq!(alarms, vec![(1, vec![3, 6], 0)]);
2179    }
2180
2181    #[test]
2182    fn mirror_categories_replayed_first() {
2183        let mut m = WatchSetMirror::default();
2184        m.add_outpoints(&[([1u8; 32], 0)]);
2185        m.set_categories(crate::Categories::CHAIN);
2186        let msgs = m.control_messages();
2187        assert!(matches!(msgs[0], Msg::SetCategories(_)), "categories lead the replay");
2188    }
2189
2190    #[test]
2191    fn mirror_raw_tx_opt_in_replayed() {
2192        // The raw-tx opt-in survives a reconnect: it renders into the replay
2193        // control messages, before the watch-set adds so it is in effect when
2194        // matches flow. Unset → no SetWatchOptions in the replay.
2195        let mut m = WatchSetMirror::default();
2196        m.add_scripts(&[([9u8; 32], None)]);
2197        assert!(
2198            !m.control_messages()
2199                .iter()
2200                .any(|msg| matches!(msg, Msg::SetWatchOptions(_))),
2201            "no opt-in → no SetWatchOptions replayed"
2202        );
2203
2204        m.set_watch_options(true);
2205        let msgs = m.control_messages();
2206        let opt = msgs.iter().find_map(|msg| match msg {
2207            Msg::SetWatchOptions(o) => Some(o.include_raw_tx),
2208            _ => None,
2209        });
2210        assert_eq!(opt, Some(true), "opt-in replayed as SetWatchOptions{{true}}");
2211        // It leads the watch-set adds (like categories), so raw_tx is on from
2212        // the first replayed match.
2213        let opt_pos = msgs
2214            .iter()
2215            .position(|msg| matches!(msg, Msg::SetWatchOptions(_)))
2216            .unwrap();
2217        let add_pos = msgs
2218            .iter()
2219            .position(|msg| matches!(msg, Msg::AddScripts(_)))
2220            .unwrap();
2221        assert!(opt_pos < add_pos, "options precede the watch-set adds");
2222    }
2223
2224    #[test]
2225    fn mirror_descriptor_latest_window_wins() {
2226        let mut m = WatchSetMirror::default();
2227        m.add_descriptor("wpkh(xpub...)".into(), 20, 0);
2228        m.add_descriptor("wpkh(xpub...)".into(), 20, 5); // window advanced
2229        let descs: Vec<_> = m
2230            .control_messages()
2231            .into_iter()
2232            .filter_map(|msg| match msg {
2233                Msg::AddDescriptor(d) => Some((d.gap_limit, d.start)),
2234                _ => None,
2235            })
2236            .collect();
2237        assert_eq!(descs, vec![(20, 5)], "only the latest window replays");
2238    }
2239
2240    #[test]
2241    fn mirror_removed_descriptor_is_not_replayed() {
2242        let mut m = WatchSetMirror::default();
2243        m.add_descriptor("wpkh(a)".into(), 20, 0);
2244        m.add_descriptor("wpkh(b)".into(), 20, 0);
2245        m.remove_descriptor("wpkh(a)");
2246        let descs: Vec<_> = m
2247            .control_messages()
2248            .into_iter()
2249            .filter_map(|msg| match msg {
2250                Msg::AddDescriptor(d) => Some(d.descriptor),
2251                _ => None,
2252            })
2253            .collect();
2254        // The removed descriptor is gone from the replay; a reconnect re-registers
2255        // only the surviving one (the fresh stream has nothing to RemoveDescriptor).
2256        assert_eq!(descs, vec!["wpkh(b)".to_string()]);
2257    }
2258
2259    #[test]
2260    fn mirror_empty_renders_nothing() {
2261        assert!(WatchSetMirror::default().control_messages().is_empty());
2262    }
2263
2264    #[cfg(feature = "bitcoin")]
2265    #[test]
2266    fn sp_mirror_entries_validates_and_dedups() {
2267        use bitcoin::secp256k1::{PublicKey, Secp256k1, SecretKey};
2268        let secp = Secp256k1::new();
2269        let key = |b: u8| {
2270            PublicKey::from_secret_key(&secp, &SecretKey::from_slice(&[b; 32]).unwrap()).serialize()
2271        };
2272        let b_scan = [0x11u8; 32];
2273        let (spend_a, spend_b) = (key(0x22), key(0x33));
2274
2275        // Two targets with the SAME scan identity (same b_scan) but different
2276        // spend key/labels: dedup collapses to one, last-wins — so the mirror,
2277        // the wire message, and every reconnect replay all agree.
2278        let t1 = SilentPaymentTarget { scan_secret: b_scan, spend_pubkey: spend_a, labels: vec![0] };
2279        let t2 =
2280            SilentPaymentTarget { scan_secret: b_scan, spend_pubkey: spend_b, labels: vec![1, 2] };
2281        let entries = sp_mirror_entries([t1, t2]).expect("valid targets");
2282        assert_eq!(entries.len(), 1, "same identity collapses to one entry");
2283        assert_eq!(entries[0].1.spend_pubkey, spend_b, "last write wins");
2284        assert_eq!(entries[0].1.labels, vec![1, 2]);
2285
2286        // A malformed spend_pubkey is rejected up front (deterministic), not
2287        // silently accepted and skipped by the server.
2288        let mut bad_spend = spend_a;
2289        bad_spend[0] = 0x01; // invalid compressed-point prefix
2290        let bad =
2291            SilentPaymentTarget { scan_secret: b_scan, spend_pubkey: bad_spend, labels: vec![] };
2292        assert!(matches!(sp_mirror_entries([bad]), Err(StreamError::InvalidArgument(_))));
2293
2294        // A malformed scan_secret (zero scalar) is likewise rejected.
2295        let bad2 =
2296            SilentPaymentTarget { scan_secret: [0u8; 32], spend_pubkey: spend_a, labels: vec![] };
2297        assert!(matches!(sp_mirror_entries([bad2]), Err(StreamError::InvalidArgument(_))));
2298    }
2299
2300    #[cfg(feature = "bitcoin")]
2301    #[tokio::test]
2302    async fn sp_add_enforces_connection_cap() {
2303        use crate::client::MAX_SP_TARGETS_PER_CONNECTION;
2304        let spend = valid_spend_pubkey();
2305        let target = |i: u8| SilentPaymentTarget {
2306            scan_secret: [i; 32],
2307            spend_pubkey: spend,
2308            labels: vec![0],
2309        };
2310        let store = Arc::new(MemStore::default());
2311        let mut w = watch_with(&store);
2312        // Exactly the cap of distinct targets is accepted.
2313        let full: Vec<_> = (1..=MAX_SP_TARGETS_PER_CONNECTION as u8).map(target).collect();
2314        w.add_silent_payments(full).await.expect("cap-exact add accepted");
2315        // Re-asserting a held identity at the cap is free (net-new = 0).
2316        w.add_silent_payments([target(1)]).await.expect("re-assert at cap is free");
2317        // A further DISTINCT target is rejected deterministically rather than
2318        // silently shed by the server and replayed forever by the mirror.
2319        let err = w
2320            .add_silent_payments([target(MAX_SP_TARGETS_PER_CONNECTION as u8 + 1)])
2321            .await
2322            .expect_err("over-cap add must be rejected");
2323        assert!(matches!(err, StreamError::InvalidArgument(_)), "got {err:?}");
2324    }
2325
2326    #[cfg(feature = "bitcoin")]
2327    #[tokio::test]
2328    async fn sp_add_enforces_label_cap() {
2329        use crate::client::MAX_SP_LABELS_PER_TARGET;
2330        let spend = valid_spend_pubkey();
2331        let store = Arc::new(MemStore::default());
2332        let mut w = watch_with(&store);
2333        // One distinct label over the cap is rejected up front.
2334        let over = SilentPaymentTarget {
2335            scan_secret: [1u8; 32],
2336            spend_pubkey: spend,
2337            labels: (0..=MAX_SP_LABELS_PER_TARGET as u32).collect(),
2338        };
2339        let err = w.add_silent_payments([over]).await.expect_err("over-label rejected");
2340        assert!(matches!(err, StreamError::InvalidArgument(_)), "got {err:?}");
2341        // Exactly the cap is accepted.
2342        let ok = SilentPaymentTarget {
2343            scan_secret: [1u8; 32],
2344            spend_pubkey: spend,
2345            labels: (0..MAX_SP_LABELS_PER_TARGET as u32).collect(),
2346        };
2347        w.add_silent_payments([ok]).await.expect("cap-exact labels accepted");
2348    }
2349
2350    /// A valid compressed spend point for SP target test fixtures.
2351    #[cfg(feature = "bitcoin")]
2352    fn valid_spend_pubkey() -> [u8; 33] {
2353        use bitcoin::secp256k1::{PublicKey, Secp256k1, SecretKey};
2354        let secp = Secp256k1::new();
2355        PublicKey::from_secret_key(&secp, &SecretKey::from_slice(&[0x22u8; 32]).unwrap()).serialize()
2356    }
2357
2358    // --- a CursorStore we can assert against ----------------------------------
2359
2360    #[derive(Default)]
2361    struct MemStore(Mutex<Option<Cursor>>);
2362    impl CursorStore for MemStore {
2363        fn load(&self) -> Result<Option<Cursor>, StreamError> {
2364            Ok(*self.0.lock().unwrap())
2365        }
2366        fn save(&self, cursor: Cursor) -> Result<(), StreamError> {
2367            *self.0.lock().unwrap() = Some(cursor);
2368            Ok(())
2369        }
2370    }
2371
2372    fn watch_with(store: &Arc<MemStore>) -> ResilientWatch {
2373        ResilientWatch::new(
2374            StreamClient::for_test(),
2375            ResilientWatchConfig::new().cursor_store(store.clone()),
2376        )
2377    }
2378
2379    fn cur(height: u32) -> Cursor {
2380        Cursor { height, tx_index: 0, mempool_seq: 0, instance_id: 1 }
2381    }
2382
2383    // --- offline edits land in the mirror -------------------------------------
2384
2385    #[tokio::test]
2386    async fn offline_edits_accumulate_for_replay() {
2387        let store = Arc::new(MemStore::default());
2388        let mut w = watch_with(&store);
2389        // Disconnected: every edit records to the mirror and sends nothing.
2390        w.add_scripts([([1u8; 32], None)]).await.unwrap();
2391        w.add_outpoints([([9u8; 32], 2)]).await.unwrap();
2392        w.add_depth_alarms([[7u8; 32]], [0]).await.unwrap(); // all depths invalid → no-op
2393        let kinds: Vec<_> = w
2394            .mirror
2395            .control_messages()
2396            .into_iter()
2397            .map(|m| std::mem::discriminant(&m))
2398            .collect();
2399        // Scripts + outpoints recorded; the all-invalid depth alarm did not.
2400        assert_eq!(kinds.len(), 2);
2401        assert!(w.handle.is_none(), "still disconnected");
2402    }
2403
2404    // --- re-anchor catch-up ---------------------------------------------------
2405
2406    #[tokio::test]
2407    async fn cursor_accepted_is_surfaced_and_resets_retry_counter() {
2408        let store = Arc::new(MemStore::default());
2409        let mut w = watch_with(&store);
2410        w.reanchor_attempts = 3;
2411        let ev = Event::CursorAccepted { from: Some(cur(100)), clamped: false, earliest_replayed: 100 };
2412        let out = w.handle_event(ev, None).await.unwrap();
2413        assert!(matches!(out, Some(Event::CursorAccepted { .. })), "accept surfaces to caller");
2414        assert_eq!(w.reanchor_attempts, 0, "a landed re-anchor resets the retry counter");
2415    }
2416
2417    #[tokio::test]
2418    async fn cursor_accepted_adopts_anchor_before_any_replayed_event() {
2419        let store = Arc::new(MemStore::default());
2420        let mut w = watch_with(&store);
2421        // High-water sits forward (e.g. from prior live events); the caller
2422        // re-anchors backward to replay a window.
2423        w.resume = Some(cur(1000));
2424        w.desired_cursor = Some(cur(200));
2425        // Server admits the re-anchor. No replayed confirmed event has arrived yet
2426        // (this control frame carries no cursor).
2427        let ev = Event::CursorAccepted { from: Some(cur(200)), clamped: false, earliest_replayed: 200 };
2428        let out = w.handle_event(ev, None).await.unwrap();
2429        assert!(matches!(out, Some(Event::CursorAccepted { .. })));
2430        // The accepted anchor is adopted immediately, so a reconnect *before* the
2431        // first replayed event re-anchors from 200, not the stale high-water 1000.
2432        assert_eq!(w.resume, Some(cur(200)), "accepted anchor becomes the resume point at once");
2433        assert_eq!(w.desired_cursor, Some(cur(200)));
2434    }
2435
2436    #[tokio::test]
2437    async fn terminal_reject_is_surfaced_for_escalation() {
2438        let store = Arc::new(MemStore::default());
2439        let mut w = watch_with(&store);
2440        let ev = Event::CursorRejected {
2441            reason: CursorRejectReason::NoSource,
2442            current_head: Some(cur(500)),
2443        };
2444        let out = w.handle_event(ev, None).await.unwrap();
2445        assert!(
2446            matches!(out, Some(Event::CursorRejected { reason: CursorRejectReason::NoSource, .. })),
2447            "NoSource is terminal — surfaced so the caller can resnapshot"
2448        );
2449    }
2450
2451    #[tokio::test]
2452    async fn transient_reject_retries_in_place_until_budget_exhausted() {
2453        let store = Arc::new(MemStore::default());
2454        let mut w = ResilientWatch::new(
2455            StreamClient::for_test(),
2456            ResilientWatchConfig::new()
2457                .cursor_store(store.clone())
2458                // Tiny backoff + a 2-retry budget so the test is fast and bounded.
2459                .backoff(Backoff {
2460                    initial: std::time::Duration::from_millis(0),
2461                    max: std::time::Duration::from_millis(0),
2462                    multiplier: 1.0,
2463                    max_retries: Some(2),
2464                }),
2465        );
2466        w.desired_cursor = Some(cur(100));
2467        let reject = || Event::CursorRejected {
2468            reason: CursorRejectReason::RateLimited,
2469            current_head: None,
2470        };
2471        // `handle_event` now records each transient reject as a pending retry
2472        // (the backoff + re-send is driven by `next`); we assert the budget
2473        // accounting and that the reject is consumed (Ok(None)) until exhausted,
2474        // then surfaced.
2475        assert!(w.handle_event(reject(), None).await.unwrap().is_none(), "retry 1 consumed");
2476        assert!(w.pending_reanchor.is_some(), "retry 1 deferred to a pending slot");
2477        assert!(w.handle_event(reject(), None).await.unwrap().is_none(), "retry 2 consumed");
2478        let out = w.handle_event(reject(), None).await.unwrap();
2479        assert!(
2480            matches!(out, Some(Event::CursorRejected { .. })),
2481            "budget exhausted → surfaced for the caller to escalate"
2482        );
2483        assert_eq!(w.reanchor_attempts, 0, "counter resets after surfacing");
2484    }
2485
2486    #[tokio::test]
2487    async fn transient_reject_retry_is_cancel_safe() {
2488        // Regression for #453: cancelling `next` (e.g. losing a `select!` race)
2489        // while a transient-reject retry is mid-backoff must not burn the retry
2490        // budget without the re-send landing. The retry lives in `self` as
2491        // pending state and is driven by `drive_pending_reanchor`, so a cancelled
2492        // drive resumes the *same* retry.
2493        let store = Arc::new(MemStore::default());
2494        let mut w = ResilientWatch::new(
2495            StreamClient::for_test(),
2496            ResilientWatchConfig::new().cursor_store(store.clone()).backoff(Backoff {
2497                // A long backoff so the drive is guaranteed to park in the sleep
2498                // (never reaching the re-send) before we cancel it.
2499                initial: std::time::Duration::from_secs(30),
2500                max: std::time::Duration::from_secs(30),
2501                multiplier: 1.0,
2502                max_retries: Some(3),
2503            }),
2504        );
2505        w.desired_cursor = Some(cur(100));
2506
2507        // A transient reject charges the budget once and defers the retry — no
2508        // inline await in `handle_event` any more.
2509        let out = w
2510            .handle_event(
2511                Event::CursorRejected { reason: CursorRejectReason::RateLimited, current_head: None },
2512                None,
2513            )
2514            .await
2515            .unwrap();
2516        assert!(out.is_none(), "transient reject consumed");
2517        assert_eq!(w.reanchor_attempts, 1, "one retry charged");
2518        assert!(w.pending_reanchor.is_some(), "retry deferred to a resumable slot");
2519
2520        // Cancel the drive while it is parked in the 30s backoff: `biased` polls
2521        // the drive first (it parks), then the 1ms timer fires and preempts it,
2522        // dropping the drive future.
2523        tokio::select! {
2524            biased;
2525            _ = w.drive_pending_reanchor() => unreachable!("30s backoff cannot have elapsed"),
2526            _ = tokio::time::sleep(std::time::Duration::from_millis(1)) => {}
2527        }
2528        assert!(w.pending_reanchor.is_some(), "a cancelled drive leaves the retry resumable");
2529        assert_eq!(w.reanchor_attempts, 1, "the cancelled drive did not re-charge the budget");
2530
2531        // Resume to completion. Collapse the remaining backoff so the test stays
2532        // fast; disconnected, so the re-send is a no-op and the slot just clears.
2533        w.pending_reanchor.as_mut().unwrap().deadline = tokio::time::Instant::now();
2534        w.drive_pending_reanchor().await.unwrap();
2535        assert!(w.pending_reanchor.is_none(), "the resumed drive completes the retry");
2536        assert_eq!(w.reanchor_attempts, 1, "resuming does not re-charge the budget");
2537    }
2538
2539    // Drain the cursor of every `SetCursor` control message queued on a test
2540    // handle's receiver, in send order.
2541    fn drained_set_cursors(
2542        rx: &mut tokio::sync::mpsc::Receiver<pb::SubscribeControl>,
2543    ) -> Vec<Cursor> {
2544        let mut out = Vec::new();
2545        while let Ok(ctrl) = rx.try_recv() {
2546            if let Some(Msg::SetCursor(s)) = ctrl.msg {
2547                out.push(s.cursor.expect("a SetCursor carries a cursor"));
2548            }
2549        }
2550        out
2551    }
2552
2553    #[tokio::test]
2554    async fn set_cursor_supersedes_a_pending_transient_retry() {
2555        // Regression for the #455 review: a deferred transient-reject retry must
2556        // not re-send a now-stale anchor after the caller has explicitly
2557        // re-anchored elsewhere. The exposing sequence is the #453 actor pattern —
2558        // cancel `next` mid-retry, then issue `set_cursor` before resuming.
2559        let store = Arc::new(MemStore::default());
2560        let (handle, mut rx) = crate::client::WatchHandle::for_test();
2561        let mut w = watch_with(&store);
2562        w.handle = Some(handle);
2563        w.desired_cursor = Some(cur(100));
2564
2565        // A transient reject defers a retry that would re-send the anchor at 100.
2566        w.handle_event(
2567            Event::CursorRejected { reason: CursorRejectReason::RateLimited, current_head: None },
2568            None,
2569        )
2570        .await
2571        .unwrap();
2572        assert!(w.pending_reanchor.is_some(), "retry deferred");
2573
2574        // The caller now re-anchors to 200: sends set_cursor(200) live, and must
2575        // supersede the deferred retry for 100.
2576        w.set_cursor(cur(200)).await.unwrap();
2577        assert!(w.pending_reanchor.is_none(), "explicit re-anchor drops the stale deferred retry");
2578        assert_eq!(drained_set_cursors(&mut rx), vec![cur(200)], "only the fresh anchor is sent");
2579
2580        // Driving the (now-cleared) retry re-sends nothing — in particular not the
2581        // stale 100 that would clobber the caller's 200.
2582        w.drive_pending_reanchor().await.unwrap();
2583        assert!(drained_set_cursors(&mut rx).is_empty(), "a superseded retry re-sends nothing");
2584    }
2585
2586    #[tokio::test]
2587    async fn a_deferred_retry_re_sends_the_live_desired_cursor() {
2588        // The drive reads `desired_cursor` live, not a snapshot taken when the
2589        // reject was recorded, so an anchor that moved between the reject and the
2590        // drive is what re-lands on the wire.
2591        let store = Arc::new(MemStore::default());
2592        let (handle, mut rx) = crate::client::WatchHandle::for_test();
2593        let mut w = ResilientWatch::new(
2594            StreamClient::for_test(),
2595            ResilientWatchConfig::new().cursor_store(store.clone()).backoff(Backoff {
2596                initial: std::time::Duration::ZERO,
2597                max: std::time::Duration::ZERO,
2598                multiplier: 1.0,
2599                max_retries: Some(3),
2600            }),
2601        );
2602        w.handle = Some(handle);
2603        w.desired_cursor = Some(cur(100));
2604
2605        // Defer a retry (recorded while the desire is 100)…
2606        w.handle_event(
2607            Event::CursorRejected {
2608                reason: CursorRejectReason::ConcurrentReanchor,
2609                current_head: None,
2610            },
2611            None,
2612        )
2613        .await
2614        .unwrap();
2615        // …then the desire advances to 200 while the retry is still pending.
2616        w.desired_cursor = Some(cur(200));
2617
2618        // The drive re-sends the live 200, never the stale 100.
2619        w.drive_pending_reanchor().await.unwrap();
2620        assert_eq!(drained_set_cursors(&mut rx), vec![cur(200)], "the live anchor is re-sent");
2621        assert!(w.pending_reanchor.is_none(), "the retry completed");
2622    }
2623
2624    // --- one-shot watch eviction sync -----------------------------------------
2625
2626    #[tokio::test]
2627    async fn fired_depth_alarm_is_pruned_from_the_mirror() {
2628        let store = Arc::new(MemStore::default());
2629        let mut w = watch_with(&store);
2630        // Arm two alarms on one txid; the server fires and self-evicts the
2631        // depth-3 one (reporting the requested threshold 3, not actual confs).
2632        w.add_depth_alarms([[7u8; 32]], [3, 6]).await.unwrap();
2633        let ev = Event::TxidDepthReached { txid: [7u8; 32].to_vec(), depth: 3, height: 100 };
2634        let out = w.handle_event(ev, None).await.unwrap();
2635        assert!(matches!(out, Some(Event::TxidDepthReached { .. })), "still surfaced to caller");
2636        // Fired (txid,3) pruned so a reconnect won't re-register it; (txid,6)
2637        // stays for replay.
2638        assert!(!w.mirror.depth_alarms.contains(&([7u8; 32], 3)), "fired alarm pruned");
2639        assert!(w.mirror.depth_alarms.contains(&([7u8; 32], 6)), "unfired alarm retained");
2640    }
2641
2642    #[tokio::test]
2643    async fn finalized_lifecycle_watch_is_pruned_from_the_mirror() {
2644        let store = Arc::new(MemStore::default());
2645        let mut w = watch_with(&store);
2646        w.add_tx_lifecycle([[8u8; 32]], AutoClose::AtDepth(6)).await.unwrap();
2647        let ev = Event::TxidFinalized { txid: [8u8; 32].to_vec(), depth: 6, height: 200 };
2648        let out = w.handle_event(ev, None).await.unwrap();
2649        assert!(matches!(out, Some(Event::TxidFinalized { .. })), "still surfaced to caller");
2650        assert!(
2651            !w.mirror.tx_lifecycles.contains_key(&[8u8; 32]),
2652            "auto-close finalize prunes the lifecycle watch the server evicted"
2653        );
2654    }
2655
2656    // --- refused adds leave the mirror ----------------------------------------
2657
2658    fn refused(r: pb::WatchAddRejected) -> Event {
2659        Event::WatchAddRejected(WatchAddRejected::from_proto(r))
2660    }
2661
2662    #[tokio::test]
2663    async fn refused_adds_are_pruned_from_the_mirror() {
2664        use pb::watch_add_rejected::{Kind, Reason};
2665        let store = Arc::new(MemStore::default());
2666        let mut w = watch_with(&store);
2667        w.add_scripts([([1u8; 32], None), ([2u8; 32], None)]).await.unwrap();
2668        w.add_outpoints([([3u8; 32], 0), ([3u8; 32], 1)]).await.unwrap();
2669        // 12 bits: the caller's low nibble survives in the mirror, the server
2670        // echoes it masked.
2671        w.add_script_prefixes([(vec![0xab, 0xcd], 12)]).await.unwrap();
2672
2673        let out = w
2674            .handle_event(
2675                refused(pb::WatchAddRejected {
2676                    kind: Kind::Scripts as i32,
2677                    reason: Reason::QuotaExceeded as i32,
2678                    scripthashes: vec![vec![2u8; 32]],
2679                    ..Default::default()
2680                }),
2681                None,
2682            )
2683            .await
2684            .unwrap();
2685        assert!(matches!(out, Some(Event::WatchAddRejected(_))), "still surfaced to the caller");
2686        assert!(w.mirror.scripts.contains_key(&[1u8; 32]), "an item the server holds stays");
2687        assert!(!w.mirror.scripts.contains_key(&[2u8; 32]), "a refused script is dropped");
2688
2689        w.handle_event(
2690            refused(pb::WatchAddRejected {
2691                kind: Kind::Outpoints as i32,
2692                reason: Reason::PermissionDenied as i32,
2693                outpoints: vec![pb::Outpoint { txid: vec![3u8; 32], vout: 1 }],
2694                ..Default::default()
2695            }),
2696            None,
2697        )
2698        .await
2699        .unwrap();
2700        assert!(w.mirror.outpoints.contains(&([3u8; 32], 0)));
2701        assert!(!w.mirror.outpoints.contains(&([3u8; 32], 1)));
2702
2703        w.handle_event(
2704            refused(pb::WatchAddRejected {
2705                kind: Kind::ScriptPrefixes as i32,
2706                reason: Reason::CapExceeded as i32,
2707                prefixes: vec![pb::ScriptPrefix { prefix: vec![0xab, 0xc0], bits: 12 }],
2708                ..Default::default()
2709            }),
2710            None,
2711        )
2712        .await
2713        .unwrap();
2714        assert!(w.mirror.prefixes.is_empty(), "the masked echo matches the caller's prefix");
2715    }
2716
2717    #[tokio::test]
2718    async fn refused_descriptor_slide_falls_back_to_the_window_the_server_holds() {
2719        use pb::watch_add_rejected::{Kind, Reason};
2720        let store = Arc::new(MemStore::default());
2721        let mut w = watch_with(&store);
2722        w.add_descriptor("wpkh(a)", 20, 0).await.unwrap();
2723        w.add_descriptor("wpkh(a)", 20, 20).await.unwrap();
2724        w.add_descriptor("wpkh(b)", 20, 0).await.unwrap();
2725
2726        // The slide of `a` to start 20 is refused; the server keeps start 0.
2727        w.handle_event(
2728            refused(pb::WatchAddRejected {
2729                kind: Kind::Descriptor as i32,
2730                reason: Reason::QuotaExceeded as i32,
2731                descriptor: "wpkh(a)".into(),
2732                gap_limit: 20,
2733                start: 20,
2734                descriptor_kept: true,
2735                ..Default::default()
2736            }),
2737            None,
2738        )
2739        .await
2740        .unwrap();
2741        assert_eq!(w.mirror.descriptors.get("wpkh(a)"), Some(&(20, 0)), "replays the held window");
2742
2743        // A brand-new descriptor that is refused is not watched at all.
2744        w.handle_event(
2745            refused(pb::WatchAddRejected {
2746                kind: Kind::Descriptor as i32,
2747                reason: Reason::CapExceeded as i32,
2748                descriptor: "wpkh(b)".into(),
2749                gap_limit: 20,
2750                start: 0,
2751                descriptor_kept: false,
2752                ..Default::default()
2753            }),
2754            None,
2755        )
2756        .await
2757        .unwrap();
2758        assert!(!w.mirror.descriptors.contains_key("wpkh(b)"));
2759
2760        // Two slides in flight, both refused: the fallback is the window held
2761        // before either, not the first refused one.
2762        w.add_descriptor("wpkh(c)", 20, 0).await.unwrap();
2763        w.add_descriptor("wpkh(c)", 20, 20).await.unwrap();
2764        w.add_descriptor("wpkh(c)", 20, 40).await.unwrap();
2765        for start in [20, 40] {
2766            w.handle_event(
2767                refused(pb::WatchAddRejected {
2768                    kind: Kind::Descriptor as i32,
2769                    reason: Reason::QuotaExceeded as i32,
2770                    descriptor: "wpkh(c)".into(),
2771                    gap_limit: 20,
2772                    start,
2773                    descriptor_kept: true,
2774                    ..Default::default()
2775                }),
2776                None,
2777            )
2778            .await
2779            .unwrap();
2780        }
2781        assert_eq!(w.mirror.descriptors.get("wpkh(c)"), Some(&(20, 0)), "falls back past both refusals");
2782
2783        // A refusal for a window the caller already slid past changes nothing.
2784        w.add_descriptor("wpkh(a)", 20, 40).await.unwrap();
2785        w.handle_event(
2786            refused(pb::WatchAddRejected {
2787                kind: Kind::Descriptor as i32,
2788                reason: Reason::QuotaExceeded as i32,
2789                descriptor: "wpkh(a)".into(),
2790                gap_limit: 20,
2791                start: 20,
2792                descriptor_kept: true,
2793                ..Default::default()
2794            }),
2795            None,
2796        )
2797        .await
2798        .unwrap();
2799        assert_eq!(w.mirror.descriptors.get("wpkh(a)"), Some(&(20, 40)));
2800    }
2801
2802    fn zero_backoff(max_retries: Option<u32>) -> Backoff {
2803        Backoff {
2804            initial: std::time::Duration::ZERO,
2805            max: std::time::Duration::ZERO,
2806            multiplier: 1.0,
2807            max_retries,
2808        }
2809    }
2810
2811    fn rate_limited_scripts(shs: &[u8]) -> Event {
2812        use pb::watch_add_rejected::{Kind, Reason};
2813        refused(pb::WatchAddRejected {
2814            kind: Kind::Scripts as i32,
2815            reason: Reason::RateLimited as i32,
2816            retry_after_secs: 0,
2817            scripthashes: shs.iter().map(|b| vec![*b; 32]).collect(),
2818            ..Default::default()
2819        })
2820    }
2821
2822    fn sent_scripts(rx: &mut tokio::sync::mpsc::Receiver<pb::SubscribeControl>) -> Vec<pb::AddScripts> {
2823        let mut out = Vec::new();
2824        while let Ok(ctrl) = rx.try_recv() {
2825            if let Some(Msg::AddScripts(a)) = ctrl.msg {
2826                out.push(a);
2827            }
2828        }
2829        out
2830    }
2831
2832    #[tokio::test]
2833    async fn rate_limited_add_is_re_sent_from_the_mirror() {
2834        let store = Arc::new(MemStore::default());
2835        let (handle, mut rx) = crate::client::WatchHandle::for_test();
2836        let mut w = ResilientWatch::new(
2837            StreamClient::for_test(),
2838            ResilientWatchConfig::new().cursor_store(store.clone()).backoff(zero_backoff(Some(3))),
2839        );
2840        w.handle = Some(handle);
2841        w.add_scripts([([1u8; 32], None), ([2u8; 32], Some(500)), ([3u8; 32], None)]).await.unwrap();
2842        let _ = sent_scripts(&mut rx);
2843
2844        // The node throttled the add of 2 and 3: the event is absorbed, and both
2845        // stay in the mirror for the retry.
2846        let out = w.handle_event(rate_limited_scripts(&[2, 3]), None).await.unwrap();
2847        assert!(out.is_none(), "a rate-limited add is retried, not surfaced");
2848        assert!(w.mirror.scripts.contains_key(&[2u8; 32]) && w.mirror.scripts.contains_key(&[3u8; 32]));
2849        assert_eq!(w.pending_add_retries.len(), 1);
2850
2851        // The caller removes 3 before the retry is due: the removal wins.
2852        w.remove_scripts([[3u8; 32]]).await.unwrap();
2853        while rx.try_recv().is_ok() {}
2854        w.send_add_retry(0).await.unwrap();
2855        let sent = sent_scripts(&mut rx);
2856        assert_eq!(sent.len(), 1, "one re-send: {sent:?}");
2857        assert_eq!(sent[0].scripthashes, vec![vec![2u8; 32]], "only what the caller still holds");
2858        assert_eq!(sent[0].min_values, vec![500], "with its current floor");
2859        assert!(w.pending_add_retries.is_empty());
2860    }
2861
2862    #[tokio::test]
2863    async fn rate_limited_add_is_dropped_and_surfaced_once_the_budget_runs_out() {
2864        let store = Arc::new(MemStore::default());
2865        let mut w = ResilientWatch::new(
2866            StreamClient::for_test(),
2867            ResilientWatchConfig::new().cursor_store(store.clone()).backoff(zero_backoff(Some(1))),
2868        );
2869        w.add_scripts([([2u8; 32], None)]).await.unwrap();
2870        assert!(w.handle_event(rate_limited_scripts(&[2]), None).await.unwrap().is_none(), "retry 1");
2871        // The retried add is throttled again: the budget of 1 is spent.
2872        let out = w.handle_event(rate_limited_scripts(&[2]), None).await.unwrap();
2873        assert!(matches!(out, Some(Event::WatchAddRejected(_))), "surfaced once the budget is spent");
2874        assert!(!w.mirror.scripts.contains_key(&[2u8; 32]), "and dropped from the mirror");
2875        assert!(w.add_retry_attempts.is_empty(), "its budget is released");
2876    }
2877
2878    #[tokio::test]
2879    async fn a_reconnect_resets_pending_add_retries() {
2880        let store = Arc::new(MemStore::default());
2881        let mut w = ResilientWatch::new(
2882            StreamClient::for_test(),
2883            ResilientWatchConfig::new().cursor_store(store.clone()).backoff(zero_backoff(Some(3))),
2884        );
2885        w.add_scripts([([2u8; 32], None)]).await.unwrap();
2886        w.handle_event(rate_limited_scripts(&[2]), None).await.unwrap();
2887        w.teardown();
2888        assert!(w.pending_add_retries.is_empty() && w.add_retry_attempts.is_empty());
2889        assert!(w.mirror.scripts.contains_key(&[2u8; 32]), "the reconnect re-sends it with the mirror");
2890    }
2891
2892    // --- cursor persistence (commit-on-poll) ----------------------------------
2893
2894    #[tokio::test]
2895    async fn confirmed_cursor_commits_on_the_following_poll() {
2896        let store = Arc::new(MemStore::default());
2897        let mut w = watch_with(&store);
2898        let c = cur(900);
2899
2900        // Deliver a confirmed event carrying cursor c.
2901        w.commit_due().unwrap();
2902        let out = w
2903            .handle_event(Event::BlockConnected { hash: vec![0xaa], height: 900 }, Some(c))
2904            .await
2905            .unwrap();
2906        assert!(out.is_some());
2907        w.arm_commit();
2908        // Not yet committed — the caller has the event but hasn't polled again.
2909        assert_eq!(store.load().unwrap(), None);
2910
2911        // The next poll acks it.
2912        w.commit_due().unwrap();
2913        assert_eq!(store.load().unwrap(), Some(c));
2914    }
2915
2916    /// A store that fails its first `n` saves, then behaves — a transient I/O
2917    /// failure (a briefly full disk, an unwritable mount), not a permanent one.
2918    #[derive(Default)]
2919    struct FlakyStore {
2920        inner: Mutex<Option<Cursor>>,
2921        fail_next: Mutex<u32>,
2922    }
2923    impl CursorStore for FlakyStore {
2924        fn load(&self) -> Result<Option<Cursor>, StreamError> {
2925            Ok(*self.inner.lock().unwrap())
2926        }
2927        fn save(&self, cursor: Cursor) -> Result<(), StreamError> {
2928            let mut left = self.fail_next.lock().unwrap();
2929            if *left > 0 {
2930                *left -= 1;
2931                return Err(StreamError::Decode("disk full".into()));
2932            }
2933            *self.inner.lock().unwrap() = Some(cursor);
2934            Ok(())
2935        }
2936    }
2937
2938    /// A failed store write must leave the cursor ARMED so the retry writes it.
2939    ///
2940    /// `ResilientWatch` carries its own copy of `commit_due`, so the
2941    /// subscription-side regression test does not cover this one. Same bug, same
2942    /// consequence: taking the arm before the write made the retry return
2943    /// `Ok(())` with nothing persisted, and a caller committing before shutdown
2944    /// exits believing a cursor is durable that was never written.
2945    #[tokio::test]
2946    async fn commit_retry_after_a_store_failure_actually_writes() {
2947        let store = Arc::new(FlakyStore { fail_next: Mutex::new(1), ..Default::default() });
2948        let mut w = ResilientWatch::new(
2949            StreamClient::for_test(),
2950            ResilientWatchConfig { cursor_store: store.clone(), ..Default::default() },
2951        );
2952        let c = cur(900);
2953
2954        w.handle_event(Event::BlockConnected { hash: vec![0xaa], height: 900 }, Some(c))
2955            .await
2956            .unwrap();
2957        w.arm_commit();
2958
2959        let err = w.commit_due().unwrap_err();
2960        assert!(matches!(err, StreamError::Decode(_)), "the transient failure surfaces");
2961        assert_eq!(store.load().unwrap(), None, "nothing was written");
2962
2963        w.commit_due().expect("retry after a transient failure");
2964        assert_eq!(
2965            store.load().unwrap(),
2966            Some(c),
2967            "the retried commit must persist the cursor the failed one armed; \
2968             returning Ok() with an empty store is a false durability ack"
2969        );
2970    }
2971
2972    #[tokio::test]
2973    async fn seed_resume_prefers_store_over_from_cursor() {
2974        let store = Arc::new(MemStore::default());
2975        store.save(cur(800)).unwrap();
2976        let mut w = ResilientWatch::new(
2977            StreamClient::for_test(),
2978            ResilientWatchConfig::new().cursor_store(store.clone()).from_cursor(cur(1)),
2979        );
2980        w.seed_resume().unwrap();
2981        assert_eq!(w.resume, Some(cur(800)), "persisted cursor wins over from_cursor");
2982        assert_eq!(w.committed, Some(cur(800)), "loaded cursor seeds committed");
2983    }
2984
2985    // --- watch-set loader (#447) ----------------------------------------------
2986
2987    /// Drive the loader step exactly as `connect` does — populate a fresh shared
2988    /// mirror through the builder, then take it — without needing a live stream.
2989    async fn run_loader(config: &ResilientWatchConfig) -> Result<WatchSetMirror, StreamError> {
2990        let loader = config.watch_set_loader.clone().expect("loader configured");
2991        let shared = Arc::new(Mutex::new(WatchSetMirror::default()));
2992        loader(WatchSetBuilder { mirror: shared.clone() })
2993            .await
2994            .map_err(StreamError::WatchSetLoader)?;
2995        Ok(std::mem::take(&mut *shared.lock().unwrap()))
2996    }
2997
2998    #[test]
2999    fn builder_facade_records_every_watch_kind() {
3000        let m = Arc::new(Mutex::new(WatchSetMirror::default()));
3001        let b = WatchSetBuilder { mirror: m.clone() };
3002        b.set_categories(crate::Categories::CHAIN);
3003        b.add_scripts([([1u8; 32], Some(5_000))]);
3004        b.add_outpoints([([2u8; 32], 0)]);
3005        b.add_tx_lifecycle([[3u8; 32]], AutoClose::AtDepth(6));
3006        b.add_depth_alarms([[4u8; 32]], [3, 6]);
3007        b.add_descriptor("wpkh(xpub...)", 20, 0);
3008        b.add_script_prefixes([(vec![0xab], 8)]).unwrap();
3009
3010        let mirror = std::mem::take(&mut *m.lock().unwrap());
3011        let kinds: Vec<_> = mirror
3012            .control_messages()
3013            .into_iter()
3014            .map(|msg| std::mem::discriminant(&msg))
3015            .collect();
3016        // Categories + scripts + outpoints + lifecycle + depth-alarm + descriptor
3017        // + prefixes = 7 messages, the same shapes a manual replay emits.
3018        assert_eq!(kinds.len(), 7, "every declared kind renders to a control message");
3019        assert!(
3020            matches!(mirror.control_messages()[0], Msg::SetCategories(_)),
3021            "categories still lead the replay"
3022        );
3023    }
3024
3025    #[test]
3026    fn builder_depth_alarms_filter_and_cross_product() {
3027        let m = Arc::new(Mutex::new(WatchSetMirror::default()));
3028        let b = WatchSetBuilder { mirror: m.clone() };
3029        // Depth 0 is invalid and dropped; the two txids × the one valid depth (5)
3030        // cross-multiply into two alarms.
3031        b.add_depth_alarms([[1u8; 32], [2u8; 32]], [0, 5]);
3032        let mirror = std::mem::take(&mut *m.lock().unwrap());
3033        assert!(mirror.depth_alarms.contains(&([1u8; 32], 5)));
3034        assert!(mirror.depth_alarms.contains(&([2u8; 32], 5)));
3035        assert_eq!(mirror.depth_alarms.len(), 2, "depth 0 dropped, depth 5 kept");
3036    }
3037
3038    #[test]
3039    fn builder_prefix_validation_error_aborts_the_load() {
3040        let m = Arc::new(Mutex::new(WatchSetMirror::default()));
3041        let b = WatchSetBuilder { mirror: m };
3042        // bits 0 is out of the 1..=32 range — rejected the same as the live path.
3043        assert!(matches!(
3044            b.add_script_prefixes([(vec![], 0)]),
3045            Err(StreamError::InvalidArgument(_))
3046        ));
3047    }
3048
3049    #[tokio::test]
3050    async fn loader_rebuilds_the_canonical_watch_set() {
3051        let config = ResilientWatchConfig::new().watch_set_loader(|b: WatchSetBuilder| async move {
3052            // Stand-in for "query the durable truth, then declare the set."
3053            b.add_scripts([([7u8; 32], None), ([8u8; 32], Some(1_000))]);
3054            b.add_outpoints([([9u8; 32], 1)]);
3055            Ok(())
3056        });
3057        let mirror = run_loader(&config).await.expect("loader succeeds");
3058        assert!(mirror.scripts.contains_key(&[7u8; 32]));
3059        assert_eq!(mirror.scripts.get(&[8u8; 32]), Some(&Some(1_000)));
3060        assert!(mirror.outpoints.contains(&([9u8; 32], 1)));
3061    }
3062
3063    #[tokio::test]
3064    async fn loader_canonical_set_replaces_prior_in_process_edits() {
3065        // A loaded canonical set must REPLACE whatever the in-process mirror held
3066        // (truth-of-record is external), not merge with it.
3067        let mut w = ResilientWatch::new(
3068            StreamClient::for_test(),
3069            ResilientWatchConfig::new().watch_set_loader(|b: WatchSetBuilder| async move {
3070                b.add_scripts([([2u8; 32], None)]);
3071                Ok(())
3072            }),
3073        );
3074        // Pre-existing in-process edit the loader does not know about.
3075        w.add_scripts([([1u8; 32], None)]).await.unwrap();
3076        assert!(w.mirror.scripts.contains_key(&[1u8; 32]));
3077        // Adopt the loaded set the way `connect` does.
3078        w.mirror = run_loader(&w.config).await.unwrap();
3079        assert!(!w.mirror.scripts.contains_key(&[1u8; 32]), "stale in-process edit dropped");
3080        assert!(w.mirror.scripts.contains_key(&[2u8; 32]), "canonical loaded script present");
3081    }
3082
3083    #[tokio::test]
3084    async fn loader_error_maps_to_watch_set_loader_and_is_reconnectable() {
3085        let config = ResilientWatchConfig::new().watch_set_loader(|_b: WatchSetBuilder| async move {
3086            Err("source-of-truth unreachable".into())
3087        });
3088        let err = run_loader(&config).await.expect_err("loader failure surfaces");
3089        assert!(matches!(err, StreamError::WatchSetLoader(_)));
3090        assert!(
3091            is_reconnectable(&err),
3092            "a loader failure is a transient reconnect-level condition, not caller-facing"
3093        );
3094        // And the inner integrator message is preserved.
3095        assert!(err.to_string().contains("source-of-truth unreachable"));
3096    }
3097
3098    #[test]
3099    fn no_loader_leaves_behavior_unchanged() {
3100        // The default config has no loader: mirror-replay is untouched.
3101        assert!(ResilientWatchConfig::new().watch_set_loader.is_none());
3102    }
3103
3104    // --- reload(): diff the live set against truth and apply the delta ---------
3105
3106    #[test]
3107    fn reconcile_to_emits_only_the_delta_removes_before_adds() {
3108        // Old set vs a target that adds, removes, and changes one of each kind.
3109        let mut old = WatchSetMirror::default();
3110        old.add_scripts(&[([1u8; 32], None), ([2u8; 32], Some(500))]); // 2 kept-ish
3111        old.add_outpoints(&[([9u8; 32], 0)]);
3112        old.add_descriptor("desc-keep".into(), 20, 0);
3113        old.add_descriptor("desc-drop".into(), 20, 0);
3114
3115        let mut new = WatchSetMirror::default();
3116        new.add_scripts(&[([1u8; 32], None), ([2u8; 32], Some(999))]); // sh2 floor changed
3117        new.add_scripts(&[([3u8; 32], None)]); // sh3 new
3118        // sh? none removed here besides via descriptors; outpoint 9 removed (absent)
3119        new.add_descriptor("desc-keep".into(), 20, 0); // unchanged
3120        // desc-drop removed; desc-new added
3121        new.add_descriptor("desc-new".into(), 20, 0);
3122
3123        let (msgs, counts) = old.reconcile_to(&new);
3124
3125        // sh3 + sh2(changed) added (2), desc-new added (1) → added 3.
3126        // outpoint 9 removed (1), desc-drop removed (1) → removed 2.
3127        // sh1 + desc-keep unchanged → unchanged 2.
3128        assert_eq!(counts.added, 3, "sh2(changed) + sh3 + desc-new");
3129        assert_eq!(counts.removed, 2, "outpoint + desc-drop");
3130        assert_eq!(counts.unchanged, 2, "sh1 + desc-keep");
3131
3132        // Every Remove*/RemoveDescriptor comes before any watch-adding Add* — a
3133        // removal must free its quota unit before the replacement add claims it,
3134        // so a swap at quota does not have its adds silently rejected.
3135        let last_remove = msgs.iter().rposition(|m| {
3136            matches!(
3137                m,
3138                Msg::RemoveScripts(_)
3139                    | Msg::RemoveOutpoints(_)
3140                    | Msg::RemoveTransactions(_)
3141                    | Msg::RemoveScriptPrefixes(_)
3142                    | Msg::RemoveDescriptor(_)
3143            )
3144        });
3145        let first_add = msgs.iter().position(|m| {
3146            matches!(
3147                m,
3148                Msg::AddScripts(_)
3149                    | Msg::AddOutpoints(_)
3150                    | Msg::AddTransactions(_)
3151                    | Msg::AddDescriptor(_)
3152                    | Msg::AddScriptPrefixes(_)
3153            )
3154        });
3155        if let (Some(lr), Some(fa)) = (last_remove, first_add) {
3156            assert!(lr < fa, "every Remove must precede every quota-consuming Add");
3157        }
3158        // A vanished descriptor is dropped with RemoveDescriptor, not by scripthash.
3159        assert!(msgs.iter().any(|m| matches!(m, Msg::RemoveDescriptor(d) if d.descriptor == "desc-drop")));
3160    }
3161
3162    #[test]
3163    fn reconcile_disjoint_swap_removes_before_adds_for_quota_safety() {
3164        // A tenant at quota N reloading N old watches → N disjoint new watches.
3165        // The removes must precede the adds so the server frees the N old units
3166        // before the N new adds are charged; add-first would let the server
3167        // silently reject the over-quota adds and then apply the removes.
3168        let mut live = WatchSetMirror::default();
3169        live.add_scripts(&[([1u8; 32], None), ([2u8; 32], None), ([3u8; 32], None)]);
3170        let mut target = WatchSetMirror::default();
3171        target.add_scripts(&[([4u8; 32], None), ([5u8; 32], None), ([6u8; 32], None)]);
3172
3173        let (msgs, counts) = live.reconcile_to(&target);
3174        assert_eq!((counts.added, counts.removed, counts.unchanged), (3, 3, 0));
3175
3176        let remove_pos = msgs.iter().position(|m| matches!(m, Msg::RemoveScripts(_)));
3177        let add_pos = msgs.iter().position(|m| matches!(m, Msg::AddScripts(_)));
3178        assert!(
3179            remove_pos < add_pos,
3180            "RemoveScripts must be sent before AddScripts so freed quota covers the replacements",
3181        );
3182    }
3183
3184    #[test]
3185    fn reconcile_identical_sets_is_empty() {
3186        let mut m = WatchSetMirror::default();
3187        m.add_scripts(&[([1u8; 32], Some(5))]);
3188        m.add_descriptor("d".into(), 20, 0);
3189        let (msgs, counts) = m.reconcile_to(&m.clone());
3190        assert!(msgs.is_empty(), "no churn when nothing changed");
3191        assert_eq!((counts.added, counts.removed), (0, 0));
3192        assert_eq!(counts.unchanged, 2);
3193    }
3194
3195    #[test]
3196    fn reconcile_categories_relaxation_emits_an_explicit_reset() {
3197        // Live server is filtered to CHAIN; truth (the reload target) has no
3198        // category filter. reconcile must emit SetCategories{0} ("all") so the
3199        // relaxation takes effect live — not silently leave the server filtered.
3200        let mut live = WatchSetMirror::default();
3201        live.set_categories(crate::Categories::CHAIN);
3202        let target = WatchSetMirror::default(); // no filter
3203
3204        let (msgs, _) = live.reconcile_to(&target);
3205        assert_eq!(
3206            msgs.iter()
3207                .filter_map(|m| match m {
3208                    Msg::SetCategories(s) => Some(s.categories),
3209                    _ => None,
3210                })
3211                .collect::<Vec<_>>(),
3212            vec![0],
3213            "relaxing a filter to all-categories emits SetCategories{{0}}",
3214        );
3215
3216        // Setting a filter from none, and changing between filters, still emit.
3217        let none = WatchSetMirror::default();
3218        let mut chain = WatchSetMirror::default();
3219        chain.set_categories(crate::Categories::CHAIN);
3220        assert!(
3221            matches!(none.reconcile_to(&chain).0.first(), Some(Msg::SetCategories(s)) if s.categories == crate::Categories::CHAIN),
3222            "setting a filter from unfiltered emits it",
3223        );
3224
3225        // None ↔ Some(0) are both "all categories": no spurious message.
3226        let mut all = WatchSetMirror::default();
3227        all.set_categories(0);
3228        assert!(
3229            !none.reconcile_to(&all).0.iter().any(|m| matches!(m, Msg::SetCategories(_))),
3230            "None and Some(0) are the same effective filter — no reset emitted",
3231        );
3232    }
3233
3234    #[tokio::test]
3235    async fn reload_without_loader_errors() {
3236        let store = Arc::new(MemStore::default());
3237        let mut w = watch_with(&store);
3238        assert!(matches!(w.reload().await, Err(ReloadError::NoLoader)));
3239    }
3240
3241    #[tokio::test]
3242    async fn reload_surfaces_loader_error() {
3243        let mut w = ResilientWatch::new(
3244            StreamClient::for_test(),
3245            ResilientWatchConfig::new()
3246                .watch_set_loader(|_b: WatchSetBuilder| async move { Err("truth down".into()) }),
3247        );
3248        let err = w.reload().await.expect_err("loader failure surfaces");
3249        assert!(matches!(err, ReloadError::Loader(_)));
3250        assert!(err.to_string().contains("truth down"));
3251    }
3252
3253    #[tokio::test]
3254    async fn reload_while_disconnected_defers_but_updates_the_mirror() {
3255        let mut w = ResilientWatch::new(
3256            StreamClient::for_test(),
3257            ResilientWatchConfig::new().watch_set_loader(|b: WatchSetBuilder| async move {
3258                b.add_scripts([([5u8; 32], None)]);
3259                Ok(())
3260            }),
3261        );
3262        // Pre-existing live edit the loader will not reproduce.
3263        w.add_scripts([([1u8; 32], None)]).await.unwrap();
3264
3265        let summary = w.reload().await.expect("reload ok");
3266        assert!(!summary.applied, "no live stream → deferred to reconnect loader");
3267        assert_eq!(summary.added, 1, "sh5 added");
3268        assert_eq!(summary.removed, 1, "stale sh1 removed");
3269        // The mirror now reflects truth, so a reconnect re-registers the right set.
3270        assert!(w.mirror.scripts.contains_key(&[5u8; 32]));
3271        assert!(!w.mirror.scripts.contains_key(&[1u8; 32]));
3272    }
3273
3274    #[tokio::test]
3275    async fn reload_while_connected_sends_only_the_delta() {
3276        let mut w = ResilientWatch::new(
3277            StreamClient::for_test(),
3278            ResilientWatchConfig::new().watch_set_loader(|b: WatchSetBuilder| async move {
3279                // Truth: keep sh1, drop sh2, add sh3.
3280                b.add_scripts([([1u8; 32], None), ([3u8; 32], None)]);
3281                Ok(())
3282            }),
3283        );
3284        w.add_scripts([([1u8; 32], None), ([2u8; 32], None)]).await.unwrap();
3285
3286        // Inject a live handle whose receiver we can inspect.
3287        let (handle, mut rx) = crate::client::WatchHandle::for_test();
3288        w.handle = Some(handle);
3289
3290        let summary = w.reload().await.expect("reload ok");
3291        assert!(summary.applied, "connected → applied live");
3292        // Advisory client counts (server's WatchSetResult is authoritative).
3293        assert_eq!((summary.added, summary.removed, summary.unchanged), (1, 1, 1));
3294
3295        // Exactly ONE SetWatchSet carrying the whole desired set (sh1 + sh3)
3296        // reached the wire — no client-computed Add*/Remove* delta.
3297        let mut msgs = Vec::new();
3298        while let Ok(ctrl) = rx.try_recv() {
3299            msgs.push(ctrl.msg.unwrap());
3300        }
3301        assert_eq!(msgs.len(), 1, "reload sends exactly one control message");
3302        let Msg::SetWatchSet(sws) = &msgs[0] else {
3303            panic!("expected a SetWatchSet, got {:?}", msgs[0]);
3304        };
3305        let mut got: Vec<Vec<u8>> = sws.scripthashes.clone();
3306        got.sort();
3307        assert_eq!(
3308            got,
3309            vec![[1u8; 32].to_vec(), [3u8; 32].to_vec()],
3310            "the snapshot is the full desired set, not a delta",
3311        );
3312    }
3313
3314    #[tokio::test]
3315    async fn reload_turns_off_raw_tx_opt_in_when_truth_drops_it() {
3316        // A live stream opted into raw_tx directly; the reloaded truth does NOT
3317        // declare it (a delivery knob, not watch-set truth — the common case).
3318        // reload must explicitly send SetWatchOptions{false} so the server stops
3319        // inlining full txs, not silently leave it on until an incidental
3320        // reconnect. SetWatchSet does not carry the opt-in, so this is the only
3321        // signal that reconciles it.
3322        let mut w = ResilientWatch::new(
3323            StreamClient::for_test(),
3324            ResilientWatchConfig::new().watch_set_loader(|b: WatchSetBuilder| async move {
3325                b.add_scripts([([1u8; 32], None)]);
3326                Ok(())
3327            }),
3328        );
3329        w.add_scripts([([1u8; 32], None)]).await.unwrap();
3330        w.set_watch_options(true).await.unwrap(); // opt in on the live mirror
3331        assert_eq!(w.mirror.include_raw_tx, Some(true));
3332
3333        let (handle, mut rx) = crate::client::WatchHandle::for_test();
3334        w.handle = Some(handle);
3335
3336        w.reload().await.expect("reload ok");
3337
3338        let mut msgs = Vec::new();
3339        while let Ok(ctrl) = rx.try_recv() {
3340            msgs.push(ctrl.msg.unwrap());
3341        }
3342        // reload sent the SetWatchSet snapshot AND an explicit turn-off.
3343        let opt = msgs.iter().find_map(|m| match m {
3344            Msg::SetWatchOptions(o) => Some(o.include_raw_tx),
3345            _ => None,
3346        });
3347        assert_eq!(opt, Some(false), "reload turns the opt-in OFF to match truth");
3348        // The mirror now reflects truth (no opt-in), so a reconnect stays off.
3349        assert_eq!(w.mirror.include_raw_tx, None);
3350    }
3351
3352    #[tokio::test]
3353    async fn reload_does_not_touch_raw_tx_opt_in_when_unchanged() {
3354        // Neither the live stream nor truth wants raw_tx → reload must not emit a
3355        // spurious SetWatchOptions (None and false are the same effective state).
3356        let mut w = ResilientWatch::new(
3357            StreamClient::for_test(),
3358            ResilientWatchConfig::new().watch_set_loader(|b: WatchSetBuilder| async move {
3359                b.add_scripts([([1u8; 32], None)]);
3360                Ok(())
3361            }),
3362        );
3363        w.add_scripts([([1u8; 32], None)]).await.unwrap();
3364
3365        let (handle, mut rx) = crate::client::WatchHandle::for_test();
3366        w.handle = Some(handle);
3367
3368        w.reload().await.expect("reload ok");
3369
3370        let mut msgs = Vec::new();
3371        while let Ok(ctrl) = rx.try_recv() {
3372            msgs.push(ctrl.msg.unwrap());
3373        }
3374        assert!(
3375            !msgs.iter().any(|m| matches!(m, Msg::SetWatchOptions(_))),
3376            "no opt-in change → no SetWatchOptions churn, got {msgs:?}",
3377        );
3378    }
3379}