use std::collections::{BTreeMap, BTreeSet, HashMap};
use std::future::Future;
use std::pin::Pin;
use std::sync::{Arc, Mutex};
use satd_events_proto::v1 as pb;
use satd_events_proto::v1::subscribe_control::Msg;
use crate::client::{
validate_prefix, AutoClose, EventStream, SilentPaymentTarget, StreamClient, WatchHandle,
};
use crate::error::StreamError;
use crate::event::{Cursor, CursorRejectReason, Event, WatchAddRejectReason, WatchAddRejected};
use crate::resilience::{Backoff, CursorStore, NoopCursorStore};
type BoxError = Box<dyn std::error::Error + Send + Sync>;
type WatchSetLoaderFn =
dyn Fn(WatchSetBuilder) -> Pin<Box<dyn Future<Output = Result<(), BoxError>> + Send>>
+ Send
+ Sync;
type Scripthash = [u8; 32];
type Txid = [u8; 32];
#[derive(Debug, Default, Clone)]
pub(crate) struct WatchSetMirror {
scripts: BTreeMap<Scripthash, Option<u64>>,
outpoints: BTreeSet<(Txid, u32)>,
tx_lifecycles: BTreeMap<Txid, AutoClose>,
depth_alarms: BTreeSet<(Txid, u32)>,
descriptors: BTreeMap<String, (u32, u32)>,
descriptor_history: BTreeMap<String, Vec<(u32, u32)>>,
prefixes: BTreeSet<(u32, Vec<u8>)>,
silent_payments: BTreeMap<[u8; 33], SilentPaymentTarget>,
categories: Option<u32>,
include_raw_tx: Option<bool>,
}
impl WatchSetMirror {
fn add_scripts(&mut self, items: &[(Scripthash, Option<u64>)]) {
for (h, floor) in items {
self.scripts.insert(*h, *floor);
}
}
fn remove_scripts(&mut self, hashes: &[Scripthash]) {
for h in hashes {
self.scripts.remove(h);
}
}
fn add_outpoints(&mut self, items: &[(Txid, u32)]) {
self.outpoints.extend(items.iter().copied());
}
fn remove_outpoints(&mut self, items: &[(Txid, u32)]) {
for op in items {
self.outpoints.remove(op);
}
}
fn add_tx_lifecycle(&mut self, txids: &[Txid], auto_close: AutoClose) {
for t in txids {
self.tx_lifecycles.insert(*t, auto_close);
}
}
fn remove_tx_lifecycle(&mut self, txids: &[Txid]) {
for t in txids {
self.tx_lifecycles.remove(t);
}
}
fn add_depth_alarms(&mut self, pairs: &[(Txid, u32)]) {
self.depth_alarms.extend(pairs.iter().copied());
}
fn remove_depth_alarms(&mut self, pairs: &[(Txid, u32)]) {
for p in pairs {
self.depth_alarms.remove(p);
}
}
fn add_descriptor(&mut self, descriptor: String, gap_limit: u32, start: u32) {
let window = (gap_limit, start);
match self.descriptors.insert(descriptor.clone(), window) {
Some(prev) if prev != window => {
let history = self.descriptor_history.entry(descriptor).or_default();
history.retain(|w| *w != window);
history.push(prev);
if history.len() > DESCRIPTOR_HISTORY {
history.remove(0);
}
}
Some(_) => {}
None => {
self.descriptor_history.remove(&descriptor);
}
}
}
fn remove_descriptor(&mut self, descriptor: &str) {
self.descriptors.remove(descriptor);
self.descriptor_history.remove(descriptor);
}
fn subset_for(&self, r: &WatchAddRejected) -> WatchSetMirror {
let fixed = |b: &Vec<u8>| <[u8; 32]>::try_from(b.as_slice()).ok();
let mut out = WatchSetMirror::default();
for sh in r.scripthashes.iter().filter_map(fixed) {
if let Some(floor) = self.scripts.get(&sh) {
out.scripts.insert(sh, *floor);
}
}
for (t, v) in &r.outpoints {
if let Some(op) = fixed(t).map(|t| (t, *v)).filter(|op| self.outpoints.contains(op)) {
out.outpoints.insert(op);
}
}
for t in r.txids.iter().filter_map(fixed) {
if let Some(close) = self.tx_lifecycles.get(&t) {
out.tx_lifecycles.insert(t, *close);
}
}
for (t, d) in &r.depth_alarms {
if let Some(alarm) = fixed(t).map(|t| (t, *d)).filter(|a| self.depth_alarms.contains(a)) {
out.depth_alarms.insert(alarm);
}
}
for (prefix, bits) in &r.prefixes {
for (b, p) in &self.prefixes {
if b == bits && mask_prefix(p, *b) == *prefix {
out.prefixes.insert((*b, p.clone()));
}
}
}
for id in r.scan_pubkeys.iter().filter_map(|b| <[u8; 33]>::try_from(b.as_slice()).ok()) {
if let Some(t) = self.silent_payments.get(&id) {
out.silent_payments.insert(id, t.clone());
}
}
if let Some(d) = &r.descriptor
&& self.descriptors.get(&d.descriptor) == Some(&(d.gap_limit, d.start))
{
out.descriptors.insert(d.descriptor.clone(), (d.gap_limit, d.start));
}
out
}
fn forget_rejected(&mut self, r: &WatchAddRejected) {
let fixed = |b: &Vec<u8>| <[u8; 32]>::try_from(b.as_slice()).ok();
let scripts: Vec<Scripthash> = r.scripthashes.iter().filter_map(fixed).collect();
self.remove_scripts(&scripts);
let outpoints: Vec<(Txid, u32)> =
r.outpoints.iter().filter_map(|(t, v)| Some((fixed(t)?, *v))).collect();
self.remove_outpoints(&outpoints);
let txids: Vec<Txid> = r.txids.iter().filter_map(fixed).collect();
self.remove_tx_lifecycle(&txids);
let alarms: Vec<(Txid, u32)> =
r.depth_alarms.iter().filter_map(|(t, d)| Some((fixed(t)?, *d))).collect();
self.remove_depth_alarms(&alarms);
for (prefix, bits) in &r.prefixes {
self.prefixes.retain(|(b, p)| !(b == bits && mask_prefix(p, *b) == *prefix));
}
let ids: Vec<[u8; 33]> =
r.scan_pubkeys.iter().filter_map(|b| <[u8; 33]>::try_from(b.as_slice()).ok()).collect();
self.remove_silent_payments(&ids);
if let Some(d) = &r.descriptor {
let refused = (d.gap_limit, d.start);
if self.descriptors.get(&d.descriptor) == Some(&refused) {
let prev = self.descriptor_history.get_mut(&d.descriptor).and_then(Vec::pop);
match prev {
Some(prev) if d.kept => {
self.descriptors.insert(d.descriptor.clone(), prev);
}
None if d.kept => {}
_ => {
self.descriptors.remove(&d.descriptor);
self.descriptor_history.remove(&d.descriptor);
}
}
} else if let Some(history) = self.descriptor_history.get_mut(&d.descriptor) {
history.retain(|w| *w != refused);
}
if self.descriptor_history.get(&d.descriptor).is_some_and(Vec::is_empty) {
self.descriptor_history.remove(&d.descriptor);
}
}
}
fn add_prefixes(&mut self, items: &[pb::ScriptPrefix]) {
for sp in items {
self.prefixes.insert((sp.bits, sp.prefix.clone()));
}
}
fn remove_prefixes(&mut self, items: &[pb::ScriptPrefix]) {
for sp in items {
self.prefixes.remove(&(sp.bits, sp.prefix.clone()));
}
}
#[cfg(feature = "bitcoin")]
fn add_silent_payments(&mut self, entries: &[([u8; 33], SilentPaymentTarget)]) {
for (scan_pubkey, target) in entries {
self.silent_payments.insert(*scan_pubkey, target.clone());
}
}
fn remove_silent_payments(&mut self, scan_pubkeys: &[[u8; 33]]) {
for k in scan_pubkeys {
self.silent_payments.remove(k);
}
}
fn set_categories(&mut self, categories: u32) {
self.categories = Some(categories);
}
fn set_watch_options(&mut self, include_raw_tx: bool) {
self.include_raw_tx = Some(include_raw_tx);
}
fn control_messages(&self) -> Vec<Msg> {
let mut out = Vec::new();
if let Some(c) = self.categories {
out.push(Msg::SetCategories(pb::SetCategories { categories: c }));
}
if let Some(include_raw_tx) = self.include_raw_tx {
out.push(Msg::SetWatchOptions(pb::SetWatchOptions { include_raw_tx }));
}
if !self.scripts.is_empty() {
let scripthashes: Vec<Vec<u8>> = self.scripts.keys().map(|h| h.to_vec()).collect();
let min_values = if self.scripts.values().any(Option::is_some) {
self.scripts.values().map(|f| f.unwrap_or(0)).collect()
} else {
Vec::new()
};
out.push(Msg::AddScripts(pb::AddScripts { scripthashes, min_values }));
}
if !self.outpoints.is_empty() {
let outpoints = self
.outpoints
.iter()
.map(|(t, v)| pb::Outpoint { txid: t.to_vec(), vout: *v })
.collect();
out.push(Msg::AddOutpoints(pb::AddOutpoints { outpoints }));
}
if !self.tx_lifecycles.is_empty() {
let mut by_depth: BTreeMap<u32, Vec<Vec<u8>>> = BTreeMap::new();
for (txid, ac) in &self.tx_lifecycles {
let depth = match ac {
AutoClose::Never => 0,
AutoClose::AtDepth(d) => *d,
};
by_depth.entry(depth).or_default().push(txid.to_vec());
}
for (auto_close_depth, txids) in by_depth {
out.push(Msg::AddTransactions(pb::AddTransactions {
txids,
min_depths: Vec::new(),
auto_close_depth,
}));
}
}
if !self.depth_alarms.is_empty() {
let mut by_txid: BTreeMap<Txid, Vec<u32>> = BTreeMap::new();
for (txid, depth) in &self.depth_alarms {
by_txid.entry(*txid).or_default().push(*depth);
}
for (txid, min_depths) in by_txid {
out.push(Msg::AddTransactions(pb::AddTransactions {
txids: vec![txid.to_vec()],
min_depths,
auto_close_depth: 0,
}));
}
}
for (descriptor, (gap_limit, start)) in &self.descriptors {
out.push(Msg::AddDescriptor(pb::AddDescriptor {
descriptor: descriptor.clone(),
gap_limit: *gap_limit,
start: *start,
}));
}
if !self.prefixes.is_empty() {
let prefixes = self
.prefixes
.iter()
.map(|(bits, prefix)| pb::ScriptPrefix { prefix: prefix.clone(), bits: *bits })
.collect();
out.push(Msg::AddScriptPrefixes(pb::AddScriptPrefixes { prefixes }));
}
if !self.silent_payments.is_empty() {
let targets = self.silent_payments.values().map(|t| t.to_proto()).collect();
out.push(Msg::AddSilentPayments(pb::AddSilentPayments { targets }));
}
out
}
#[allow(clippy::needless_update)]
fn to_set_watch_set(&self) -> pb::SetWatchSet {
let scripthashes: Vec<Vec<u8>> = self.scripts.keys().map(|h| h.to_vec()).collect();
let min_values = if self.scripts.values().any(Option::is_some) {
self.scripts.values().map(|f| f.unwrap_or(0)).collect()
} else {
Vec::new()
};
pb::SetWatchSet {
categories: self.categories.unwrap_or(0),
scripthashes,
min_values,
outpoints: self
.outpoints
.iter()
.map(|(t, v)| pb::Outpoint { txid: t.to_vec(), vout: *v })
.collect(),
descriptors: self
.descriptors
.iter()
.map(|(d, (gap_limit, start))| pb::AddDescriptor {
descriptor: d.clone(),
gap_limit: *gap_limit,
start: *start,
})
.collect(),
prefixes: self
.prefixes
.iter()
.map(|(bits, prefix)| pb::ScriptPrefix { prefix: prefix.clone(), bits: *bits })
.collect(),
lifecycles: self
.tx_lifecycles
.iter()
.map(|(t, ac)| pb::WatchLifecycle {
txid: t.to_vec(),
auto_close_depth: match ac {
AutoClose::Never => 0,
AutoClose::AtDepth(d) => *d,
},
})
.collect(),
depth_alarms: self
.depth_alarms
.iter()
.map(|(t, d)| pb::WatchDepthAlarm { txid: t.to_vec(), depth: *d })
.collect(),
silent_payments: self.silent_payments.values().map(|t| t.to_proto()).collect(),
..Default::default()
}
}
fn reconcile_to(&self, target: &WatchSetMirror) -> (Vec<Msg>, ReloadCounts) {
let mut lead = Vec::new();
let mut adds = Vec::new();
let mut removes = Vec::new();
let mut c = ReloadCounts::default();
let target_mask = target.categories.unwrap_or(0);
let live_mask = self.categories.unwrap_or(0);
if target_mask != live_mask {
lead.push(Msg::SetCategories(pb::SetCategories { categories: target_mask }));
}
{
let mut changed: Vec<(Scripthash, Option<u64>)> = Vec::new();
for (h, floor) in &target.scripts {
match self.scripts.get(h) {
Some(f) if f == floor => c.unchanged += 1,
_ => changed.push((*h, *floor)),
}
}
let gone: Vec<Scripthash> =
self.scripts.keys().filter(|h| !target.scripts.contains_key(*h)).copied().collect();
if !changed.is_empty() {
c.added += changed.len();
let scripthashes = changed.iter().map(|(h, _)| h.to_vec()).collect();
let min_values = if changed.iter().any(|(_, f)| f.is_some()) {
changed.iter().map(|(_, f)| f.unwrap_or(0)).collect()
} else {
Vec::new()
};
adds.push(Msg::AddScripts(pb::AddScripts { scripthashes, min_values }));
}
if !gone.is_empty() {
c.removed += gone.len();
removes.push(Msg::RemoveScripts(pb::RemoveScripts {
scripthashes: gone.iter().map(|h| h.to_vec()).collect(),
}));
}
}
{
let add: Vec<_> = target.outpoints.difference(&self.outpoints).copied().collect();
let gone: Vec<_> = self.outpoints.difference(&target.outpoints).copied().collect();
c.unchanged += target.outpoints.intersection(&self.outpoints).count();
if !add.is_empty() {
c.added += add.len();
adds.push(Msg::AddOutpoints(pb::AddOutpoints {
outpoints: add.iter().map(|(t, v)| pb::Outpoint { txid: t.to_vec(), vout: *v }).collect(),
}));
}
if !gone.is_empty() {
c.removed += gone.len();
removes.push(Msg::RemoveOutpoints(pb::RemoveOutpoints {
outpoints: gone.iter().map(|(t, v)| pb::Outpoint { txid: t.to_vec(), vout: *v }).collect(),
}));
}
}
{
let mut by_depth: BTreeMap<u32, Vec<Vec<u8>>> = BTreeMap::new();
for (txid, ac) in &target.tx_lifecycles {
match self.tx_lifecycles.get(txid) {
Some(old) if old == ac => c.unchanged += 1,
_ => {
let depth = match ac {
AutoClose::Never => 0,
AutoClose::AtDepth(d) => *d,
};
by_depth.entry(depth).or_default().push(txid.to_vec());
c.added += 1;
}
}
}
for (auto_close_depth, txids) in by_depth {
adds.push(Msg::AddTransactions(pb::AddTransactions {
txids,
min_depths: Vec::new(),
auto_close_depth,
}));
}
let gone: Vec<Vec<u8>> = self
.tx_lifecycles
.keys()
.filter(|t| !target.tx_lifecycles.contains_key(*t))
.map(|t| t.to_vec())
.collect();
if !gone.is_empty() {
c.removed += gone.len();
removes.push(Msg::RemoveTransactions(pb::RemoveTransactions {
txids: gone,
min_depths: Vec::new(),
}));
}
}
{
let add: Vec<_> = target.depth_alarms.difference(&self.depth_alarms).copied().collect();
let gone: Vec<_> = self.depth_alarms.difference(&target.depth_alarms).copied().collect();
c.unchanged += target.depth_alarms.intersection(&self.depth_alarms).count();
if !add.is_empty() {
c.added += add.len();
let mut by_txid: BTreeMap<Txid, Vec<u32>> = BTreeMap::new();
for (t, d) in add {
by_txid.entry(t).or_default().push(d);
}
for (txid, min_depths) in by_txid {
adds.push(Msg::AddTransactions(pb::AddTransactions {
txids: vec![txid.to_vec()],
min_depths,
auto_close_depth: 0,
}));
}
}
if !gone.is_empty() {
c.removed += gone.len();
let mut by_txid: BTreeMap<Txid, Vec<u32>> = BTreeMap::new();
for (t, d) in gone {
by_txid.entry(t).or_default().push(d);
}
for (txid, min_depths) in by_txid {
removes.push(Msg::RemoveTransactions(pb::RemoveTransactions {
txids: vec![txid.to_vec()],
min_depths,
}));
}
}
}
{
for (d, win) in &target.descriptors {
match self.descriptors.get(d) {
Some(old) if old == win => c.unchanged += 1,
_ => {
c.added += 1;
adds.push(Msg::AddDescriptor(pb::AddDescriptor {
descriptor: d.clone(),
gap_limit: win.0,
start: win.1,
}));
}
}
}
for d in self.descriptors.keys() {
if !target.descriptors.contains_key(d) {
c.removed += 1;
removes.push(Msg::RemoveDescriptor(pb::RemoveDescriptor { descriptor: d.clone() }));
}
}
}
{
let add: Vec<_> = target.prefixes.difference(&self.prefixes).cloned().collect();
let gone: Vec<_> = self.prefixes.difference(&target.prefixes).cloned().collect();
c.unchanged += target.prefixes.intersection(&self.prefixes).count();
if !add.is_empty() {
c.added += add.len();
adds.push(Msg::AddScriptPrefixes(pb::AddScriptPrefixes {
prefixes: add.iter().map(|(bits, p)| pb::ScriptPrefix { prefix: p.clone(), bits: *bits }).collect(),
}));
}
if !gone.is_empty() {
c.removed += gone.len();
removes.push(Msg::RemoveScriptPrefixes(pb::RemoveScriptPrefixes {
prefixes: gone.iter().map(|(bits, p)| pb::ScriptPrefix { prefix: p.clone(), bits: *bits }).collect(),
}));
}
}
{
let mut changed: Vec<pb::SilentPaymentTarget> = Vec::new();
for (id, t) in &target.silent_payments {
match self.silent_payments.get(id) {
Some(cur)
if cur.labels == t.labels && cur.spend_pubkey == t.spend_pubkey =>
{
c.unchanged += 1;
}
_ => changed.push(t.to_proto()),
}
}
let gone: Vec<Vec<u8>> = self
.silent_payments
.keys()
.filter(|id| !target.silent_payments.contains_key(*id))
.map(|id| id.to_vec())
.collect();
if !changed.is_empty() {
c.added += changed.len();
adds.push(Msg::AddSilentPayments(pb::AddSilentPayments { targets: changed }));
}
if !gone.is_empty() {
c.removed += gone.len();
removes.push(Msg::RemoveSilentPayments(pb::RemoveSilentPayments {
scan_pubkeys: gone,
}));
}
}
lead.extend(removes);
lead.extend(adds);
(lead, c)
}
}
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
struct ReloadCounts {
added: usize,
removed: usize,
unchanged: usize,
}
pub struct WatchSetBuilder {
mirror: Arc<Mutex<WatchSetMirror>>,
}
impl WatchSetBuilder {
fn with(&self, f: impl FnOnce(&mut WatchSetMirror)) {
let mut m = self.mirror.lock().unwrap_or_else(|p| p.into_inner());
f(&mut m);
}
pub fn add_scripts(&self, items: impl IntoIterator<Item = (Scripthash, Option<u64>)>) {
let items: Vec<_> = items.into_iter().collect();
self.with(|m| m.add_scripts(&items));
}
pub fn add_outpoints(&self, outpoints: impl IntoIterator<Item = (Txid, u32)>) {
let items: Vec<_> = outpoints.into_iter().collect();
self.with(|m| m.add_outpoints(&items));
}
pub fn add_tx_lifecycle(&self, txids: impl IntoIterator<Item = Txid>, auto_close: AutoClose) {
let txids: Vec<_> = txids.into_iter().collect();
if txids.is_empty() {
return;
}
self.with(|m| m.add_tx_lifecycle(&txids, auto_close));
}
pub fn add_depth_alarms(
&self,
txids: impl IntoIterator<Item = Txid>,
depths: impl IntoIterator<Item = u32>,
) {
let txids: Vec<_> = txids.into_iter().collect();
let depths: Vec<u32> = depths.into_iter().filter(|d| *d >= 1).collect();
if txids.is_empty() || depths.is_empty() {
return;
}
let pairs = cross_product(&txids, &depths);
self.with(|m| m.add_depth_alarms(&pairs));
}
pub fn add_descriptor(&self, descriptor: impl Into<String>, gap_limit: u32, start: u32) {
let descriptor = descriptor.into();
self.with(|m| m.add_descriptor(descriptor, gap_limit, start));
}
pub fn add_script_prefixes(
&self,
prefixes: impl IntoIterator<Item = (Vec<u8>, u32)>,
) -> Result<(), StreamError> {
let validated = validate_prefixes(prefixes)?;
if validated.is_empty() {
return Ok(());
}
self.with(|m| m.add_prefixes(&validated));
Ok(())
}
#[cfg(feature = "bitcoin")]
#[cfg_attr(docsrs, doc(cfg(feature = "bitcoin")))]
pub fn add_silent_payments(
&self,
targets: impl IntoIterator<Item = SilentPaymentTarget>,
) -> Result<(), StreamError> {
let entries = sp_mirror_entries(targets)?;
if entries.is_empty() {
return Ok(());
}
self.with(|m| m.add_silent_payments(&entries));
Ok(())
}
pub fn set_categories(&self, categories: u32) {
self.with(|m| m.set_categories(categories));
}
pub fn set_watch_options(&self, include_raw_tx: bool) {
self.with(|m| m.set_watch_options(include_raw_tx));
}
}
pub struct ResilientWatchConfig {
pub backoff: Backoff,
pub cursor_store: Arc<dyn CursorStore>,
pub from_cursor: Option<Cursor>,
pub(crate) watch_set_loader: Option<Arc<WatchSetLoaderFn>>,
}
impl Default for ResilientWatchConfig {
fn default() -> Self {
ResilientWatchConfig {
backoff: Backoff::default(),
cursor_store: Arc::new(NoopCursorStore),
from_cursor: None,
watch_set_loader: None,
}
}
}
impl std::fmt::Debug for ResilientWatchConfig {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("ResilientWatchConfig")
.field("backoff", &self.backoff)
.field("cursor_store", &"<dyn CursorStore>")
.field("from_cursor", &self.from_cursor)
.field("watch_set_loader", &self.watch_set_loader.as_ref().map(|_| "<loader>"))
.finish()
}
}
impl ResilientWatchConfig {
pub fn new() -> Self {
Self::default()
}
pub fn cursor_store(mut self, store: Arc<dyn CursorStore>) -> Self {
self.cursor_store = store;
self
}
pub fn backoff(mut self, backoff: Backoff) -> Self {
self.backoff = backoff;
self
}
pub fn from_cursor(mut self, cursor: Cursor) -> Self {
self.from_cursor = Some(cursor);
self
}
pub fn watch_set_loader<F, Fut>(mut self, loader: F) -> Self
where
F: Fn(WatchSetBuilder) -> Fut + Send + Sync + 'static,
Fut: Future<Output = Result<(), BoxError>> + Send + 'static,
{
self.watch_set_loader = Some(Arc::new(move |b| Box::pin(loader(b))));
self
}
}
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
pub struct ReloadSummary {
pub added: usize,
pub removed: usize,
pub unchanged: usize,
pub applied: bool,
}
#[derive(Debug, thiserror::Error)]
pub enum ReloadError {
#[error("reload requires a watch_set_loader, but none is configured")]
NoLoader,
#[error("watch-set loader failed: {0}")]
Loader(#[source] BoxError),
}
#[derive(Clone, Copy)]
struct PendingReanchor {
deadline: tokio::time::Instant,
}
struct PendingAddRetry {
deadline: tokio::time::Instant,
rejected: WatchAddRejected,
}
fn retry_keys(r: &WatchAddRejected) -> Vec<Vec<u8>> {
let tagged = |tag: u8, bytes: &[u8]| {
let mut k = Vec::with_capacity(1 + bytes.len());
k.push(tag);
k.extend_from_slice(bytes);
k
};
let mut keys = Vec::new();
keys.extend(r.scripthashes.iter().map(|b| tagged(1, b)));
keys.extend(r.outpoints.iter().map(|(t, v)| tagged(2, &[t.as_slice(), &v.to_be_bytes()].concat())));
keys.extend(r.txids.iter().map(|b| tagged(3, b)));
keys.extend(r.depth_alarms.iter().map(|(t, d)| tagged(4, &[t.as_slice(), &d.to_be_bytes()].concat())));
keys.extend(r.descriptor.iter().map(|d| tagged(5, d.descriptor.as_bytes())));
keys.extend(r.prefixes.iter().map(|(p, bits)| tagged(6, &[p.as_slice(), &bits.to_be_bytes()].concat())));
keys.extend(r.scan_pubkeys.iter().map(|b| tagged(7, b)));
keys
}
pub struct ResilientWatch {
client: StreamClient,
config: ResilientWatchConfig,
mirror: WatchSetMirror,
handle: Option<WatchHandle>,
stream: Option<EventStream>,
resume: Option<Cursor>,
desired_cursor: Option<Cursor>,
commit_next: Option<Cursor>,
committed: Option<Cursor>,
seeded: bool,
reconnect_attempts: u32,
reanchor_attempts: u32,
pending_reanchor: Option<PendingReanchor>,
pending_add_retries: Vec<PendingAddRetry>,
add_retry_attempts: HashMap<Vec<u8>, u32>,
last_error: Option<StreamError>,
}
impl ResilientWatch {
pub(crate) fn new(client: StreamClient, config: ResilientWatchConfig) -> Self {
ResilientWatch {
client,
config,
mirror: WatchSetMirror::default(),
handle: None,
stream: None,
resume: None,
desired_cursor: None,
commit_next: None,
committed: None,
seeded: false,
reconnect_attempts: 0,
reanchor_attempts: 0,
pending_reanchor: None,
pending_add_retries: Vec::new(),
add_retry_attempts: HashMap::new(),
last_error: None,
}
}
pub async fn add_scripts(
&mut self,
items: impl IntoIterator<Item = (Scripthash, Option<u64>)>,
) -> Result<(), StreamError> {
let items: Vec<_> = items.into_iter().collect();
if items.is_empty() {
return Ok(());
}
self.mirror.add_scripts(&items);
let res = match &self.handle {
Some(h) => Some(h.add_scripts(items).await),
None => None,
};
self.after_send(res)
}
pub async fn remove_scripts(
&mut self,
hashes: impl IntoIterator<Item = Scripthash>,
) -> Result<(), StreamError> {
let hashes: Vec<_> = hashes.into_iter().collect();
if hashes.is_empty() {
return Ok(());
}
self.mirror.remove_scripts(&hashes);
let res = match &self.handle {
Some(h) => Some(h.remove_scripts(hashes).await),
None => None,
};
self.after_send(res)
}
pub async fn add_outpoints(
&mut self,
outpoints: impl IntoIterator<Item = (Txid, u32)>,
) -> Result<(), StreamError> {
let items: Vec<_> = outpoints.into_iter().collect();
if items.is_empty() {
return Ok(());
}
self.mirror.add_outpoints(&items);
let res = match &self.handle {
Some(h) => Some(h.add_outpoints(items).await),
None => None,
};
self.after_send(res)
}
pub async fn remove_outpoints(
&mut self,
outpoints: impl IntoIterator<Item = (Txid, u32)>,
) -> Result<(), StreamError> {
let items: Vec<_> = outpoints.into_iter().collect();
if items.is_empty() {
return Ok(());
}
self.mirror.remove_outpoints(&items);
let res = match &self.handle {
Some(h) => Some(h.remove_outpoints(items).await),
None => None,
};
self.after_send(res)
}
pub async fn add_tx_lifecycle(
&mut self,
txids: impl IntoIterator<Item = Txid>,
auto_close: AutoClose,
) -> Result<(), StreamError> {
let txids: Vec<_> = txids.into_iter().collect();
if txids.is_empty() {
return Ok(());
}
self.mirror.add_tx_lifecycle(&txids, auto_close);
let res = match &self.handle {
Some(h) => Some(h.add_tx_lifecycle(txids, auto_close).await),
None => None,
};
self.after_send(res)
}
pub async fn remove_tx_lifecycle(
&mut self,
txids: impl IntoIterator<Item = Txid>,
) -> Result<(), StreamError> {
let txids: Vec<_> = txids.into_iter().collect();
if txids.is_empty() {
return Ok(());
}
self.mirror.remove_tx_lifecycle(&txids);
let res = match &self.handle {
Some(h) => Some(h.remove_tx_lifecycle(txids).await),
None => None,
};
self.after_send(res)
}
pub async fn add_depth_alarms(
&mut self,
txids: impl IntoIterator<Item = Txid>,
depths: impl IntoIterator<Item = u32>,
) -> Result<(), StreamError> {
let txids: Vec<_> = txids.into_iter().collect();
let depths: Vec<u32> = depths.into_iter().filter(|d| *d >= 1).collect();
if txids.is_empty() || depths.is_empty() {
return Ok(());
}
let pairs = cross_product(&txids, &depths);
self.mirror.add_depth_alarms(&pairs);
let res = match &self.handle {
Some(h) => Some(h.add_depth_alarms(txids, depths).await),
None => None,
};
self.after_send(res)
}
pub async fn remove_depth_alarms(
&mut self,
txids: impl IntoIterator<Item = Txid>,
depths: impl IntoIterator<Item = u32>,
) -> Result<(), StreamError> {
let txids: Vec<_> = txids.into_iter().collect();
let depths: Vec<u32> = depths.into_iter().filter(|d| *d >= 1).collect();
if txids.is_empty() || depths.is_empty() {
return Ok(());
}
let pairs = cross_product(&txids, &depths);
self.mirror.remove_depth_alarms(&pairs);
let res = match &self.handle {
Some(h) => Some(h.remove_depth_alarms(txids, depths).await),
None => None,
};
self.after_send(res)
}
pub async fn add_descriptor(
&mut self,
descriptor: impl Into<String>,
gap_limit: u32,
start: u32,
) -> Result<(), StreamError> {
let descriptor = descriptor.into();
self.mirror.add_descriptor(descriptor.clone(), gap_limit, start);
let res = match &self.handle {
Some(h) => Some(h.add_descriptor(descriptor, gap_limit, start).await),
None => None,
};
self.after_send(res)
}
pub async fn remove_descriptor(
&mut self,
descriptor: impl Into<String>,
) -> Result<(), StreamError> {
let descriptor = descriptor.into();
self.mirror.remove_descriptor(&descriptor);
let res = match &self.handle {
Some(h) => Some(h.remove_descriptor(descriptor).await),
None => None,
};
self.after_send(res)
}
pub async fn add_script_prefixes(
&mut self,
prefixes: impl IntoIterator<Item = (Vec<u8>, u32)>,
) -> Result<(), StreamError> {
let validated = validate_prefixes(prefixes)?;
if validated.is_empty() {
return Ok(());
}
self.mirror.add_prefixes(&validated);
let res = match &self.handle {
Some(h) => Some(
h.send_control(pb::SubscribeControl {
msg: Some(Msg::AddScriptPrefixes(pb::AddScriptPrefixes {
prefixes: validated,
})),
})
.await,
),
None => None,
};
self.after_send(res)
}
pub async fn remove_script_prefixes(
&mut self,
prefixes: impl IntoIterator<Item = (Vec<u8>, u32)>,
) -> Result<(), StreamError> {
let validated = validate_prefixes(prefixes)?;
if validated.is_empty() {
return Ok(());
}
self.mirror.remove_prefixes(&validated);
let res = match &self.handle {
Some(h) => Some(
h.send_control(pb::SubscribeControl {
msg: Some(Msg::RemoveScriptPrefixes(pb::RemoveScriptPrefixes {
prefixes: validated,
})),
})
.await,
),
None => None,
};
self.after_send(res)
}
#[cfg(feature = "bitcoin")]
#[cfg_attr(docsrs, doc(cfg(feature = "bitcoin")))]
pub async fn add_silent_payments(
&mut self,
targets: impl IntoIterator<Item = SilentPaymentTarget>,
) -> Result<(), StreamError> {
let entries = sp_mirror_entries(targets)?;
if entries.is_empty() {
return Ok(());
}
let held = self.mirror.silent_payments.len();
let net_new = entries
.iter()
.filter(|(id, _)| !self.mirror.silent_payments.contains_key(id))
.count();
if held + net_new > crate::client::MAX_SP_TARGETS_PER_CONNECTION {
return Err(StreamError::InvalidArgument(format!(
"silent-payment target cap exceeded: {held} held + {net_new} new > {}",
crate::client::MAX_SP_TARGETS_PER_CONNECTION
)));
}
self.mirror.add_silent_payments(&entries);
let targets: Vec<pb::SilentPaymentTarget> =
entries.iter().map(|(_, t)| t.to_proto()).collect();
let res = match &self.handle {
Some(h) => Some(
h.send_control(pb::SubscribeControl {
msg: Some(Msg::AddSilentPayments(pb::AddSilentPayments { targets })),
})
.await,
),
None => None,
};
self.after_send(res)
}
pub async fn remove_silent_payments(
&mut self,
scan_pubkeys: impl IntoIterator<Item = [u8; 33]>,
) -> Result<(), StreamError> {
let scan_pubkeys: Vec<[u8; 33]> = scan_pubkeys.into_iter().collect();
if scan_pubkeys.is_empty() {
return Ok(());
}
self.mirror.remove_silent_payments(&scan_pubkeys);
let res = match &self.handle {
Some(h) => Some(
h.send_control(pb::SubscribeControl {
msg: Some(Msg::RemoveSilentPayments(pb::RemoveSilentPayments {
scan_pubkeys: scan_pubkeys.iter().map(|k| k.to_vec()).collect(),
})),
})
.await,
),
None => None,
};
self.after_send(res)
}
pub async fn set_categories(&mut self, categories: u32) -> Result<(), StreamError> {
self.mirror.set_categories(categories);
let res = match &self.handle {
Some(h) => Some(h.set_categories(categories).await),
None => None,
};
self.after_send(res)
}
pub async fn set_watch_options(&mut self, include_raw_tx: bool) -> Result<(), StreamError> {
self.mirror.set_watch_options(include_raw_tx);
let res = match &self.handle {
Some(h) => Some(h.set_watch_options(include_raw_tx).await),
None => None,
};
self.after_send(res)
}
pub async fn set_cursor(&mut self, cursor: Cursor) -> Result<(), StreamError> {
self.desired_cursor = Some(cursor);
self.reanchor_attempts = 0;
self.pending_reanchor = None;
let res = match &self.handle {
Some(h) => Some(h.set_cursor(cursor).await),
None => None,
};
self.after_send(res)
}
pub async fn rescan(&mut self, from_height: u32, to_height: u32) -> Result<(), StreamError> {
let res = match &self.handle {
Some(h) => Some(h.rescan(from_height, to_height).await),
None => None,
};
self.after_send(res)
}
pub async fn reload(&mut self) -> Result<ReloadSummary, ReloadError> {
let loader = self.config.watch_set_loader.clone().ok_or(ReloadError::NoLoader)?;
let shared = Arc::new(Mutex::new(WatchSetMirror::default()));
loader(WatchSetBuilder { mirror: shared.clone() })
.await
.map_err(ReloadError::Loader)?;
let loaded = std::mem::take(&mut *shared.lock().unwrap_or_else(|p| p.into_inner()));
let (_delta, counts) = self.mirror.reconcile_to(&loaded);
let mut applied = false;
if let Some(h) = &self.handle {
let msg = Msg::SetWatchSet(loaded.to_set_watch_set());
let set_res = h.send_control(pb::SubscribeControl { msg: Some(msg) }).await;
let old_raw_tx = self.mirror.include_raw_tx.unwrap_or(false);
let new_raw_tx = loaded.include_raw_tx.unwrap_or(false);
let opt_res = match &set_res {
Ok(()) if old_raw_tx != new_raw_tx => Some(h.set_watch_options(new_raw_tx).await),
_ => None,
};
if set_res.is_ok() && !matches!(opt_res, Some(Err(_))) {
applied = true;
} else {
self.teardown();
}
}
self.mirror = loaded;
Ok(ReloadSummary {
added: counts.added,
removed: counts.removed,
unchanged: counts.unchanged,
applied,
})
}
pub fn resume_cursor(&self) -> Option<&Cursor> {
self.resume.as_ref()
}
pub fn commit(&mut self) -> Result<(), StreamError> {
self.commit_due()
}
pub async fn next(&mut self) -> Result<Event, StreamError> {
self.commit_due()?;
loop {
if self.pending_reanchor.is_some() {
self.drive_pending_reanchor().await?;
continue;
}
if self.stream.is_none() {
if self.reconnect_attempts > 0 {
if let Some(max) = self.config.backoff.max_retries
&& self.reconnect_attempts > max
{
return Err(self.last_error.take().unwrap_or(StreamError::ControlClosed));
}
let delay = self.config.backoff.delay_for(self.reconnect_attempts - 1);
tokio::time::sleep(delay).await;
}
match self.connect().await {
Ok(()) => {}
Err(e) if is_reconnectable(&e) => {
self.teardown();
self.reconnect_attempts = self.reconnect_attempts.saturating_add(1);
self.last_error = Some(e);
continue;
}
Err(e) => return Err(e),
}
}
let now = tokio::time::Instant::now();
if let Some(i) = self.pending_add_retries.iter().position(|p| p.deadline <= now) {
self.send_add_retry(i).await?;
continue;
}
let next_retry = self.pending_add_retries.iter().map(|p| p.deadline).min();
let stream = self.stream.as_mut().expect("connected");
let msg = match next_retry {
Some(deadline) => tokio::select! {
m = stream.message() => m,
_ = tokio::time::sleep_until(deadline) => continue,
},
None => stream.message().await,
};
match msg {
Ok(Some(ev)) => {
self.reconnect_attempts = 0;
self.last_error = None;
let cur = self.stream.as_ref().and_then(|s| s.cursor().copied());
if let Some(out) = self.handle_event(ev, cur).await? {
self.arm_commit();
return Ok(out);
}
}
Ok(None) => {
self.teardown();
self.reconnect_attempts = self.reconnect_attempts.saturating_add(1);
}
Err(e) if e.is_retryable() => {
self.teardown();
self.reconnect_attempts = self.reconnect_attempts.saturating_add(1);
self.last_error = Some(e);
}
Err(e) => return Err(e),
}
}
}
async fn handle_event(
&mut self,
ev: Event,
cur: Option<Cursor>,
) -> Result<Option<Event>, StreamError> {
if let Some(c) = cur {
self.resume = Some(c);
}
if let Event::WatchAddRejected(r) = &ev
&& r.reason == WatchAddRejectReason::RateLimited
{
let keys = retry_keys(r);
let attempt =
keys.iter().filter_map(|k| self.add_retry_attempts.get(k)).copied().max().unwrap_or(0);
let exhausted = self.config.backoff.max_retries.is_some_and(|max| attempt >= max);
if !exhausted {
for k in keys {
self.add_retry_attempts.insert(k, attempt.saturating_add(1));
}
let delay = self
.config
.backoff
.delay_for(attempt)
.max(std::time::Duration::from_secs(r.retry_after_secs.into()));
self.pending_add_retries.push(PendingAddRetry {
deadline: tokio::time::Instant::now() + delay,
rejected: r.clone(),
});
return Ok(None);
}
for k in keys {
self.add_retry_attempts.remove(&k);
}
}
match &ev {
Event::WatchAddRejected(r) => self.mirror.forget_rejected(r),
Event::TxidDepthReached { txid, depth, .. } => {
if let Ok(t) = <[u8; 32]>::try_from(txid.as_slice()) {
self.mirror.remove_depth_alarms(&[(t, *depth)]);
}
}
Event::TxidFinalized { txid, .. } => {
if let Ok(t) = <[u8; 32]>::try_from(txid.as_slice()) {
self.mirror.remove_tx_lifecycle(&[t]);
}
}
_ => {}
}
let transient_reject = matches!(
&ev,
Event::CursorRejected {
reason: CursorRejectReason::RateLimited | CursorRejectReason::ConcurrentReanchor,
..
}
);
if transient_reject {
if let Some(max) = self.config.backoff.max_retries
&& self.reanchor_attempts >= max
{
self.reanchor_attempts = 0;
self.pending_reanchor = None;
return Ok(Some(ev));
}
let delay = self.config.backoff.delay_for(self.reanchor_attempts);
self.reanchor_attempts = self.reanchor_attempts.saturating_add(1);
self.pending_reanchor =
Some(PendingReanchor { deadline: tokio::time::Instant::now() + delay });
return Ok(None);
}
if let Event::CursorAccepted { from, .. } = &ev {
if let Some(c) = from {
self.resume = Some(*c);
self.desired_cursor = Some(*c);
}
self.reanchor_attempts = 0;
}
Ok(Some(ev))
}
async fn drive_pending_reanchor(&mut self) -> Result<(), StreamError> {
let Some(pending) = self.pending_reanchor else {
return Ok(());
};
tokio::time::sleep_until(pending.deadline).await;
let res = match (self.desired_cursor, &self.handle) {
(Some(c), Some(h)) => Some(h.set_cursor(c).await),
_ => None,
};
self.pending_reanchor = None;
self.after_send(res)
}
async fn connect(&mut self) -> Result<(), StreamError> {
self.seed_resume()?;
let (handle, stream) = self.client.watch().await?;
if let Some(loader) = self.config.watch_set_loader.clone() {
let shared = Arc::new(Mutex::new(WatchSetMirror::default()));
loader(WatchSetBuilder { mirror: shared.clone() })
.await
.map_err(StreamError::WatchSetLoader)?;
self.mirror = std::mem::take(&mut *shared.lock().unwrap_or_else(|p| p.into_inner()));
}
for msg in self.mirror.control_messages() {
handle.send_control(pb::SubscribeControl { msg: Some(msg) }).await?;
}
self.desired_cursor = self.resume;
self.reanchor_attempts = 0;
debug_assert!(self.pending_reanchor.is_none());
if let Some(c) = self.resume {
handle.set_cursor(c).await?;
}
self.handle = Some(handle);
self.stream = Some(stream);
Ok(())
}
fn seed_resume(&mut self) -> Result<(), StreamError> {
if !self.seeded {
let loaded = self.config.cursor_store.load()?;
if let Some(c) = loaded {
self.committed.get_or_insert(c);
}
self.resume = self.resume.or(loaded).or(self.config.from_cursor);
self.seeded = true;
}
Ok(())
}
fn teardown(&mut self) {
self.handle = None;
self.stream = None;
self.pending_add_retries.clear();
self.add_retry_attempts.clear();
self.pending_reanchor = None;
}
async fn send_add_retry(&mut self, i: usize) -> Result<(), StreamError> {
let msgs = self.mirror.subset_for(&self.pending_add_retries[i].rejected).control_messages();
let res = match &self.handle {
Some(h) => {
let mut res = Ok(());
for msg in msgs {
res = h.send_control(pb::SubscribeControl { msg: Some(msg) }).await;
if res.is_err() {
break;
}
}
Some(res)
}
None => None,
};
self.pending_add_retries.remove(i);
self.after_send(res)
}
fn after_send(&mut self, res: Option<Result<(), StreamError>>) -> Result<(), StreamError> {
match res {
Some(Err(StreamError::ControlClosed)) => {
self.teardown();
Ok(())
}
Some(Err(e)) => Err(e),
_ => Ok(()),
}
}
fn arm_commit(&mut self) {
self.commit_next = self.resume;
}
fn commit_due(&mut self) -> Result<(), StreamError> {
let Some(c) = self.commit_next else { return Ok(()) };
if self.committed == Some(c) {
self.commit_next = None;
return Ok(());
}
self.config.cursor_store.save(c)?;
if self.commit_next == Some(c) {
self.commit_next = None;
}
self.committed = Some(c);
Ok(())
}
}
const DESCRIPTOR_HISTORY: usize = 8;
fn mask_prefix(prefix: &[u8], bits: u32) -> Vec<u8> {
let mut out = prefix.to_vec();
let rem = bits % 8;
if rem != 0
&& let Some(last) = out.get_mut((bits / 8) as usize)
{
*last &= 0xffu8 << (8 - rem);
}
out
}
fn cross_product(txids: &[Txid], depths: &[u32]) -> Vec<(Txid, u32)> {
let mut out = Vec::with_capacity(txids.len() * depths.len());
for t in txids {
for d in depths {
out.push((*t, *d));
}
}
out
}
fn validate_prefixes(
prefixes: impl IntoIterator<Item = (Vec<u8>, u32)>,
) -> Result<Vec<pb::ScriptPrefix>, StreamError> {
prefixes
.into_iter()
.map(|(prefix, bits)| validate_prefix(prefix, bits))
.collect()
}
#[cfg(feature = "bitcoin")]
fn sp_mirror_entries(
targets: impl IntoIterator<Item = SilentPaymentTarget>,
) -> Result<Vec<([u8; 33], SilentPaymentTarget)>, StreamError> {
let mut deduped: BTreeMap<[u8; 33], SilentPaymentTarget> = BTreeMap::new();
for t in targets {
let id = t.validate()?;
deduped.insert(id, t); }
Ok(deduped.into_iter().collect())
}
fn is_reconnectable(e: &StreamError) -> bool {
e.is_retryable() || matches!(e, StreamError::ControlClosed | StreamError::WatchSetLoader(_))
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::Mutex;
#[test]
fn mirror_scripts_floor_overwrite_and_remove() {
let mut m = WatchSetMirror::default();
m.add_scripts(&[([1u8; 32], Some(5_000)), ([2u8; 32], None)]);
m.add_scripts(&[([1u8; 32], Some(9_000))]);
m.remove_scripts(&[[2u8; 32]]);
let msgs = m.control_messages();
assert_eq!(msgs.len(), 1);
match &msgs[0] {
Msg::AddScripts(a) => {
assert_eq!(a.scripthashes, vec![[1u8; 32].to_vec()]);
assert_eq!(a.min_values, vec![9_000]);
}
other => panic!("expected AddScripts, got {other:?}"),
}
}
#[test]
fn mirror_unfloored_scripts_emit_empty_min_values() {
let mut m = WatchSetMirror::default();
m.add_scripts(&[([1u8; 32], None), ([2u8; 32], None)]);
match &m.control_messages()[0] {
Msg::AddScripts(a) => assert!(a.min_values.is_empty(), "no floor → empty min_values"),
other => panic!("expected AddScripts, got {other:?}"),
}
}
#[test]
fn mirror_lifecycle_grouped_by_autoclose_depth() {
let mut m = WatchSetMirror::default();
m.add_tx_lifecycle(&[[1u8; 32], [2u8; 32]], AutoClose::AtDepth(6));
m.add_tx_lifecycle(&[[3u8; 32]], AutoClose::Never);
let groups: Vec<_> = m
.control_messages()
.into_iter()
.filter_map(|msg| match msg {
Msg::AddTransactions(a) => Some((a.auto_close_depth, a.min_depths, a.txids.len())),
_ => None,
})
.collect();
assert!(groups.contains(&(0, vec![], 1)));
assert!(groups.contains(&(6, vec![], 2)));
}
#[test]
fn mirror_depth_alarms_grouped_by_txid_with_min_depths() {
let mut m = WatchSetMirror::default();
m.add_depth_alarms(&[([7u8; 32], 3), ([7u8; 32], 6)]);
let alarms: Vec<_> = m
.control_messages()
.into_iter()
.filter_map(|msg| match msg {
Msg::AddTransactions(a) => Some((a.txids.len(), a.min_depths, a.auto_close_depth)),
_ => None,
})
.collect();
assert_eq!(alarms, vec![(1, vec![3, 6], 0)]);
}
#[test]
fn mirror_categories_replayed_first() {
let mut m = WatchSetMirror::default();
m.add_outpoints(&[([1u8; 32], 0)]);
m.set_categories(crate::Categories::CHAIN);
let msgs = m.control_messages();
assert!(matches!(msgs[0], Msg::SetCategories(_)), "categories lead the replay");
}
#[test]
fn mirror_raw_tx_opt_in_replayed() {
let mut m = WatchSetMirror::default();
m.add_scripts(&[([9u8; 32], None)]);
assert!(
!m.control_messages()
.iter()
.any(|msg| matches!(msg, Msg::SetWatchOptions(_))),
"no opt-in → no SetWatchOptions replayed"
);
m.set_watch_options(true);
let msgs = m.control_messages();
let opt = msgs.iter().find_map(|msg| match msg {
Msg::SetWatchOptions(o) => Some(o.include_raw_tx),
_ => None,
});
assert_eq!(opt, Some(true), "opt-in replayed as SetWatchOptions{{true}}");
let opt_pos = msgs
.iter()
.position(|msg| matches!(msg, Msg::SetWatchOptions(_)))
.unwrap();
let add_pos = msgs
.iter()
.position(|msg| matches!(msg, Msg::AddScripts(_)))
.unwrap();
assert!(opt_pos < add_pos, "options precede the watch-set adds");
}
#[test]
fn mirror_descriptor_latest_window_wins() {
let mut m = WatchSetMirror::default();
m.add_descriptor("wpkh(xpub...)".into(), 20, 0);
m.add_descriptor("wpkh(xpub...)".into(), 20, 5); let descs: Vec<_> = m
.control_messages()
.into_iter()
.filter_map(|msg| match msg {
Msg::AddDescriptor(d) => Some((d.gap_limit, d.start)),
_ => None,
})
.collect();
assert_eq!(descs, vec![(20, 5)], "only the latest window replays");
}
#[test]
fn mirror_removed_descriptor_is_not_replayed() {
let mut m = WatchSetMirror::default();
m.add_descriptor("wpkh(a)".into(), 20, 0);
m.add_descriptor("wpkh(b)".into(), 20, 0);
m.remove_descriptor("wpkh(a)");
let descs: Vec<_> = m
.control_messages()
.into_iter()
.filter_map(|msg| match msg {
Msg::AddDescriptor(d) => Some(d.descriptor),
_ => None,
})
.collect();
assert_eq!(descs, vec!["wpkh(b)".to_string()]);
}
#[test]
fn mirror_empty_renders_nothing() {
assert!(WatchSetMirror::default().control_messages().is_empty());
}
#[cfg(feature = "bitcoin")]
#[test]
fn sp_mirror_entries_validates_and_dedups() {
use bitcoin::secp256k1::{PublicKey, Secp256k1, SecretKey};
let secp = Secp256k1::new();
let key = |b: u8| {
PublicKey::from_secret_key(&secp, &SecretKey::from_slice(&[b; 32]).unwrap()).serialize()
};
let b_scan = [0x11u8; 32];
let (spend_a, spend_b) = (key(0x22), key(0x33));
let t1 = SilentPaymentTarget { scan_secret: b_scan, spend_pubkey: spend_a, labels: vec![0] };
let t2 =
SilentPaymentTarget { scan_secret: b_scan, spend_pubkey: spend_b, labels: vec![1, 2] };
let entries = sp_mirror_entries([t1, t2]).expect("valid targets");
assert_eq!(entries.len(), 1, "same identity collapses to one entry");
assert_eq!(entries[0].1.spend_pubkey, spend_b, "last write wins");
assert_eq!(entries[0].1.labels, vec![1, 2]);
let mut bad_spend = spend_a;
bad_spend[0] = 0x01; let bad =
SilentPaymentTarget { scan_secret: b_scan, spend_pubkey: bad_spend, labels: vec![] };
assert!(matches!(sp_mirror_entries([bad]), Err(StreamError::InvalidArgument(_))));
let bad2 =
SilentPaymentTarget { scan_secret: [0u8; 32], spend_pubkey: spend_a, labels: vec![] };
assert!(matches!(sp_mirror_entries([bad2]), Err(StreamError::InvalidArgument(_))));
}
#[cfg(feature = "bitcoin")]
#[tokio::test]
async fn sp_add_enforces_connection_cap() {
use crate::client::MAX_SP_TARGETS_PER_CONNECTION;
let spend = valid_spend_pubkey();
let target = |i: u8| SilentPaymentTarget {
scan_secret: [i; 32],
spend_pubkey: spend,
labels: vec![0],
};
let store = Arc::new(MemStore::default());
let mut w = watch_with(&store);
let full: Vec<_> = (1..=MAX_SP_TARGETS_PER_CONNECTION as u8).map(target).collect();
w.add_silent_payments(full).await.expect("cap-exact add accepted");
w.add_silent_payments([target(1)]).await.expect("re-assert at cap is free");
let err = w
.add_silent_payments([target(MAX_SP_TARGETS_PER_CONNECTION as u8 + 1)])
.await
.expect_err("over-cap add must be rejected");
assert!(matches!(err, StreamError::InvalidArgument(_)), "got {err:?}");
}
#[cfg(feature = "bitcoin")]
#[tokio::test]
async fn sp_add_enforces_label_cap() {
use crate::client::MAX_SP_LABELS_PER_TARGET;
let spend = valid_spend_pubkey();
let store = Arc::new(MemStore::default());
let mut w = watch_with(&store);
let over = SilentPaymentTarget {
scan_secret: [1u8; 32],
spend_pubkey: spend,
labels: (0..=MAX_SP_LABELS_PER_TARGET as u32).collect(),
};
let err = w.add_silent_payments([over]).await.expect_err("over-label rejected");
assert!(matches!(err, StreamError::InvalidArgument(_)), "got {err:?}");
let ok = SilentPaymentTarget {
scan_secret: [1u8; 32],
spend_pubkey: spend,
labels: (0..MAX_SP_LABELS_PER_TARGET as u32).collect(),
};
w.add_silent_payments([ok]).await.expect("cap-exact labels accepted");
}
#[cfg(feature = "bitcoin")]
fn valid_spend_pubkey() -> [u8; 33] {
use bitcoin::secp256k1::{PublicKey, Secp256k1, SecretKey};
let secp = Secp256k1::new();
PublicKey::from_secret_key(&secp, &SecretKey::from_slice(&[0x22u8; 32]).unwrap()).serialize()
}
#[derive(Default)]
struct MemStore(Mutex<Option<Cursor>>);
impl CursorStore for MemStore {
fn load(&self) -> Result<Option<Cursor>, StreamError> {
Ok(*self.0.lock().unwrap())
}
fn save(&self, cursor: Cursor) -> Result<(), StreamError> {
*self.0.lock().unwrap() = Some(cursor);
Ok(())
}
}
fn watch_with(store: &Arc<MemStore>) -> ResilientWatch {
ResilientWatch::new(
StreamClient::for_test(),
ResilientWatchConfig::new().cursor_store(store.clone()),
)
}
fn cur(height: u32) -> Cursor {
Cursor { height, tx_index: 0, mempool_seq: 0, instance_id: 1 }
}
#[tokio::test]
async fn offline_edits_accumulate_for_replay() {
let store = Arc::new(MemStore::default());
let mut w = watch_with(&store);
w.add_scripts([([1u8; 32], None)]).await.unwrap();
w.add_outpoints([([9u8; 32], 2)]).await.unwrap();
w.add_depth_alarms([[7u8; 32]], [0]).await.unwrap(); let kinds: Vec<_> = w
.mirror
.control_messages()
.into_iter()
.map(|m| std::mem::discriminant(&m))
.collect();
assert_eq!(kinds.len(), 2);
assert!(w.handle.is_none(), "still disconnected");
}
#[tokio::test]
async fn cursor_accepted_is_surfaced_and_resets_retry_counter() {
let store = Arc::new(MemStore::default());
let mut w = watch_with(&store);
w.reanchor_attempts = 3;
let ev = Event::CursorAccepted { from: Some(cur(100)), clamped: false, earliest_replayed: 100 };
let out = w.handle_event(ev, None).await.unwrap();
assert!(matches!(out, Some(Event::CursorAccepted { .. })), "accept surfaces to caller");
assert_eq!(w.reanchor_attempts, 0, "a landed re-anchor resets the retry counter");
}
#[tokio::test]
async fn cursor_accepted_adopts_anchor_before_any_replayed_event() {
let store = Arc::new(MemStore::default());
let mut w = watch_with(&store);
w.resume = Some(cur(1000));
w.desired_cursor = Some(cur(200));
let ev = Event::CursorAccepted { from: Some(cur(200)), clamped: false, earliest_replayed: 200 };
let out = w.handle_event(ev, None).await.unwrap();
assert!(matches!(out, Some(Event::CursorAccepted { .. })));
assert_eq!(w.resume, Some(cur(200)), "accepted anchor becomes the resume point at once");
assert_eq!(w.desired_cursor, Some(cur(200)));
}
#[tokio::test]
async fn terminal_reject_is_surfaced_for_escalation() {
let store = Arc::new(MemStore::default());
let mut w = watch_with(&store);
let ev = Event::CursorRejected {
reason: CursorRejectReason::NoSource,
current_head: Some(cur(500)),
};
let out = w.handle_event(ev, None).await.unwrap();
assert!(
matches!(out, Some(Event::CursorRejected { reason: CursorRejectReason::NoSource, .. })),
"NoSource is terminal — surfaced so the caller can resnapshot"
);
}
#[tokio::test]
async fn transient_reject_retries_in_place_until_budget_exhausted() {
let store = Arc::new(MemStore::default());
let mut w = ResilientWatch::new(
StreamClient::for_test(),
ResilientWatchConfig::new()
.cursor_store(store.clone())
.backoff(Backoff {
initial: std::time::Duration::from_millis(0),
max: std::time::Duration::from_millis(0),
multiplier: 1.0,
max_retries: Some(2),
}),
);
w.desired_cursor = Some(cur(100));
let reject = || Event::CursorRejected {
reason: CursorRejectReason::RateLimited,
current_head: None,
};
assert!(w.handle_event(reject(), None).await.unwrap().is_none(), "retry 1 consumed");
assert!(w.pending_reanchor.is_some(), "retry 1 deferred to a pending slot");
assert!(w.handle_event(reject(), None).await.unwrap().is_none(), "retry 2 consumed");
let out = w.handle_event(reject(), None).await.unwrap();
assert!(
matches!(out, Some(Event::CursorRejected { .. })),
"budget exhausted → surfaced for the caller to escalate"
);
assert_eq!(w.reanchor_attempts, 0, "counter resets after surfacing");
}
#[tokio::test]
async fn transient_reject_retry_is_cancel_safe() {
let store = Arc::new(MemStore::default());
let mut w = ResilientWatch::new(
StreamClient::for_test(),
ResilientWatchConfig::new().cursor_store(store.clone()).backoff(Backoff {
initial: std::time::Duration::from_secs(30),
max: std::time::Duration::from_secs(30),
multiplier: 1.0,
max_retries: Some(3),
}),
);
w.desired_cursor = Some(cur(100));
let out = w
.handle_event(
Event::CursorRejected { reason: CursorRejectReason::RateLimited, current_head: None },
None,
)
.await
.unwrap();
assert!(out.is_none(), "transient reject consumed");
assert_eq!(w.reanchor_attempts, 1, "one retry charged");
assert!(w.pending_reanchor.is_some(), "retry deferred to a resumable slot");
tokio::select! {
biased;
_ = w.drive_pending_reanchor() => unreachable!("30s backoff cannot have elapsed"),
_ = tokio::time::sleep(std::time::Duration::from_millis(1)) => {}
}
assert!(w.pending_reanchor.is_some(), "a cancelled drive leaves the retry resumable");
assert_eq!(w.reanchor_attempts, 1, "the cancelled drive did not re-charge the budget");
w.pending_reanchor.as_mut().unwrap().deadline = tokio::time::Instant::now();
w.drive_pending_reanchor().await.unwrap();
assert!(w.pending_reanchor.is_none(), "the resumed drive completes the retry");
assert_eq!(w.reanchor_attempts, 1, "resuming does not re-charge the budget");
}
fn drained_set_cursors(
rx: &mut tokio::sync::mpsc::Receiver<pb::SubscribeControl>,
) -> Vec<Cursor> {
let mut out = Vec::new();
while let Ok(ctrl) = rx.try_recv() {
if let Some(Msg::SetCursor(s)) = ctrl.msg {
out.push(s.cursor.expect("a SetCursor carries a cursor"));
}
}
out
}
#[tokio::test]
async fn set_cursor_supersedes_a_pending_transient_retry() {
let store = Arc::new(MemStore::default());
let (handle, mut rx) = crate::client::WatchHandle::for_test();
let mut w = watch_with(&store);
w.handle = Some(handle);
w.desired_cursor = Some(cur(100));
w.handle_event(
Event::CursorRejected { reason: CursorRejectReason::RateLimited, current_head: None },
None,
)
.await
.unwrap();
assert!(w.pending_reanchor.is_some(), "retry deferred");
w.set_cursor(cur(200)).await.unwrap();
assert!(w.pending_reanchor.is_none(), "explicit re-anchor drops the stale deferred retry");
assert_eq!(drained_set_cursors(&mut rx), vec![cur(200)], "only the fresh anchor is sent");
w.drive_pending_reanchor().await.unwrap();
assert!(drained_set_cursors(&mut rx).is_empty(), "a superseded retry re-sends nothing");
}
#[tokio::test]
async fn a_deferred_retry_re_sends_the_live_desired_cursor() {
let store = Arc::new(MemStore::default());
let (handle, mut rx) = crate::client::WatchHandle::for_test();
let mut w = ResilientWatch::new(
StreamClient::for_test(),
ResilientWatchConfig::new().cursor_store(store.clone()).backoff(Backoff {
initial: std::time::Duration::ZERO,
max: std::time::Duration::ZERO,
multiplier: 1.0,
max_retries: Some(3),
}),
);
w.handle = Some(handle);
w.desired_cursor = Some(cur(100));
w.handle_event(
Event::CursorRejected {
reason: CursorRejectReason::ConcurrentReanchor,
current_head: None,
},
None,
)
.await
.unwrap();
w.desired_cursor = Some(cur(200));
w.drive_pending_reanchor().await.unwrap();
assert_eq!(drained_set_cursors(&mut rx), vec![cur(200)], "the live anchor is re-sent");
assert!(w.pending_reanchor.is_none(), "the retry completed");
}
#[tokio::test]
async fn fired_depth_alarm_is_pruned_from_the_mirror() {
let store = Arc::new(MemStore::default());
let mut w = watch_with(&store);
w.add_depth_alarms([[7u8; 32]], [3, 6]).await.unwrap();
let ev = Event::TxidDepthReached { txid: [7u8; 32].to_vec(), depth: 3, height: 100 };
let out = w.handle_event(ev, None).await.unwrap();
assert!(matches!(out, Some(Event::TxidDepthReached { .. })), "still surfaced to caller");
assert!(!w.mirror.depth_alarms.contains(&([7u8; 32], 3)), "fired alarm pruned");
assert!(w.mirror.depth_alarms.contains(&([7u8; 32], 6)), "unfired alarm retained");
}
#[tokio::test]
async fn finalized_lifecycle_watch_is_pruned_from_the_mirror() {
let store = Arc::new(MemStore::default());
let mut w = watch_with(&store);
w.add_tx_lifecycle([[8u8; 32]], AutoClose::AtDepth(6)).await.unwrap();
let ev = Event::TxidFinalized { txid: [8u8; 32].to_vec(), depth: 6, height: 200 };
let out = w.handle_event(ev, None).await.unwrap();
assert!(matches!(out, Some(Event::TxidFinalized { .. })), "still surfaced to caller");
assert!(
!w.mirror.tx_lifecycles.contains_key(&[8u8; 32]),
"auto-close finalize prunes the lifecycle watch the server evicted"
);
}
fn refused(r: pb::WatchAddRejected) -> Event {
Event::WatchAddRejected(WatchAddRejected::from_proto(r))
}
#[tokio::test]
async fn refused_adds_are_pruned_from_the_mirror() {
use pb::watch_add_rejected::{Kind, Reason};
let store = Arc::new(MemStore::default());
let mut w = watch_with(&store);
w.add_scripts([([1u8; 32], None), ([2u8; 32], None)]).await.unwrap();
w.add_outpoints([([3u8; 32], 0), ([3u8; 32], 1)]).await.unwrap();
w.add_script_prefixes([(vec![0xab, 0xcd], 12)]).await.unwrap();
let out = w
.handle_event(
refused(pb::WatchAddRejected {
kind: Kind::Scripts as i32,
reason: Reason::QuotaExceeded as i32,
scripthashes: vec![vec![2u8; 32]],
..Default::default()
}),
None,
)
.await
.unwrap();
assert!(matches!(out, Some(Event::WatchAddRejected(_))), "still surfaced to the caller");
assert!(w.mirror.scripts.contains_key(&[1u8; 32]), "an item the server holds stays");
assert!(!w.mirror.scripts.contains_key(&[2u8; 32]), "a refused script is dropped");
w.handle_event(
refused(pb::WatchAddRejected {
kind: Kind::Outpoints as i32,
reason: Reason::PermissionDenied as i32,
outpoints: vec![pb::Outpoint { txid: vec![3u8; 32], vout: 1 }],
..Default::default()
}),
None,
)
.await
.unwrap();
assert!(w.mirror.outpoints.contains(&([3u8; 32], 0)));
assert!(!w.mirror.outpoints.contains(&([3u8; 32], 1)));
w.handle_event(
refused(pb::WatchAddRejected {
kind: Kind::ScriptPrefixes as i32,
reason: Reason::CapExceeded as i32,
prefixes: vec![pb::ScriptPrefix { prefix: vec![0xab, 0xc0], bits: 12 }],
..Default::default()
}),
None,
)
.await
.unwrap();
assert!(w.mirror.prefixes.is_empty(), "the masked echo matches the caller's prefix");
}
#[tokio::test]
async fn refused_descriptor_slide_falls_back_to_the_window_the_server_holds() {
use pb::watch_add_rejected::{Kind, Reason};
let store = Arc::new(MemStore::default());
let mut w = watch_with(&store);
w.add_descriptor("wpkh(a)", 20, 0).await.unwrap();
w.add_descriptor("wpkh(a)", 20, 20).await.unwrap();
w.add_descriptor("wpkh(b)", 20, 0).await.unwrap();
w.handle_event(
refused(pb::WatchAddRejected {
kind: Kind::Descriptor as i32,
reason: Reason::QuotaExceeded as i32,
descriptor: "wpkh(a)".into(),
gap_limit: 20,
start: 20,
descriptor_kept: true,
..Default::default()
}),
None,
)
.await
.unwrap();
assert_eq!(w.mirror.descriptors.get("wpkh(a)"), Some(&(20, 0)), "replays the held window");
w.handle_event(
refused(pb::WatchAddRejected {
kind: Kind::Descriptor as i32,
reason: Reason::CapExceeded as i32,
descriptor: "wpkh(b)".into(),
gap_limit: 20,
start: 0,
descriptor_kept: false,
..Default::default()
}),
None,
)
.await
.unwrap();
assert!(!w.mirror.descriptors.contains_key("wpkh(b)"));
w.add_descriptor("wpkh(c)", 20, 0).await.unwrap();
w.add_descriptor("wpkh(c)", 20, 20).await.unwrap();
w.add_descriptor("wpkh(c)", 20, 40).await.unwrap();
for start in [20, 40] {
w.handle_event(
refused(pb::WatchAddRejected {
kind: Kind::Descriptor as i32,
reason: Reason::QuotaExceeded as i32,
descriptor: "wpkh(c)".into(),
gap_limit: 20,
start,
descriptor_kept: true,
..Default::default()
}),
None,
)
.await
.unwrap();
}
assert_eq!(w.mirror.descriptors.get("wpkh(c)"), Some(&(20, 0)), "falls back past both refusals");
w.add_descriptor("wpkh(a)", 20, 40).await.unwrap();
w.handle_event(
refused(pb::WatchAddRejected {
kind: Kind::Descriptor as i32,
reason: Reason::QuotaExceeded as i32,
descriptor: "wpkh(a)".into(),
gap_limit: 20,
start: 20,
descriptor_kept: true,
..Default::default()
}),
None,
)
.await
.unwrap();
assert_eq!(w.mirror.descriptors.get("wpkh(a)"), Some(&(20, 40)));
}
fn zero_backoff(max_retries: Option<u32>) -> Backoff {
Backoff {
initial: std::time::Duration::ZERO,
max: std::time::Duration::ZERO,
multiplier: 1.0,
max_retries,
}
}
fn rate_limited_scripts(shs: &[u8]) -> Event {
use pb::watch_add_rejected::{Kind, Reason};
refused(pb::WatchAddRejected {
kind: Kind::Scripts as i32,
reason: Reason::RateLimited as i32,
retry_after_secs: 0,
scripthashes: shs.iter().map(|b| vec![*b; 32]).collect(),
..Default::default()
})
}
fn sent_scripts(rx: &mut tokio::sync::mpsc::Receiver<pb::SubscribeControl>) -> Vec<pb::AddScripts> {
let mut out = Vec::new();
while let Ok(ctrl) = rx.try_recv() {
if let Some(Msg::AddScripts(a)) = ctrl.msg {
out.push(a);
}
}
out
}
#[tokio::test]
async fn rate_limited_add_is_re_sent_from_the_mirror() {
let store = Arc::new(MemStore::default());
let (handle, mut rx) = crate::client::WatchHandle::for_test();
let mut w = ResilientWatch::new(
StreamClient::for_test(),
ResilientWatchConfig::new().cursor_store(store.clone()).backoff(zero_backoff(Some(3))),
);
w.handle = Some(handle);
w.add_scripts([([1u8; 32], None), ([2u8; 32], Some(500)), ([3u8; 32], None)]).await.unwrap();
let _ = sent_scripts(&mut rx);
let out = w.handle_event(rate_limited_scripts(&[2, 3]), None).await.unwrap();
assert!(out.is_none(), "a rate-limited add is retried, not surfaced");
assert!(w.mirror.scripts.contains_key(&[2u8; 32]) && w.mirror.scripts.contains_key(&[3u8; 32]));
assert_eq!(w.pending_add_retries.len(), 1);
w.remove_scripts([[3u8; 32]]).await.unwrap();
while rx.try_recv().is_ok() {}
w.send_add_retry(0).await.unwrap();
let sent = sent_scripts(&mut rx);
assert_eq!(sent.len(), 1, "one re-send: {sent:?}");
assert_eq!(sent[0].scripthashes, vec![vec![2u8; 32]], "only what the caller still holds");
assert_eq!(sent[0].min_values, vec![500], "with its current floor");
assert!(w.pending_add_retries.is_empty());
}
#[tokio::test]
async fn rate_limited_add_is_dropped_and_surfaced_once_the_budget_runs_out() {
let store = Arc::new(MemStore::default());
let mut w = ResilientWatch::new(
StreamClient::for_test(),
ResilientWatchConfig::new().cursor_store(store.clone()).backoff(zero_backoff(Some(1))),
);
w.add_scripts([([2u8; 32], None)]).await.unwrap();
assert!(w.handle_event(rate_limited_scripts(&[2]), None).await.unwrap().is_none(), "retry 1");
let out = w.handle_event(rate_limited_scripts(&[2]), None).await.unwrap();
assert!(matches!(out, Some(Event::WatchAddRejected(_))), "surfaced once the budget is spent");
assert!(!w.mirror.scripts.contains_key(&[2u8; 32]), "and dropped from the mirror");
assert!(w.add_retry_attempts.is_empty(), "its budget is released");
}
#[tokio::test]
async fn a_reconnect_resets_pending_add_retries() {
let store = Arc::new(MemStore::default());
let mut w = ResilientWatch::new(
StreamClient::for_test(),
ResilientWatchConfig::new().cursor_store(store.clone()).backoff(zero_backoff(Some(3))),
);
w.add_scripts([([2u8; 32], None)]).await.unwrap();
w.handle_event(rate_limited_scripts(&[2]), None).await.unwrap();
w.teardown();
assert!(w.pending_add_retries.is_empty() && w.add_retry_attempts.is_empty());
assert!(w.mirror.scripts.contains_key(&[2u8; 32]), "the reconnect re-sends it with the mirror");
}
#[tokio::test]
async fn confirmed_cursor_commits_on_the_following_poll() {
let store = Arc::new(MemStore::default());
let mut w = watch_with(&store);
let c = cur(900);
w.commit_due().unwrap();
let out = w
.handle_event(Event::BlockConnected { hash: vec![0xaa], height: 900 }, Some(c))
.await
.unwrap();
assert!(out.is_some());
w.arm_commit();
assert_eq!(store.load().unwrap(), None);
w.commit_due().unwrap();
assert_eq!(store.load().unwrap(), Some(c));
}
#[derive(Default)]
struct FlakyStore {
inner: Mutex<Option<Cursor>>,
fail_next: Mutex<u32>,
}
impl CursorStore for FlakyStore {
fn load(&self) -> Result<Option<Cursor>, StreamError> {
Ok(*self.inner.lock().unwrap())
}
fn save(&self, cursor: Cursor) -> Result<(), StreamError> {
let mut left = self.fail_next.lock().unwrap();
if *left > 0 {
*left -= 1;
return Err(StreamError::Decode("disk full".into()));
}
*self.inner.lock().unwrap() = Some(cursor);
Ok(())
}
}
#[tokio::test]
async fn commit_retry_after_a_store_failure_actually_writes() {
let store = Arc::new(FlakyStore { fail_next: Mutex::new(1), ..Default::default() });
let mut w = ResilientWatch::new(
StreamClient::for_test(),
ResilientWatchConfig { cursor_store: store.clone(), ..Default::default() },
);
let c = cur(900);
w.handle_event(Event::BlockConnected { hash: vec![0xaa], height: 900 }, Some(c))
.await
.unwrap();
w.arm_commit();
let err = w.commit_due().unwrap_err();
assert!(matches!(err, StreamError::Decode(_)), "the transient failure surfaces");
assert_eq!(store.load().unwrap(), None, "nothing was written");
w.commit_due().expect("retry after a transient failure");
assert_eq!(
store.load().unwrap(),
Some(c),
"the retried commit must persist the cursor the failed one armed; \
returning Ok() with an empty store is a false durability ack"
);
}
#[tokio::test]
async fn seed_resume_prefers_store_over_from_cursor() {
let store = Arc::new(MemStore::default());
store.save(cur(800)).unwrap();
let mut w = ResilientWatch::new(
StreamClient::for_test(),
ResilientWatchConfig::new().cursor_store(store.clone()).from_cursor(cur(1)),
);
w.seed_resume().unwrap();
assert_eq!(w.resume, Some(cur(800)), "persisted cursor wins over from_cursor");
assert_eq!(w.committed, Some(cur(800)), "loaded cursor seeds committed");
}
async fn run_loader(config: &ResilientWatchConfig) -> Result<WatchSetMirror, StreamError> {
let loader = config.watch_set_loader.clone().expect("loader configured");
let shared = Arc::new(Mutex::new(WatchSetMirror::default()));
loader(WatchSetBuilder { mirror: shared.clone() })
.await
.map_err(StreamError::WatchSetLoader)?;
Ok(std::mem::take(&mut *shared.lock().unwrap()))
}
#[test]
fn builder_facade_records_every_watch_kind() {
let m = Arc::new(Mutex::new(WatchSetMirror::default()));
let b = WatchSetBuilder { mirror: m.clone() };
b.set_categories(crate::Categories::CHAIN);
b.add_scripts([([1u8; 32], Some(5_000))]);
b.add_outpoints([([2u8; 32], 0)]);
b.add_tx_lifecycle([[3u8; 32]], AutoClose::AtDepth(6));
b.add_depth_alarms([[4u8; 32]], [3, 6]);
b.add_descriptor("wpkh(xpub...)", 20, 0);
b.add_script_prefixes([(vec![0xab], 8)]).unwrap();
let mirror = std::mem::take(&mut *m.lock().unwrap());
let kinds: Vec<_> = mirror
.control_messages()
.into_iter()
.map(|msg| std::mem::discriminant(&msg))
.collect();
assert_eq!(kinds.len(), 7, "every declared kind renders to a control message");
assert!(
matches!(mirror.control_messages()[0], Msg::SetCategories(_)),
"categories still lead the replay"
);
}
#[test]
fn builder_depth_alarms_filter_and_cross_product() {
let m = Arc::new(Mutex::new(WatchSetMirror::default()));
let b = WatchSetBuilder { mirror: m.clone() };
b.add_depth_alarms([[1u8; 32], [2u8; 32]], [0, 5]);
let mirror = std::mem::take(&mut *m.lock().unwrap());
assert!(mirror.depth_alarms.contains(&([1u8; 32], 5)));
assert!(mirror.depth_alarms.contains(&([2u8; 32], 5)));
assert_eq!(mirror.depth_alarms.len(), 2, "depth 0 dropped, depth 5 kept");
}
#[test]
fn builder_prefix_validation_error_aborts_the_load() {
let m = Arc::new(Mutex::new(WatchSetMirror::default()));
let b = WatchSetBuilder { mirror: m };
assert!(matches!(
b.add_script_prefixes([(vec![], 0)]),
Err(StreamError::InvalidArgument(_))
));
}
#[tokio::test]
async fn loader_rebuilds_the_canonical_watch_set() {
let config = ResilientWatchConfig::new().watch_set_loader(|b: WatchSetBuilder| async move {
b.add_scripts([([7u8; 32], None), ([8u8; 32], Some(1_000))]);
b.add_outpoints([([9u8; 32], 1)]);
Ok(())
});
let mirror = run_loader(&config).await.expect("loader succeeds");
assert!(mirror.scripts.contains_key(&[7u8; 32]));
assert_eq!(mirror.scripts.get(&[8u8; 32]), Some(&Some(1_000)));
assert!(mirror.outpoints.contains(&([9u8; 32], 1)));
}
#[tokio::test]
async fn loader_canonical_set_replaces_prior_in_process_edits() {
let mut w = ResilientWatch::new(
StreamClient::for_test(),
ResilientWatchConfig::new().watch_set_loader(|b: WatchSetBuilder| async move {
b.add_scripts([([2u8; 32], None)]);
Ok(())
}),
);
w.add_scripts([([1u8; 32], None)]).await.unwrap();
assert!(w.mirror.scripts.contains_key(&[1u8; 32]));
w.mirror = run_loader(&w.config).await.unwrap();
assert!(!w.mirror.scripts.contains_key(&[1u8; 32]), "stale in-process edit dropped");
assert!(w.mirror.scripts.contains_key(&[2u8; 32]), "canonical loaded script present");
}
#[tokio::test]
async fn loader_error_maps_to_watch_set_loader_and_is_reconnectable() {
let config = ResilientWatchConfig::new().watch_set_loader(|_b: WatchSetBuilder| async move {
Err("source-of-truth unreachable".into())
});
let err = run_loader(&config).await.expect_err("loader failure surfaces");
assert!(matches!(err, StreamError::WatchSetLoader(_)));
assert!(
is_reconnectable(&err),
"a loader failure is a transient reconnect-level condition, not caller-facing"
);
assert!(err.to_string().contains("source-of-truth unreachable"));
}
#[test]
fn no_loader_leaves_behavior_unchanged() {
assert!(ResilientWatchConfig::new().watch_set_loader.is_none());
}
#[test]
fn reconcile_to_emits_only_the_delta_removes_before_adds() {
let mut old = WatchSetMirror::default();
old.add_scripts(&[([1u8; 32], None), ([2u8; 32], Some(500))]); old.add_outpoints(&[([9u8; 32], 0)]);
old.add_descriptor("desc-keep".into(), 20, 0);
old.add_descriptor("desc-drop".into(), 20, 0);
let mut new = WatchSetMirror::default();
new.add_scripts(&[([1u8; 32], None), ([2u8; 32], Some(999))]); new.add_scripts(&[([3u8; 32], None)]); new.add_descriptor("desc-keep".into(), 20, 0); new.add_descriptor("desc-new".into(), 20, 0);
let (msgs, counts) = old.reconcile_to(&new);
assert_eq!(counts.added, 3, "sh2(changed) + sh3 + desc-new");
assert_eq!(counts.removed, 2, "outpoint + desc-drop");
assert_eq!(counts.unchanged, 2, "sh1 + desc-keep");
let last_remove = msgs.iter().rposition(|m| {
matches!(
m,
Msg::RemoveScripts(_)
| Msg::RemoveOutpoints(_)
| Msg::RemoveTransactions(_)
| Msg::RemoveScriptPrefixes(_)
| Msg::RemoveDescriptor(_)
)
});
let first_add = msgs.iter().position(|m| {
matches!(
m,
Msg::AddScripts(_)
| Msg::AddOutpoints(_)
| Msg::AddTransactions(_)
| Msg::AddDescriptor(_)
| Msg::AddScriptPrefixes(_)
)
});
if let (Some(lr), Some(fa)) = (last_remove, first_add) {
assert!(lr < fa, "every Remove must precede every quota-consuming Add");
}
assert!(msgs.iter().any(|m| matches!(m, Msg::RemoveDescriptor(d) if d.descriptor == "desc-drop")));
}
#[test]
fn reconcile_disjoint_swap_removes_before_adds_for_quota_safety() {
let mut live = WatchSetMirror::default();
live.add_scripts(&[([1u8; 32], None), ([2u8; 32], None), ([3u8; 32], None)]);
let mut target = WatchSetMirror::default();
target.add_scripts(&[([4u8; 32], None), ([5u8; 32], None), ([6u8; 32], None)]);
let (msgs, counts) = live.reconcile_to(&target);
assert_eq!((counts.added, counts.removed, counts.unchanged), (3, 3, 0));
let remove_pos = msgs.iter().position(|m| matches!(m, Msg::RemoveScripts(_)));
let add_pos = msgs.iter().position(|m| matches!(m, Msg::AddScripts(_)));
assert!(
remove_pos < add_pos,
"RemoveScripts must be sent before AddScripts so freed quota covers the replacements",
);
}
#[test]
fn reconcile_identical_sets_is_empty() {
let mut m = WatchSetMirror::default();
m.add_scripts(&[([1u8; 32], Some(5))]);
m.add_descriptor("d".into(), 20, 0);
let (msgs, counts) = m.reconcile_to(&m.clone());
assert!(msgs.is_empty(), "no churn when nothing changed");
assert_eq!((counts.added, counts.removed), (0, 0));
assert_eq!(counts.unchanged, 2);
}
#[test]
fn reconcile_categories_relaxation_emits_an_explicit_reset() {
let mut live = WatchSetMirror::default();
live.set_categories(crate::Categories::CHAIN);
let target = WatchSetMirror::default();
let (msgs, _) = live.reconcile_to(&target);
assert_eq!(
msgs.iter()
.filter_map(|m| match m {
Msg::SetCategories(s) => Some(s.categories),
_ => None,
})
.collect::<Vec<_>>(),
vec![0],
"relaxing a filter to all-categories emits SetCategories{{0}}",
);
let none = WatchSetMirror::default();
let mut chain = WatchSetMirror::default();
chain.set_categories(crate::Categories::CHAIN);
assert!(
matches!(none.reconcile_to(&chain).0.first(), Some(Msg::SetCategories(s)) if s.categories == crate::Categories::CHAIN),
"setting a filter from unfiltered emits it",
);
let mut all = WatchSetMirror::default();
all.set_categories(0);
assert!(
!none.reconcile_to(&all).0.iter().any(|m| matches!(m, Msg::SetCategories(_))),
"None and Some(0) are the same effective filter — no reset emitted",
);
}
#[tokio::test]
async fn reload_without_loader_errors() {
let store = Arc::new(MemStore::default());
let mut w = watch_with(&store);
assert!(matches!(w.reload().await, Err(ReloadError::NoLoader)));
}
#[tokio::test]
async fn reload_surfaces_loader_error() {
let mut w = ResilientWatch::new(
StreamClient::for_test(),
ResilientWatchConfig::new()
.watch_set_loader(|_b: WatchSetBuilder| async move { Err("truth down".into()) }),
);
let err = w.reload().await.expect_err("loader failure surfaces");
assert!(matches!(err, ReloadError::Loader(_)));
assert!(err.to_string().contains("truth down"));
}
#[tokio::test]
async fn reload_while_disconnected_defers_but_updates_the_mirror() {
let mut w = ResilientWatch::new(
StreamClient::for_test(),
ResilientWatchConfig::new().watch_set_loader(|b: WatchSetBuilder| async move {
b.add_scripts([([5u8; 32], None)]);
Ok(())
}),
);
w.add_scripts([([1u8; 32], None)]).await.unwrap();
let summary = w.reload().await.expect("reload ok");
assert!(!summary.applied, "no live stream → deferred to reconnect loader");
assert_eq!(summary.added, 1, "sh5 added");
assert_eq!(summary.removed, 1, "stale sh1 removed");
assert!(w.mirror.scripts.contains_key(&[5u8; 32]));
assert!(!w.mirror.scripts.contains_key(&[1u8; 32]));
}
#[tokio::test]
async fn reload_while_connected_sends_only_the_delta() {
let mut w = ResilientWatch::new(
StreamClient::for_test(),
ResilientWatchConfig::new().watch_set_loader(|b: WatchSetBuilder| async move {
b.add_scripts([([1u8; 32], None), ([3u8; 32], None)]);
Ok(())
}),
);
w.add_scripts([([1u8; 32], None), ([2u8; 32], None)]).await.unwrap();
let (handle, mut rx) = crate::client::WatchHandle::for_test();
w.handle = Some(handle);
let summary = w.reload().await.expect("reload ok");
assert!(summary.applied, "connected → applied live");
assert_eq!((summary.added, summary.removed, summary.unchanged), (1, 1, 1));
let mut msgs = Vec::new();
while let Ok(ctrl) = rx.try_recv() {
msgs.push(ctrl.msg.unwrap());
}
assert_eq!(msgs.len(), 1, "reload sends exactly one control message");
let Msg::SetWatchSet(sws) = &msgs[0] else {
panic!("expected a SetWatchSet, got {:?}", msgs[0]);
};
let mut got: Vec<Vec<u8>> = sws.scripthashes.clone();
got.sort();
assert_eq!(
got,
vec![[1u8; 32].to_vec(), [3u8; 32].to_vec()],
"the snapshot is the full desired set, not a delta",
);
}
#[tokio::test]
async fn reload_turns_off_raw_tx_opt_in_when_truth_drops_it() {
let mut w = ResilientWatch::new(
StreamClient::for_test(),
ResilientWatchConfig::new().watch_set_loader(|b: WatchSetBuilder| async move {
b.add_scripts([([1u8; 32], None)]);
Ok(())
}),
);
w.add_scripts([([1u8; 32], None)]).await.unwrap();
w.set_watch_options(true).await.unwrap(); assert_eq!(w.mirror.include_raw_tx, Some(true));
let (handle, mut rx) = crate::client::WatchHandle::for_test();
w.handle = Some(handle);
w.reload().await.expect("reload ok");
let mut msgs = Vec::new();
while let Ok(ctrl) = rx.try_recv() {
msgs.push(ctrl.msg.unwrap());
}
let opt = msgs.iter().find_map(|m| match m {
Msg::SetWatchOptions(o) => Some(o.include_raw_tx),
_ => None,
});
assert_eq!(opt, Some(false), "reload turns the opt-in OFF to match truth");
assert_eq!(w.mirror.include_raw_tx, None);
}
#[tokio::test]
async fn reload_does_not_touch_raw_tx_opt_in_when_unchanged() {
let mut w = ResilientWatch::new(
StreamClient::for_test(),
ResilientWatchConfig::new().watch_set_loader(|b: WatchSetBuilder| async move {
b.add_scripts([([1u8; 32], None)]);
Ok(())
}),
);
w.add_scripts([([1u8; 32], None)]).await.unwrap();
let (handle, mut rx) = crate::client::WatchHandle::for_test();
w.handle = Some(handle);
w.reload().await.expect("reload ok");
let mut msgs = Vec::new();
while let Ok(ctrl) = rx.try_recv() {
msgs.push(ctrl.msg.unwrap());
}
assert!(
!msgs.iter().any(|m| matches!(m, Msg::SetWatchOptions(_))),
"no opt-in change → no SetWatchOptions churn, got {msgs:?}",
);
}
}