Skip to main content

satd_events_client/
event.rs

1//! Typed event model — an ergonomic mirror of the `satd.events.v1` wire
2//! `NodeEvent.body` tagged union, so consumers `match` on a flat enum instead
3//! of unwrapping nested protobuf `Option`s.
4//!
5//! Hashes and txids are carried as raw `Vec<u8>` in **internal (consensus)
6//! byte order** — the order the wire and key derivation use, *not* the reversed
7//! order block explorers and Bitcoin Core JSON-RPC display. Convert at the edge:
8//! [`parse_txid`] returns a typed [`bitcoin::Txid`] (whose `Display` is explorer
9//! order, and which drops into `bitcoin`-ecosystem APIs) with the `bitcoin`
10//! feature, and [`display_hex`] returns a display-order hex string with no extra
11//! dependency. Only hashes/txids are byte-reversed for display — do not apply
12//! either helper to a public key or tweak (`output_pubkey`, `tweak`).
13
14use satd_events_proto::v1 as pb;
15
16/// Render a raw wire hash/txid (32 bytes, **internal / consensus byte order**,
17/// as every hash and txid on this API is carried) as a lowercase hex string in
18/// the reversed **display order** used by block explorers and Bitcoin Core
19/// JSON-RPC.
20///
21/// Use it on a hash or txid field (`txid`, `block_hash`, …). Do **not** apply
22/// it to a public key or tweak: those are raw bytes but are *not* reversed for
23/// display. Needs no optional dependency; for a typed value use [`parse_txid`].
24///
25/// ```
26/// # use satd_events_client::display_hex;
27/// let internal: Vec<u8> = (0u8..32).collect(); // 00,01,…,1f
28/// assert!(display_hex(&internal).starts_with("1f1e1d")); // reversed
29/// ```
30pub fn display_hex(internal_hash: &[u8]) -> String {
31    internal_hash
32        .iter()
33        .rev()
34        .map(|b| format!("{b:02x}"))
35        .collect()
36}
37
38/// Parse a raw wire txid (32 bytes, internal/consensus byte order) into a typed
39/// [`bitcoin::Txid`]. The returned value's `Display` is the usual explorer /
40/// JSON-RPC order, and it hands straight to `bitcoin`-ecosystem APIs
41/// (`bitcoincore-rpc`, comparison against `getrawtransaction`, …).
42///
43/// A thin wrapper over [`bitcoin::Txid::from_slice`]; the block-hash equivalent
44/// is `bitcoin::BlockHash::from_slice(bytes)`.
45///
46/// ```
47/// # use satd_events_client::{parse_txid, display_hex};
48/// let raw: Vec<u8> = (0u8..32).collect();
49/// let txid = parse_txid(&raw).unwrap();
50/// assert_eq!(txid.to_string(), display_hex(&raw)); // both reverse to display order
51/// ```
52#[cfg(feature = "bitcoin")]
53pub fn parse_txid(raw: &[u8]) -> Result<bitcoin::Txid, bitcoin::hashes::FromSliceError> {
54    use bitcoin::hashes::Hash as _;
55    bitcoin::Txid::from_slice(raw)
56}
57
58/// Durable resume position, re-exported from the wire schema. Persist the
59/// value returned by [`EventStream::cursor`](crate::EventStream::cursor) and
60/// present it again as `from_cursor` to resume.
61pub use satd_events_proto::v1::Cursor;
62
63/// An outpoint (`txid:vout`), raw bytes.
64#[derive(Debug, Clone, PartialEq, Eq)]
65pub struct Outpoint {
66    /// Transaction id, 32 raw bytes (internal byte order — [`parse_txid`] or
67    /// [`display_hex`] for a typed value / explorer-order string).
68    pub txid: Vec<u8>,
69    /// Output index.
70    pub vout: u32,
71}
72
73/// Descriptor attribution for a [`ScriptMatched`](Event::ScriptMatched): which
74/// descriptor watch a matched scripthash belongs to, and the exact coordinate the
75/// server derived it at.
76#[derive(Debug, Clone, PartialEq, Eq)]
77pub struct DescriptorMatch {
78    /// The descriptor string the watch was registered with.
79    pub descriptor: String,
80    /// The 0-based BIP-389 multipath branch the matched script came from (`<0;1>`
81    /// → external = 0, change = 1; always 0 for a single-path descriptor).
82    pub branch: u32,
83    /// The absolute derivation index of the matched script — ready to use, no
84    /// `gap_limit` arithmetic. `(branch, derivation_index)` is exactly what the
85    /// server derived (correct for fixed and multipath descriptors alike); the
86    /// server still tracks no derivation progress — advancing your gap limit
87    /// remains your concern.
88    pub derivation_index: u32,
89}
90
91/// Why a transaction left the mempool by policy.
92///
93/// Like the status enums, this distinguishes an **unset** field
94/// ([`Unspecified`](Self::Unspecified), proto3's zero value) from a value this
95/// build does not recognize ([`Unknown`](Self::Unknown)) — two different facts
96/// that must not collapse into one variant.
97#[derive(Debug, Clone, Copy, PartialEq, Eq)]
98#[non_exhaustive]
99pub enum EvictReason {
100    /// The producer did not set a reason (proto3's zero value).
101    Unspecified,
102    /// Evicted because the pool hit its byte budget.
103    FullPool,
104    /// Evicted on mempool-expiry.
105    Expiry,
106    /// Evicted because a connected block conflicts with it.
107    BlockConflict,
108    /// Evicted from the quarantine class on a fee-rate byte-budget overflow.
109    Policy,
110    /// Evicted because a chain reorganization made the transaction invalid
111    /// (locktime no longer satisfied, coinbase became immature, or BIP 68
112    /// sequence lock unsatisfied).
113    Reorg,
114    /// An eviction reason this client build does not recognize.
115    Unknown(i32),
116}
117
118/// Which node-health condition an [`Event::Status`] describes.
119///
120/// Open by design: a newer node may report a kind this client build predates,
121/// which surfaces as [`Unknown`](StatusKind::Unknown) rather than an error. The
122/// event's `severity` and `message` stay meaningful in that case, so a generic
123/// "log it and page on critical" handler keeps working across upgrades.
124///
125/// `#[non_exhaustive]` for the reason the `Unknown` arm exists: this crate is
126/// published, the node's taxonomy grows independently of it, and a downstream
127/// `match` that listed every variant *including* `Unknown(_)` would be
128/// exhaustive — so adding a kind here would be a breaking release. Forcing a
129/// wildcard arm keeps a node-side addition additive for every consumer.
130#[non_exhaustive]
131#[derive(Debug, Clone, Copy, PartialEq, Eq)]
132pub enum StatusKind {
133    /// The producer did not set a kind (proto3's zero value). Distinct from
134    /// [`Unknown`](Self::Unknown), which is a kind that *was* set and this
135    /// build does not recognize.
136    Unspecified,
137    /// Initial block download finished (one-shot).
138    IbdComplete,
139    /// No block connected inside the configured window.
140    ///
141    /// Not suppressed during initial block download: the node's IBD predicate
142    /// is the tip header's age, not a sync flag, so a node that was caught up
143    /// and then wedged re-enters it exactly when you want to hear about it. A
144    /// genuinely syncing node connects blocks and never crosses the threshold.
145    TipStall,
146    /// Free space on the watched directory fell below the configured floor.
147    DiskLow,
148    /// Mempool occupancy crossed the configured share of its byte cap.
149    MempoolCongested,
150    /// Connected peers stayed below the configured floor.
151    PeerFloor,
152    /// A reorg at least the configured depth landed (one-shot).
153    DeepReorg,
154    /// A block template the node built failed its own validity check.
155    TemplateInvalid,
156    /// A condition this client build does not recognize.
157    Unknown(i32),
158}
159
160impl StatusKind {
161    fn from_proto(v: i32) -> Self {
162        match pb::StatusKind::try_from(v) {
163            Ok(pb::StatusKind::Unspecified) => StatusKind::Unspecified,
164            Ok(pb::StatusKind::IbdComplete) => StatusKind::IbdComplete,
165            Ok(pb::StatusKind::TipStall) => StatusKind::TipStall,
166            Ok(pb::StatusKind::DiskLow) => StatusKind::DiskLow,
167            Ok(pb::StatusKind::MempoolCongested) => StatusKind::MempoolCongested,
168            Ok(pb::StatusKind::PeerFloor) => StatusKind::PeerFloor,
169            Ok(pb::StatusKind::DeepReorg) => StatusKind::DeepReorg,
170            Ok(pb::StatusKind::TemplateInvalid) => StatusKind::TemplateInvalid,
171            _ => StatusKind::Unknown(v),
172        }
173    }
174}
175
176/// Level-triggered lifecycle of a health condition.
177///
178/// `#[non_exhaustive]` — see [`StatusKind`].
179#[non_exhaustive]
180#[derive(Debug, Clone, Copy, PartialEq, Eq)]
181pub enum StatusState {
182    /// The producer did not set a state (proto3's zero value).
183    ///
184    /// A consumer tracking standing conditions cannot infer a lifecycle from
185    /// this — it is neither a raise nor a clear — so treat it as "unusable" and
186    /// say so, rather than silently dropping it. Dropping it is how a condition
187    /// whose *clear* arrived unset stays standing forever.
188    Unspecified,
189    /// The condition was entered; it stands until a matching `Cleared`.
190    Raised,
191    /// The condition recovered.
192    Cleared,
193    /// A one-shot observation; no `Cleared` will follow.
194    Edge,
195    /// A state this client build does not recognize.
196    Unknown(i32),
197}
198
199impl StatusState {
200    fn from_proto(v: i32) -> Self {
201        match pb::StatusState::try_from(v) {
202            Ok(pb::StatusState::Unspecified) => StatusState::Unspecified,
203            Ok(pb::StatusState::Raised) => StatusState::Raised,
204            Ok(pb::StatusState::Cleared) => StatusState::Cleared,
205            Ok(pb::StatusState::Edge) => StatusState::Edge,
206            _ => StatusState::Unknown(v),
207        }
208    }
209}
210
211/// How loud a health condition is.
212///
213/// Ordering is by *severity rank*, not by proto value, so a client filters with
214/// a comparison (`severity >= StatusSeverity::Warning`). The rank is:
215///
216/// ```text
217/// Unspecified  <  Info  <  Warning  <  Critical  <  Unknown(_)
218/// ```
219///
220/// `Unknown(_)` sorting above `Critical` is deliberate: a severity this build
221/// cannot name is not one to quietly filter out, so an additive taxonomy change
222/// fails loud rather than silent.
223///
224/// [`Unspecified`](Self::Unspecified) is the opposite case and is why the two
225/// are separate variants. `STATUS_SEVERITY_UNSPECIFIED` is proto3's zero value,
226/// which is what an absent field decodes to — a producer that never set the
227/// field at all. That is an absence of information, not a loud condition, so it
228/// ranks lowest. Folding it into `Unknown` (as this type once did) made an
229/// unset severity outrank `Critical` and page on the documented filter.
230///
231/// **satd itself never emits a zero severity** — the node's own enum has three
232/// variants and the encoder writes one of them explicitly — so this is defence
233/// against a third-party producer, an in-path relay that re-encodes the event,
234/// or a future schema, not a bug you can have hit against a stock node.
235///
236/// If you do see `Unspecified` and need a level, `kind` is the recovery: in v1
237/// severity is a fixed function of the condition, so the kind's documented
238/// severity is the one the originating node would have sent.
239///
240/// `#[non_exhaustive]` — see [`StatusKind`].
241#[non_exhaustive]
242#[derive(Debug, Clone, Copy, PartialEq, Eq)]
243pub enum StatusSeverity {
244    /// The producer did not set a severity (proto3's zero value).
245    ///
246    /// Ranks below `Info`: the event is still delivered and still carries a
247    /// `kind` and `message`, but a severity floor cannot promote a level that
248    /// was never stated.
249    Unspecified,
250    /// Worth knowing, not worth waking anyone (IBD completing).
251    Info,
252    /// Degraded but functioning (peer starvation, a congested mempool).
253    Warning,
254    /// The node is not doing its job, or is about to stop (a stalled tip, a
255    /// filling disk, a deep reorg).
256    Critical,
257    /// A severity this client build does not recognize. Ranks above `Critical`.
258    Unknown(i32),
259}
260
261impl StatusSeverity {
262    fn from_proto(v: i32) -> Self {
263        match pb::StatusSeverity::try_from(v) {
264            Ok(pb::StatusSeverity::Unspecified) => StatusSeverity::Unspecified,
265            Ok(pb::StatusSeverity::Info) => StatusSeverity::Info,
266            Ok(pb::StatusSeverity::Warning) => StatusSeverity::Warning,
267            Ok(pb::StatusSeverity::Critical) => StatusSeverity::Critical,
268            _ => StatusSeverity::Unknown(v),
269        }
270    }
271
272    /// Sort key: the severity rank, then the raw value.
273    ///
274    /// The second element only ever distinguishes two `Unknown`s, and it is
275    /// required rather than cosmetic: `Ord` must agree with `Eq`, and the
276    /// derived `Eq` says `Unknown(5) != Unknown(9)`. Ranking both as simply
277    /// "above Critical" and stopping there would report them equal, which
278    /// breaks the total order and, with it, `BTreeMap`/`sort` on this type.
279    fn rank(self) -> (u8, i32) {
280        match self {
281            StatusSeverity::Unspecified => (0, 0),
282            StatusSeverity::Info => (1, 0),
283            StatusSeverity::Warning => (2, 0),
284            StatusSeverity::Critical => (3, 0),
285            StatusSeverity::Unknown(v) => (4, v),
286        }
287    }
288}
289
290impl Ord for StatusSeverity {
291    fn cmp(&self, other: &Self) -> std::cmp::Ordering {
292        self.rank().cmp(&other.rank())
293    }
294}
295
296impl PartialOrd for StatusSeverity {
297    fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
298        Some(self.cmp(other))
299    }
300}
301
302impl From<pb::EvictReason> for EvictReason {
303    fn from(r: pb::EvictReason) -> Self {
304        match r {
305            pb::EvictReason::Unspecified => EvictReason::Unspecified,
306            pb::EvictReason::FullPool => EvictReason::FullPool,
307            pb::EvictReason::Expiry => EvictReason::Expiry,
308            pb::EvictReason::BlockConflict => EvictReason::BlockConflict,
309            pb::EvictReason::Policy => EvictReason::Policy,
310            pb::EvictReason::Reorg => EvictReason::Reorg,
311        }
312    }
313}
314
315impl EvictReason {
316    /// Decode from the raw wire value.
317    ///
318    /// This exists because prost's generated `reason()` accessor is
319    /// `try_from(..).unwrap_or_default()`, which turns *any* unrecognized value
320    /// into the zero variant. Going through it made `EvictReason::Unknown`
321    /// unconstructible and reported a reason added by a newer node as
322    /// "unspecified" — indistinguishable from a producer that omitted the
323    /// field. Decode from the `i32` instead, as every other open enum here
324    /// does.
325    fn from_proto(v: i32) -> Self {
326        match pb::EvictReason::try_from(v) {
327            Ok(r) => r.into(),
328            Err(_) => EvictReason::Unknown(v),
329        }
330    }
331}
332
333/// A k-bit prefix of `sha256(scriptPubKey)`.
334#[derive(Debug, Clone, PartialEq, Eq)]
335pub struct ScriptPrefix {
336    /// The top `ceil(bits/8)` bytes, big-endian.
337    pub prefix: Vec<u8>,
338    /// Prefix length in bits.
339    pub bits: u32,
340}
341
342/// One of a transaction's taproot outputs — a scan candidate. Carried in
343/// [`TweakEntry::taproot_outputs`] so a client can confirm a derived output key
344/// against the actual on-chain output without fetching the transaction.
345#[derive(Debug, Clone, PartialEq, Eq)]
346pub struct TaprootOutput {
347    /// Output index within the transaction.
348    pub vout: u32,
349    /// 32-byte x-only taproot output key, internal (consensus) byte order — the
350    /// raw `scriptPubKey` push, unreversed. Compare it directly against a
351    /// derived `P_k.x_only_public_key().serialize()` with no byte flip.
352    pub output_pubkey: Vec<u8>,
353    /// Output value in satoshis.
354    pub value: u64,
355}
356
357/// One transaction's public silent-payment tweak, carried in a
358/// [`Event::BlockTweaks`] for Tier 1 client-side scanning.
359#[derive(Debug, Clone, PartialEq, Eq)]
360pub struct TweakEntry {
361    /// 33-byte compressed public tweak `T = input_hash · A` for the transaction.
362    /// Always present — this is what a client feeds its own `b_scan` into to
363    /// scan the transaction's outputs locally.
364    pub tweak: Vec<u8>,
365    /// The transaction's txid (internal/consensus byte order — [`parse_txid`] /
366    /// [`display_hex`]). Empty when the subscription set `tweaks_only` (the
367    /// compact, tweak-alone form).
368    pub txid: Vec<u8>,
369    /// The largest taproot output value in the transaction, in satoshis — a cap
370    /// on what a payment here could be worth, for client-side dust triage. `0`
371    /// under `tweaks_only`.
372    pub max_value: u64,
373    /// The transaction's taproot outputs (the scan candidates). Always populated
374    /// on a [`Event::MempoolTweak`] — the whole point of Tier 1.5, since there
375    /// is no block to fetch to recover them. On a [`Event::BlockTweaks`] entry it
376    /// is present only when the subscription set `tweak_outputs`; empty
377    /// otherwise (the confirmed block is the fallback).
378    pub taproot_outputs: Vec<TaprootOutput>,
379}
380
381/// A spent prevout that matched a prefix bucket (the spend side of a
382/// [`Event::PrefixMatched`]).
383#[derive(Debug, Clone, PartialEq, Eq)]
384pub struct SpentPrevout {
385    /// The outpoint consumed.
386    pub outpoint: Outpoint,
387    /// The `scriptPubKey` it paid. Empty when the server did not retain it
388    /// (mempool spend below the `full` retention tier) — resolve the outpoint
389    /// yourself in that case.
390    pub script_pubkey: Vec<u8>,
391    /// The prevout value in satoshis. `None` when not retained (distinct from a
392    /// genuine 0-value prevout, which is `Some(0)`).
393    pub amount: Option<u64>,
394}
395
396impl From<pb::SpentPrevout> for SpentPrevout {
397    fn from(s: pb::SpentPrevout) -> Self {
398        SpentPrevout {
399            outpoint: Outpoint { txid: s.outpoint_txid, vout: s.outpoint_vout },
400            script_pubkey: s.script_pubkey,
401            amount: s.has_amount.then_some(s.amount),
402        }
403    }
404}
405
406/// A transaction that fell inside a watched script-prefix bucket. Carries the
407/// full serialized tx so the client filters the bucket against its real scripts
408/// locally (the privacy contract — the server only learns the bucket).
409#[derive(Debug, Clone, PartialEq, Eq)]
410pub struct PrefixMatch {
411    /// The registered bucket that fired.
412    pub prefix: ScriptPrefix,
413    /// Consensus-serialized matching transaction.
414    pub raw_tx: Vec<u8>,
415    /// `false` = mempool, `true` = connected block.
416    pub confirmed: bool,
417    /// Block height when confirmed; 0 in the mempool.
418    pub height: u32,
419    /// Spend-side matched prevouts; empty for a pure funding (output) match.
420    pub matched_prevouts: Vec<SpentPrevout>,
421}
422
423/// A typed streaming event — the flat mirror of `NodeEvent.body`.
424///
425/// `Eq` is not derived: the [`Lagged`](Event::Lagged) resume cursor is a
426/// prost-generated type that is only `PartialEq`.
427#[derive(Debug, Clone, PartialEq)]
428#[non_exhaustive]
429pub enum Event {
430    /// A transaction entered the mempool.
431    MempoolEnter {
432        /// Transaction id.
433        txid: Vec<u8>,
434        /// Fee in satoshis.
435        fee: u64,
436        /// Virtual size in vbytes.
437        vsize: u64,
438        /// Fee rate in sat/kvB.
439        fee_rate_sat_per_kvb: u64,
440        /// Admission time, seconds since the Unix epoch.
441        time: u64,
442    },
443    /// A mempool transaction was confirmed in a block.
444    MempoolLeaveConfirmed {
445        /// Transaction id.
446        txid: Vec<u8>,
447        /// Confirming block hash.
448        block_hash: Vec<u8>,
449        /// Confirming block height.
450        height: u32,
451    },
452    /// A mempool transaction was evicted by policy.
453    MempoolLeaveEvicted {
454        /// Transaction id.
455        txid: Vec<u8>,
456        /// Eviction reason.
457        reason: EvictReason,
458    },
459    /// A mempool transaction was replaced (RBF).
460    MempoolLeaveReplaced {
461        /// Transaction id.
462        txid: Vec<u8>,
463        /// The replacing transaction id.
464        replacing_txid: Vec<u8>,
465    },
466    /// A block was connected to the active chain.
467    BlockConnected {
468        /// Block hash.
469        hash: Vec<u8>,
470        /// Block height.
471        height: u32,
472    },
473    /// A block was disconnected (reorg).
474    BlockDisconnected {
475        /// Block hash.
476        hash: Vec<u8>,
477        /// Block height.
478        height: u32,
479    },
480    /// A reorg marker, emitted once before the disconnect/connect sequence.
481    Reorg {
482        /// Height of the abandoned tip.
483        from_height: u32,
484        /// Hash of the abandoned tip.
485        old_tip: Vec<u8>,
486        /// Height of the new active tip.
487        to_height: u32,
488        /// Hash of the new active tip.
489        new_tip: Vec<u8>,
490    },
491    /// A periodic heartbeat.
492    Heartbeat {
493        /// Publisher uptime in nanoseconds.
494        uptime_ns: u64,
495    },
496    /// A node-health condition the daemon detected about **itself** — a stalled
497    /// tip, a filling disk, peer starvation. Arrives only for a subscription
498    /// that set the [`STATUS`](crate::Categories::STATUS) category bit, which
499    /// is deliberately not part of the `0` ("all") default.
500    ///
501    /// Standing conditions are level-triggered: exactly one
502    /// [`StatusState::Raised`] on entry and one [`StatusState::Cleared`] on
503    /// recovery, with hysteresis so a value hovering at the threshold does not
504    /// flap. Observations with no recovered state (IBD finishing, a deep reorg
505    /// landing) are [`StatusState::Edge`].
506    ///
507    /// **Not replayable, and the gap is not observable.** A status event carries
508    /// no cursor, so a `from_cursor` resume never yields one; the node re-raises
509    /// standing conditions after a restart instead. A client that connects
510    /// *after* a condition was raised will not see it until the condition
511    /// changes.
512    ///
513    /// This is not something the reconnect layer can paper over.
514    /// [`ResilientSubscription`](crate::ResilientSubscription) hides reconnects
515    /// by design, and its one synthetic notice
516    /// ([`Event::ReplayGap`]) is cursor-anchored — which status has no part in.
517    /// So a drop during which one condition raised and another cleared leaves a
518    /// client's picture silently wrong, with nothing to key recovery off.
519    ///
520    /// Treat this stream as the low-latency edge signal and **`getwarnings` over
521    /// JSON-RPC as the authority** on what is wrong right now: poll it on a slow
522    /// timer as well as consuming this, and a missed transition self-corrects at
523    /// the next poll instead of persisting for the life of the process. Seeding
524    /// once on connect is not enough, because reconnects after that are
525    /// invisible to you.
526    ///
527    /// Match on [`kind`](StatusKind) and read [`details`](Event::Status) for the
528    /// numbers; treat [`StatusKind::Unknown`] as informational rather than an
529    /// error, since new conditions are added additively. See
530    /// [`examples/health_watch.rs`].
531    Status {
532        /// Which condition this describes.
533        kind: StatusKind,
534        /// Whether it was entered, recovered, or is a one-shot observation.
535        state: StatusState,
536        /// How loud it is. Ordered, so a severity floor is a comparison.
537        severity: StatusSeverity,
538        /// Human-readable one-liner. Log it or page on it; do not parse it.
539        message: String,
540        /// Kind-specific structured fields, as strings.
541        ///
542        /// Mostly decimal numbers (free bytes and the threshold, seconds since
543        /// the last block, a reorg's depth and fork height, …) but **not
544        /// uniformly** — a `cleared` event can carry a `reason` token such as
545        /// `detector_disabled` or `mempool_cap_zero`. Parse per key, not with
546        /// one integer parse over the map.
547        ///
548        /// The watched *path* is deliberately absent: it reaches every status
549        /// subscriber and every webhook receiver, and an absolute datadir path
550        /// usually names the account the node runs under.
551        ///
552        /// Tolerate unknown keys and absent optional ones.
553        details: std::collections::BTreeMap<String, String>,
554    },
555    /// A watched outpoint was spent.
556    OutpointSpent {
557        /// The spent outpoint.
558        outpoint: Outpoint,
559        /// The spending transaction id.
560        spending_txid: Vec<u8>,
561        /// The spending input index.
562        spending_vin: u32,
563        /// `false` = seen in mempool, `true` = confirmed.
564        confirmed: bool,
565    },
566    /// A watched script was matched on the funding or spending side.
567    ScriptMatched {
568        /// The matched scripthash.
569        scripthash: Vec<u8>,
570        /// The matching transaction id.
571        txid: Vec<u8>,
572        /// `true` = funding (output), `false` = spending (input).
573        is_output: bool,
574        /// vout if `is_output`, else vin.
575        index: u32,
576        /// `false` = mempool, `true` = confirmed.
577        confirmed: bool,
578        /// Matched value in satoshis: the funded output value (`is_output =
579        /// true`) or the spent-prevout value (`is_output = false`). `Some` on
580        /// the funding side and for confirmed spends; `Some` for mempool spends
581        /// when the node retained the prevout value (`streamprevoutmeta >=
582        /// amount`), else `None` (hash tier). `None` lets the consumer skip the
583        /// enrichment `getrawtransaction` for the common single-coin case.
584        amount: Option<u64>,
585        /// Full consensus-serialized matching transaction, present only when
586        /// this stream opted in via
587        /// [`set_watch_options`](crate::WatchControls::set_watch_options) with
588        /// `include_raw_tx = true`; `None` otherwise.
589        raw_tx: Option<Vec<u8>>,
590        /// Descriptor attribution: the descriptor watch(es) this scripthash
591        /// belongs to, if it was registered via `add_descriptor`. Empty for a
592        /// directly-watched script. See [`DescriptorMatch`].
593        descriptors: Vec<DescriptorMatch>,
594    },
595    /// A watched txid appeared in the mempool or a connected block.
596    TxidMatched {
597        /// Transaction id.
598        txid: Vec<u8>,
599        /// `false` = mempool, `true` = confirmed.
600        confirmed: bool,
601        /// Block height when confirmed; 0 in the mempool.
602        height: u32,
603    },
604    /// A watched tx was replaced in the mempool (RBF).
605    TxidReplaced {
606        /// Transaction id.
607        txid: Vec<u8>,
608        /// The replacing transaction id.
609        replacing_txid: Vec<u8>,
610    },
611    /// A watched tx left the mempool by policy.
612    TxidEvicted {
613        /// Transaction id.
614        txid: Vec<u8>,
615        /// Free-text reason (`"full_pool"` | `"expiry"` | `"block_conflict"` |
616        /// `"policy"`).
617        reason: String,
618    },
619    /// A watched tx's confirming block was rolled back by a reorg.
620    TxidUnconfirmed {
621        /// Transaction id.
622        txid: Vec<u8>,
623        /// Height it had been confirmed at, now disconnected.
624        prev_height: u32,
625    },
626    /// A depth alarm fired (single-shot).
627    TxidDepthReached {
628        /// Transaction id.
629        txid: Vec<u8>,
630        /// Confirmations reached (>= the requested depth).
631        depth: u32,
632        /// Active-chain height where the tx is confirmed.
633        height: u32,
634    },
635    /// A lifecycle watch's `auto_close_depth` was reached (terminal).
636    TxidFinalized {
637        /// Transaction id.
638        txid: Vec<u8>,
639        /// Confirmations reached (>= `auto_close_depth`).
640        depth: u32,
641        /// Active-chain height where the tx is confirmed.
642        height: u32,
643    },
644    /// A transaction fell inside a watched script-prefix bucket.
645    PrefixMatched(PrefixMatch),
646    /// In-band slow-consumer lag notice. Not an error: reconnect (Subscribe) or
647    /// re-anchor (Watch) with `resume_cursor` to recover the dropped events.
648    Lagged {
649        /// Number of events dropped before this notice.
650        dropped_count: u64,
651        /// The anchor to resume from to recover the gap.
652        resume_cursor: Option<Cursor>,
653    },
654    /// **SDK-synthesized — not a wire event.** Emitted by
655    /// [`ResilientSubscription`](crate::ResilientSubscription) when a durable
656    /// replay was clamped by the server to the most recent `MAX_REPLAY_BLOCKS`
657    /// (10,000) blocks, so the confirmed history in `[resume_height,
658    /// first_height - 1]` was skipped. The live stream continues correctly from
659    /// `first_height`; the gap is unrecoverable via this stream, so full-resync
660    /// the skipped range from another source (e.g. RPC `getblock`). Emitted once
661    /// per resume, immediately before the first replayed block.
662    ///
663    /// **Detection limit — absence of this event is not proof of a complete
664    /// scan.** It is synthesized by comparing the resume cursor against the
665    /// first *`BlockConnected`* delivered. A subscription that filtered chain
666    /// events out — a tweaks-only silent-payment scan, say — receives no
667    /// `BlockConnected`, so a clamp it suffered passes unremarked here. Such a
668    /// subscription is normally protected server-side instead (a tweaks-only
669    /// replay against a complete index is exempt from the clamp, and a node
670    /// whose index is incomplete refuses the subscribe outright rather than
671    /// clamping it), but that protection lives on the node, not in this event.
672    /// Do not treat this variant as a wallet's only guard against a silent hole.
673    ReplayGap {
674        /// The height the resume cursor expected next (`cursor.height + 1`).
675        resume_height: u32,
676        /// The first height the server actually delivered (`> resume_height`).
677        first_height: u32,
678    },
679    /// A mid-stream re-anchor ([`WatchHandle::set_cursor`](crate::WatchHandle::set_cursor))
680    /// was **admitted**. Confirmed-history replay follows this event (in height
681    /// order) before the live tail resumes. When `clamped` is true the requested
682    /// cursor predated the server's replay window: replay still runs, but only
683    /// from `earliest_replayed`, so full-resync history below it from another
684    /// source. This is the deterministic "accepted, replaying from X" signal.
685    CursorAccepted {
686        /// The cursor the server anchored to.
687        from: Option<Cursor>,
688        /// `true` → the replay window truncated the lower end of the gap.
689        clamped: bool,
690        /// First height the server will replay.
691        earliest_replayed: u32,
692    },
693    /// A mid-stream re-anchor ([`WatchHandle::set_cursor`](crate::WatchHandle::set_cursor))
694    /// was **not** admitted. The live stream is unchanged (still emitting from
695    /// its prior position). Decide whether to retry, back off, or escalate to a
696    /// full resnapshot based on `reason`; `current_head` is where the server is
697    /// now.
698    CursorRejected {
699        /// Why the re-anchor was declined.
700        reason: CursorRejectReason,
701        /// The server's current resume position.
702        current_head: Option<Cursor>,
703    },
704    /// An atomic watch-set replace ([`ResilientWatch::reload`](crate::ResilientWatch::reload),
705    /// carried as `SetWatchSet`) was applied. The live watch-set now equals the
706    /// reloaded truth; the counts are the server's authoritative diff by
707    /// **effective coverage** (a scripthash covered by both the old and new set —
708    /// even via a different mechanism — counts as `unchanged`, never gapped).
709    WatchSetReplaced {
710        /// Items newly watched.
711        added: u32,
712        /// Items released.
713        removed: u32,
714        /// Items in both the old and new set (kept without re-registration).
715        unchanged: u32,
716    },
717    /// An atomic watch-set replace was **rejected**; the live watch-set is
718    /// UNCHANGED (the prior set is still in effect). `reason` says which ceiling
719    /// refused it, and `required`/`quota` are in the matching unit:
720    /// [`QuotaExceeded`](WatchSetRejectReason::QuotaExceeded) — `required` units
721    /// vs the `quota` ceiling; [`CapExceeded`](WatchSetRejectReason::CapExceeded)
722    /// — `required` entries vs the per-connection entry cap (`quota`);
723    /// [`Malformed`](WatchSetRejectReason::Malformed) — a client bug (bad element),
724    /// `required`/`quota` are 0 and retrying the same set will not help. In every
725    /// case the client's mirror still reflects the (unapplied) reloaded set, so a
726    /// consumer that ignores this keeps an over-claiming mirror; react by
727    /// reloading a set the server accepts.
728    WatchSetRejected {
729        /// Why the replace was refused.
730        reason: WatchSetRejectReason,
731        /// What the rejected target needs: units (`QuotaExceeded`) or entries
732        /// (`CapExceeded`); 0 for `Malformed`.
733        required: u64,
734        /// The ceiling that refused it: unit quota (`QuotaExceeded`) or entry cap
735        /// (`CapExceeded`); 0 for `Malformed`.
736        quota: u64,
737    },
738    /// A bounded historical rescan ([`ResilientWatch::rescan`](crate::ResilientWatch::rescan))
739    /// was **admitted**. Confirmed watch-matches for the scanned range follow
740    /// this event (in height order), terminated by a [`RescanComplete`](Event::RescanComplete).
741    /// `from_height`/`to_height` are the range the server will ACTUALLY scan:
742    /// `clamped` is true when the requested upper bound exceeded the tip and was
743    /// narrowed to it. A rescan is a side query — it does not advance the durable
744    /// cursor, and its match events carry no resume cursor.
745    RescanAccepted {
746        /// First height that will be scanned (post-clamp).
747        from_height: u32,
748        /// Last height that will be scanned (post-clamp).
749        to_height: u32,
750        /// `true` → the requested range was narrowed to what the node holds.
751        clamped: bool,
752    },
753    /// A bounded historical rescan was **not** admitted; no matches follow and
754    /// the live stream is unchanged. `tip_height` is the server's current tip so
755    /// a client can re-scope the range and retry.
756    RescanRejected {
757        /// Why the rescan was declined.
758        reason: RescanRejectReason,
759        /// The server's current active-chain tip height.
760        tip_height: u32,
761    },
762    /// Terminal marker for a bounded historical rescan: the range has been fully
763    /// scanned and every match delivered. `matches` counts the match events this
764    /// rescan emitted (0 when the range held none). After this the stream resumes
765    /// its prior live position.
766    RescanComplete {
767        /// The scanned range (post-clamp), echoing [`RescanAccepted`](Event::RescanAccepted).
768        from_height: u32,
769        /// The scanned range upper bound (post-clamp).
770        to_height: u32,
771        /// Number of match events emitted for this rescan.
772        matches: u64,
773    },
774    /// One connected block's silent-payment tweaks — the Tier 1 (client-side
775    /// scan, zero-custody) firehose payload. Arrives only for a subscription that
776    /// set the [`TWEAKS`](crate::Categories::TWEAKS) category bit. For each entry,
777    /// a client computes `T · b_scan` with its own scan secret and derives the
778    /// candidate output key(s), so the scan key never leaves the device. See
779    /// [`examples/sp_light_scan.rs`].
780    BlockTweaks {
781        /// The block these tweaks describe (internal/consensus byte order).
782        block_hash: Vec<u8>,
783        /// The block's height.
784        height: u32,
785        /// One entry per silent-payment-eligible transaction in the block.
786        entries: Vec<TweakEntry>,
787        /// `true` when a subscription filter dropped entries from this block —
788        /// the `tweak_dust_limit` floor, or `tweak_unspent_only` cut-through —
789        /// so an empty `entries` may mean "filtered out", not "none present".
790        /// A surviving entry always carries its full taproot output set; no
791        /// filter trims one.
792        filtered: bool,
793    },
794    /// One accepted-but-unconfirmed transaction's silent-payment tweak — the
795    /// Tier 1.5 (mempool-time, zero-custody) firehose payload. Arrives only for a
796    /// subscription that set the [`TWEAKS`](crate::Categories::TWEAKS) category
797    /// bit **and** [`SubscribeOptions::mempool_tweaks`](crate::SubscribeOptions::mempool_tweaks).
798    /// Scan it exactly like a [`BlockTweaks`](Event::BlockTweaks) entry — compute
799    /// `T · b_scan` and derive the candidate output key(s) — for mempool-latency
800    /// detection. Ephemeral and best-effort: not replayable, no retraction on
801    /// RBF/eviction; dedup by `entry.txid` (always present) against the confirmed
802    /// `BlockTweaks`. See [`examples/sp_light_scan.rs`].
803    MempoolTweak {
804        /// The admitted transaction's tweak. Always full (`txid` present) —
805        /// `tweaks_only` does not apply to mempool tweaks.
806        entry: TweakEntry,
807    },
808    /// A BIP 352 silent payment paid a registered scan key — the Tier 2
809    /// (scan-key watch, convenience) match, delivered on the `Watch` stream to a
810    /// connection that registered the target via
811    /// [`WatchHandle::add_silent_payments`](crate::WatchHandle::add_silent_payments).
812    /// The node ran the ECDH; `tweak` + `k` let the wallet re-derive the output
813    /// key — and, with its `b_spend`, the spending key — **offline**. See
814    /// [`examples/sp_wallet.rs`].
815    SilentPaymentMatched {
816        /// The registered target's identity `b_scan·G` (33 bytes) this output
817        /// paid — echoes which of your scan keys matched, never the secret.
818        scan_pubkey: Vec<u8>,
819        /// The paying transaction's txid (internal byte order — [`parse_txid`] /
820        /// [`display_hex`]).
821        txid: Vec<u8>,
822        /// The matched output's index.
823        vout: u32,
824        /// The matched taproot output key (32-byte x-only).
825        output_pubkey: Vec<u8>,
826        /// The output value in satoshis.
827        amount: u64,
828        /// The transaction's 33-byte public tweak `T`. With `k`, re-derive the
829        /// full output key offline: `P_k = B_spend + hash(b_scan·T || k)·G`.
830        tweak: Vec<u8>,
831        /// The BIP 352 output counter for this match — the `k` in the derivation.
832        k: u32,
833        /// The matched label integer, when the output paid a registered label
834        /// (e.g. change is commonly label `0`); `None` for an unlabeled match.
835        label: Option<u32>,
836        /// `true` once seen in a connected block; `false` while only in the
837        /// mempool (re-emitted `true` on confirmation).
838        confirmed: bool,
839        /// The confirming block height; `None` while unconfirmed.
840        height: Option<u32>,
841        /// The full serialized transaction, only when this connection opted in
842        /// via `SetWatchOptions.include_raw_tx`; `None` otherwise.
843        raw_tx: Option<Vec<u8>>,
844    },
845    /// An incremental watch add (`add_scripts`, `add_outpoints`, …) that the
846    /// server did **not** register: over the quota, over the per-add rate
847    /// limit, over a per-connection cap, without `stream:watch`, or malformed.
848    /// None of the items it names is watched. Items the add re-asserted
849    /// (already watched) are not named and stay watched. The server sends
850    /// nothing for an add that registered.
851    ///
852    /// [`ResilientWatch`](crate::ResilientWatch) re-sends a
853    /// [`RateLimited`](WatchAddRejectReason::RateLimited) add itself after the
854    /// server's retry hint, within its backoff budget, and hands the event on
855    /// only once that budget runs out. For any other reason, or then, it drops
856    /// the named items from its mirror before handing this on, so a reconnect
857    /// does not re-register them; re-add them yourself if you want another try.
858    WatchAddRejected(WatchAddRejected),
859    /// A body this client build does not recognize (a newer server arm), or an
860    /// event with no body set. Ignored by well-behaved consumers.
861    Unknown,
862}
863
864/// The payload of [`Event::WatchAddRejected`]: which add was refused, why, and
865/// the items it named. Only the item field for [`kind`](Self::kind) is set.
866#[derive(Debug, Clone, PartialEq, Eq)]
867#[non_exhaustive]
868pub struct WatchAddRejected {
869    /// Which kind of add was refused.
870    pub kind: WatchAddKind,
871    /// Why it was refused.
872    pub reason: WatchAddRejectReason,
873    /// [`QuotaExceeded`](WatchAddRejectReason::QuotaExceeded): the units the
874    /// refused items cost. [`CapExceeded`](WatchAddRejectReason::CapExceeded):
875    /// the count the add would have reached. 0 otherwise.
876    pub required: u64,
877    /// `QuotaExceeded`: the units the token already holds. 0 otherwise.
878    pub held: u64,
879    /// `QuotaExceeded`: the token's unit quota. `CapExceeded`: the cap. 0
880    /// otherwise.
881    pub quota: u64,
882    /// [`RateLimited`](WatchAddRejectReason::RateLimited): seconds until the
883    /// rate limit admits another add. 0 otherwise.
884    pub retry_after_secs: u32,
885    /// Refused scripthashes (32 bytes each).
886    pub scripthashes: Vec<Vec<u8>>,
887    /// Refused outpoints, as `(txid, vout)` (txid in internal byte order).
888    pub outpoints: Vec<(Vec<u8>, u32)>,
889    /// Refused lifecycle watches, by txid.
890    pub txids: Vec<Vec<u8>>,
891    /// Refused depth alarms, as `(txid, depth)`.
892    pub depth_alarms: Vec<(Vec<u8>, u32)>,
893    /// The refused descriptor and the window it asked for.
894    pub descriptor: Option<RejectedDescriptor>,
895    /// Refused script prefixes, as `(prefix, bits)` with the prefix masked to
896    /// `bits`.
897    pub prefixes: Vec<(Vec<u8>, u32)>,
898    /// Refused silent-payment targets, by scan pubkey `b_scan·G` (33 bytes).
899    pub scan_pubkeys: Vec<Vec<u8>>,
900}
901
902/// A descriptor named by a [`WatchAddRejected`].
903#[derive(Debug, Clone, PartialEq, Eq)]
904#[non_exhaustive]
905pub struct RejectedDescriptor {
906    /// The descriptor string the add carried.
907    pub descriptor: String,
908    /// The window size the add asked for.
909    pub gap_limit: u32,
910    /// The window start the add asked for.
911    pub start: u32,
912    /// `true` when an earlier window of this descriptor stays watched (a refused
913    /// slide); `false` when the descriptor is not watched at all.
914    pub kept: bool,
915}
916
917/// Which kind of add a [`WatchAddRejected`] refers to.
918#[derive(Debug, Clone, Copy, PartialEq, Eq)]
919#[non_exhaustive]
920pub enum WatchAddKind {
921    /// `add_scripts`.
922    Scripts,
923    /// `add_outpoints`.
924    Outpoints,
925    /// `add_tx_lifecycle`.
926    Transactions,
927    /// `add_depth_alarms`.
928    DepthAlarms,
929    /// `add_descriptor`.
930    Descriptor,
931    /// `add_script_prefixes`.
932    ScriptPrefixes,
933    /// `add_silent_payments`.
934    SilentPayments,
935    /// A kind code this client build does not recognize (a newer server).
936    Unknown,
937}
938
939/// Why the server refused an incremental add (see [`Event::WatchAddRejected`]).
940#[derive(Debug, Clone, Copy, PartialEq, Eq)]
941#[non_exhaustive]
942pub enum WatchAddRejectReason {
943    /// The refused items' unit cost does not fit the token's watch quota. Remove
944    /// watches, or ask the operator for a larger quota.
945    QuotaExceeded,
946    /// The token's per-add rate limit is spent. The same add can succeed after
947    /// `retry_after_secs`.
948    RateLimited,
949    /// A per-connection cap: 16 silent-payment targets, 256 descriptors, or the
950    /// WebSocket entry cap. Remove watches on this connection first.
951    CapExceeded,
952    /// The token lacks the `stream:watch` capability.
953    PermissionDenied,
954    /// The add could not be applied as a whole (a `min_values` list not parallel
955    /// to its scripthashes, an invalid descriptor, too many txid × depth
956    /// pairs). A client bug: the same add will fail again.
957    Malformed,
958    /// A reason code this client build does not recognize (a newer server).
959    Unknown,
960}
961
962impl WatchAddRejected {
963    pub(crate) fn from_proto(r: pb::WatchAddRejected) -> Self {
964        use pb::watch_add_rejected::{Kind, Reason};
965        let kind = match Kind::try_from(r.kind) {
966            Ok(Kind::Scripts) => WatchAddKind::Scripts,
967            Ok(Kind::Outpoints) => WatchAddKind::Outpoints,
968            Ok(Kind::Transactions) => WatchAddKind::Transactions,
969            Ok(Kind::DepthAlarms) => WatchAddKind::DepthAlarms,
970            Ok(Kind::Descriptor) => WatchAddKind::Descriptor,
971            Ok(Kind::ScriptPrefixes) => WatchAddKind::ScriptPrefixes,
972            Ok(Kind::SilentPayments) => WatchAddKind::SilentPayments,
973            Ok(Kind::Unspecified) | Err(_) => WatchAddKind::Unknown,
974        };
975        let reason = match Reason::try_from(r.reason) {
976            Ok(Reason::QuotaExceeded) => WatchAddRejectReason::QuotaExceeded,
977            Ok(Reason::RateLimited) => WatchAddRejectReason::RateLimited,
978            Ok(Reason::CapExceeded) => WatchAddRejectReason::CapExceeded,
979            Ok(Reason::PermissionDenied) => WatchAddRejectReason::PermissionDenied,
980            Ok(Reason::Malformed) => WatchAddRejectReason::Malformed,
981            Ok(Reason::Unspecified) | Err(_) => WatchAddRejectReason::Unknown,
982        };
983        let descriptor = (kind == WatchAddKind::Descriptor).then_some(RejectedDescriptor {
984            descriptor: r.descriptor,
985            gap_limit: r.gap_limit,
986            start: r.start,
987            kept: r.descriptor_kept,
988        });
989        WatchAddRejected {
990            kind,
991            reason,
992            required: r.required,
993            held: r.held,
994            quota: r.quota,
995            retry_after_secs: r.retry_after_secs,
996            scripthashes: r.scripthashes,
997            outpoints: r.outpoints.into_iter().map(|o| (o.txid, o.vout)).collect(),
998            txids: r.txids,
999            depth_alarms: r.depth_alarms.into_iter().map(|d| (d.txid, d.depth)).collect(),
1000            descriptor,
1001            prefixes: r.prefixes.into_iter().map(|p| (p.prefix, p.bits)).collect(),
1002            scan_pubkeys: r.scan_pubkeys,
1003        }
1004    }
1005}
1006
1007/// Why a mid-stream re-anchor was declined by the server (see
1008/// [`Event::CursorRejected`]).
1009#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1010#[non_exhaustive]
1011pub enum CursorRejectReason {
1012    /// Per-principal re-anchor rate limit exceeded — retry after a backoff.
1013    RateLimited,
1014    /// Another re-anchor is already draining (only one runs at a time) — retry
1015    /// once it completes.
1016    ConcurrentReanchor,
1017    /// The request carried no cursor (client bug).
1018    EmptyCursor,
1019    /// The server has no block source to replay from.
1020    NoSource,
1021    /// A reason code this client build does not recognize (a newer server).
1022    Unknown,
1023}
1024
1025/// Why an atomic watch-set replace was declined by the server (see
1026/// [`Event::WatchSetRejected`]).
1027#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1028#[non_exhaustive]
1029pub enum WatchSetRejectReason {
1030    /// The target set's total unit cost exceeds the principal's quota (`required`
1031    /// units vs the `quota` ceiling) — shed items and retry. Transient: a smaller
1032    /// set fits.
1033    QuotaExceeded,
1034    /// The target's watch-set **entry** count (`required`) exceeds the
1035    /// per-connection cap (`quota`, `streamwsmaxsubscriptions`). Distinct from
1036    /// `QuotaExceeded`: this bound applies even to a no-auth connection with no
1037    /// quota, and counts entries (a prefix is one) not units — shed entries and
1038    /// retry.
1039    CapExceeded,
1040    /// The server could not parse or expand an element of the snapshot (a bad
1041    /// scripthash, outpoint, txid, descriptor, or prefix). A full replace is
1042    /// all-or-nothing, so the whole snapshot was refused. This is a client bug —
1043    /// retrying the same set will not help.
1044    Malformed,
1045    /// A reason code this client build does not recognize (a newer server).
1046    Unknown,
1047}
1048
1049/// Map the proto `WatchSetRejected.Reason` enum (an `i32` on the wire) to the
1050/// client enum, treating an unrecognized code as [`WatchSetRejectReason::Unknown`].
1051fn watch_set_reject_reason(reason: i32) -> WatchSetRejectReason {
1052    match pb::watch_set_rejected::Reason::try_from(reason) {
1053        Ok(pb::watch_set_rejected::Reason::QuotaExceeded) => WatchSetRejectReason::QuotaExceeded,
1054        Ok(pb::watch_set_rejected::Reason::CapExceeded) => WatchSetRejectReason::CapExceeded,
1055        Ok(pb::watch_set_rejected::Reason::Malformed) => WatchSetRejectReason::Malformed,
1056        Ok(pb::watch_set_rejected::Reason::Unspecified) | Err(_) => WatchSetRejectReason::Unknown,
1057    }
1058}
1059
1060/// Why a bounded historical rescan was declined by the server (see
1061/// [`Event::RescanRejected`]).
1062#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1063#[non_exhaustive]
1064pub enum RescanRejectReason {
1065    /// Per-principal rescan rate limit exceeded — retry after a backoff.
1066    RateLimited,
1067    /// Another rescan is already draining on this connection (only one runs at a
1068    /// time) — retry once it completes.
1069    ConcurrentRescan,
1070    /// `to_height < from_height`, or the range lies entirely above the tip.
1071    InvalidRange,
1072    /// The (clamped) span exceeds the server cap — page the range into smaller
1073    /// rescans.
1074    RangeTooLarge,
1075    /// The server has no block-scan source (no local block bodies/undo).
1076    NoSource,
1077    /// The connection watches nothing — a rescan could match nothing. Register a
1078    /// watch-set first.
1079    EmptyWatchSet,
1080    /// A reason code this client build does not recognize (a newer server).
1081    Unknown,
1082}
1083
1084/// Map the proto `RescanRejected.Reason` enum (an `i32` on the wire) to the
1085/// client enum, treating an unrecognized code as [`RescanRejectReason::Unknown`].
1086fn rescan_reject_reason(reason: i32) -> RescanRejectReason {
1087    match pb::rescan_rejected::Reason::try_from(reason) {
1088        Ok(pb::rescan_rejected::Reason::RateLimited) => RescanRejectReason::RateLimited,
1089        Ok(pb::rescan_rejected::Reason::ConcurrentRescan) => RescanRejectReason::ConcurrentRescan,
1090        Ok(pb::rescan_rejected::Reason::InvalidRange) => RescanRejectReason::InvalidRange,
1091        Ok(pb::rescan_rejected::Reason::RangeTooLarge) => RescanRejectReason::RangeTooLarge,
1092        Ok(pb::rescan_rejected::Reason::NoSource) => RescanRejectReason::NoSource,
1093        Ok(pb::rescan_rejected::Reason::EmptyWatchSet) => RescanRejectReason::EmptyWatchSet,
1094        Ok(pb::rescan_rejected::Reason::Unspecified) | Err(_) => RescanRejectReason::Unknown,
1095    }
1096}
1097
1098impl From<pb::NodeEvent> for Event {
1099    fn from(ev: pb::NodeEvent) -> Self {
1100        use pb::node_event::Body;
1101        let Some(body) = ev.body else { return Event::Unknown };
1102        match body {
1103            Body::Mempool(m) => mempool_event(m),
1104            Body::Chain(c) => chain_event(c),
1105            Body::Heartbeat(h) => Event::Heartbeat { uptime_ns: h.uptime_ns },
1106            Body::Status(s) => Event::Status {
1107                kind: StatusKind::from_proto(s.kind),
1108                state: StatusState::from_proto(s.state),
1109                severity: StatusSeverity::from_proto(s.severity),
1110                message: s.message,
1111                details: s.details.into_iter().collect(),
1112            },
1113            Body::OutpointSpent(o) => Event::OutpointSpent {
1114                outpoint: Outpoint { txid: o.outpoint_txid, vout: o.outpoint_vout },
1115                spending_txid: o.spending_txid,
1116                spending_vin: o.spending_vin,
1117                confirmed: o.confirmed,
1118            },
1119            Body::ScriptMatched(s) => Event::ScriptMatched {
1120                scripthash: s.scripthash,
1121                txid: s.txid,
1122                is_output: s.is_output,
1123                index: s.index,
1124                confirmed: s.confirmed,
1125                amount: s.has_amount.then_some(s.amount),
1126                raw_tx: (!s.raw_tx.is_empty()).then_some(s.raw_tx),
1127                descriptors: s
1128                    .descriptor_matches
1129                    .into_iter()
1130                    .map(|d| DescriptorMatch {
1131                        descriptor: d.descriptor,
1132                        branch: d.branch,
1133                        derivation_index: d.derivation_index,
1134                    })
1135                    .collect(),
1136            },
1137            Body::TxidMatched(t) => Event::TxidMatched {
1138                txid: t.txid,
1139                confirmed: t.confirmed,
1140                height: t.height,
1141            },
1142            Body::TxidReplaced(t) => Event::TxidReplaced {
1143                txid: t.txid,
1144                replacing_txid: t.replacing_txid,
1145            },
1146            Body::TxidEvicted(t) => Event::TxidEvicted { txid: t.txid, reason: t.reason },
1147            Body::TxidUnconfirmed(t) => Event::TxidUnconfirmed {
1148                txid: t.txid,
1149                prev_height: t.prev_height,
1150            },
1151            Body::TxidDepthReached(t) => Event::TxidDepthReached {
1152                txid: t.txid,
1153                depth: t.depth,
1154                height: t.height,
1155            },
1156            Body::TxidFinalized(t) => Event::TxidFinalized {
1157                txid: t.txid,
1158                depth: t.depth,
1159                height: t.height,
1160            },
1161            // A PrefixMatched without its bucket is a degenerate message the
1162            // local re-filter cannot use (bits:0 matches nothing meaningfully);
1163            // surface it as Unknown rather than a structurally-valid-looking 0.
1164            Body::PrefixMatched(p) => match p.prefix {
1165                Some(sp) => Event::PrefixMatched(PrefixMatch {
1166                    prefix: ScriptPrefix { prefix: sp.prefix, bits: sp.bits },
1167                    raw_tx: p.raw_tx,
1168                    confirmed: p.confirmed,
1169                    height: p.height,
1170                    matched_prevouts: p.matched_prevouts.into_iter().map(Into::into).collect(),
1171                }),
1172                None => Event::Unknown,
1173            },
1174            Body::Lagged(l) => Event::Lagged {
1175                dropped_count: l.dropped_count,
1176                resume_cursor: l.resume_cursor,
1177            },
1178            Body::SetCursorResult(r) => match r.outcome {
1179                Some(pb::set_cursor_result::Outcome::Accepted(a)) => Event::CursorAccepted {
1180                    from: a.from,
1181                    clamped: a.clamped,
1182                    earliest_replayed: a.earliest_replayed,
1183                },
1184                Some(pb::set_cursor_result::Outcome::Rejected(rj)) => Event::CursorRejected {
1185                    reason: cursor_reject_reason(rj.reason),
1186                    current_head: rj.current_head,
1187                },
1188                // A result frame with no outcome set is a degenerate message.
1189                None => Event::Unknown,
1190            },
1191            Body::SetWatchSetResult(r) => match r.outcome {
1192                Some(pb::watch_set_result::Outcome::Accepted(a)) => Event::WatchSetReplaced {
1193                    added: a.added,
1194                    removed: a.removed,
1195                    unchanged: a.unchanged,
1196                },
1197                Some(pb::watch_set_result::Outcome::Rejected(rj)) => Event::WatchSetRejected {
1198                    reason: watch_set_reject_reason(rj.reason),
1199                    required: rj.required,
1200                    quota: rj.quota,
1201                },
1202                None => Event::Unknown,
1203            },
1204            Body::WatchAddRejected(r) => Event::WatchAddRejected(WatchAddRejected::from_proto(r)),
1205            Body::RescanResult(r) => match r.outcome {
1206                Some(pb::rescan_result::Outcome::Accepted(a)) => Event::RescanAccepted {
1207                    from_height: a.from_height,
1208                    to_height: a.to_height,
1209                    clamped: a.clamped,
1210                },
1211                Some(pb::rescan_result::Outcome::Rejected(rj)) => Event::RescanRejected {
1212                    reason: rescan_reject_reason(rj.reason),
1213                    tip_height: rj.tip_height,
1214                },
1215                None => Event::Unknown,
1216            },
1217            Body::RescanComplete(c) => Event::RescanComplete {
1218                from_height: c.from_height,
1219                to_height: c.to_height,
1220                matches: c.matches,
1221            },
1222            // BIP 352 silent payments (§7.7). Tier 1 firehose tweaks and the
1223            // Tier 2 scan-key match.
1224            Body::BlockTweaks(b) => Event::BlockTweaks {
1225                block_hash: b.block_hash,
1226                height: b.height,
1227                entries: b
1228                    .entries
1229                    .into_iter()
1230                    .map(|e| TweakEntry {
1231                        tweak: e.tweak,
1232                        txid: e.txid,
1233                        max_value: e.max_value,
1234                        taproot_outputs: e
1235                            .taproot_outputs
1236                            .into_iter()
1237                            .map(taproot_output_from_proto)
1238                            .collect(),
1239                    })
1240                    .collect(),
1241                filtered: b.filtered,
1242            },
1243            Body::MempoolTweak(m) => Event::MempoolTweak {
1244                entry: m
1245                    .entry
1246                    .map(|e| TweakEntry {
1247                        tweak: e.tweak,
1248                        txid: e.txid,
1249                        max_value: e.max_value,
1250                        taproot_outputs: e
1251                            .taproot_outputs
1252                            .into_iter()
1253                            .map(taproot_output_from_proto)
1254                            .collect(),
1255                    })
1256                    // A well-formed MempoolTweak always carries its entry; an
1257                    // absent one (malformed wire) degrades to an empty entry
1258                    // rather than dropping the event.
1259                    .unwrap_or(TweakEntry {
1260                        tweak: Vec::new(),
1261                        txid: Vec::new(),
1262                        max_value: 0,
1263                        taproot_outputs: Vec::new(),
1264                    }),
1265            },
1266            Body::SilentPaymentMatched(s) => Event::SilentPaymentMatched {
1267                scan_pubkey: s.scan_pubkey,
1268                txid: s.txid,
1269                vout: s.vout,
1270                output_pubkey: s.output_pubkey,
1271                amount: s.amount,
1272                tweak: s.tweak,
1273                k: s.k,
1274                label: s.has_label.then_some(s.label),
1275                confirmed: s.confirmed,
1276                // `height` is 0 on the wire while unconfirmed; surface `None`.
1277                height: s.confirmed.then_some(s.height),
1278                raw_tx: (!s.raw_tx.is_empty()).then_some(s.raw_tx),
1279            },
1280            // Forward-compatible catch-all: any body a still-newer proto adds maps
1281            // to `Unknown`. It is unreachable against the *current* workspace proto
1282            // (every body above is now typed), but retained so this crate keeps
1283            // compiling if the schema grows ahead of these arms — the same
1284            // resilience the SP bodies themselves relied on before they were typed.
1285            #[allow(unreachable_patterns)]
1286            _ => Event::Unknown,
1287        }
1288    }
1289}
1290
1291/// Map a proto [`pb::TaprootOutput`] to the client [`TaprootOutput`]. Bytes are
1292/// carried through unchanged (x-only key, internal byte order).
1293fn taproot_output_from_proto(o: pb::TaprootOutput) -> TaprootOutput {
1294    TaprootOutput {
1295        vout: o.vout,
1296        output_pubkey: o.output_pubkey,
1297        value: o.value,
1298    }
1299}
1300
1301/// Map the proto `CursorRejected.Reason` enum (an `i32` on the wire) to the
1302/// client enum, treating an unrecognized code as [`CursorRejectReason::Unknown`].
1303fn cursor_reject_reason(reason: i32) -> CursorRejectReason {
1304    match pb::cursor_rejected::Reason::try_from(reason) {
1305        Ok(pb::cursor_rejected::Reason::RateLimited) => CursorRejectReason::RateLimited,
1306        Ok(pb::cursor_rejected::Reason::ConcurrentReanchor) => {
1307            CursorRejectReason::ConcurrentReanchor
1308        }
1309        Ok(pb::cursor_rejected::Reason::EmptyCursor) => CursorRejectReason::EmptyCursor,
1310        Ok(pb::cursor_rejected::Reason::NoSource) => CursorRejectReason::NoSource,
1311        Ok(pb::cursor_rejected::Reason::Unspecified) | Err(_) => CursorRejectReason::Unknown,
1312    }
1313}
1314
1315fn mempool_event(m: pb::MempoolEvent) -> Event {
1316    use pb::mempool_event::Body;
1317    match m.body {
1318        Some(Body::Enter(e)) => Event::MempoolEnter {
1319            txid: e.txid,
1320            fee: e.fee,
1321            vsize: e.vsize,
1322            fee_rate_sat_per_kvb: e.fee_rate_sat_per_kvb,
1323            time: e.time,
1324        },
1325        Some(Body::LeaveConfirmed(e)) => Event::MempoolLeaveConfirmed {
1326            txid: e.txid,
1327            block_hash: e.block_hash,
1328            height: e.height,
1329        },
1330        Some(Body::LeaveEvicted(e)) => {
1331            // NOT `e.reason()` — that accessor collapses an unrecognized value
1332            // into the zero variant. See `EvictReason::from_proto`.
1333            let reason = EvictReason::from_proto(e.reason);
1334            Event::MempoolLeaveEvicted { txid: e.txid, reason }
1335        }
1336        Some(Body::LeaveReplaced(e)) => Event::MempoolLeaveReplaced {
1337            txid: e.txid,
1338            replacing_txid: e.replacing_txid,
1339        },
1340        None => Event::Unknown,
1341    }
1342}
1343
1344fn chain_event(c: pb::ChainEvent) -> Event {
1345    use pb::chain_event::Body;
1346    match c.body {
1347        Some(Body::BlockConnected(b)) => Event::BlockConnected { hash: b.hash, height: b.height },
1348        Some(Body::BlockDisconnected(b)) => {
1349            Event::BlockDisconnected { hash: b.hash, height: b.height }
1350        }
1351        Some(Body::Reorg(r)) => Event::Reorg {
1352            from_height: r.from_height,
1353            old_tip: r.old_tip,
1354            to_height: r.to_height,
1355            new_tip: r.new_tip,
1356        },
1357        None => Event::Unknown,
1358    }
1359}
1360
1361#[cfg(test)]
1362mod tests {
1363    use super::*;
1364
1365    #[test]
1366    fn an_unset_severity_decodes_to_unspecified_not_unknown() {
1367        // proto3's zero value. Before this was its own variant it decoded to
1368        // `Unknown(0)`, which ranked above `Critical`.
1369        assert_eq!(StatusSeverity::from_proto(0), StatusSeverity::Unspecified);
1370    }
1371
1372    #[test]
1373    fn an_unset_severity_ranks_below_info() {
1374        assert!(StatusSeverity::Unspecified < StatusSeverity::Info);
1375        assert!(StatusSeverity::Unspecified < StatusSeverity::Critical);
1376        // The regression this exists for: the documented filter must not page
1377        // on a severity the producer never set.
1378        assert!(StatusSeverity::from_proto(0) < StatusSeverity::Warning);
1379    }
1380
1381    #[test]
1382    fn known_severities_order_by_loudness() {
1383        assert!(StatusSeverity::Info < StatusSeverity::Warning);
1384        assert!(StatusSeverity::Warning < StatusSeverity::Critical);
1385        for (v, want) in [
1386            (1, StatusSeverity::Info),
1387            (2, StatusSeverity::Warning),
1388            (3, StatusSeverity::Critical),
1389        ] {
1390            assert_eq!(StatusSeverity::from_proto(v), want);
1391        }
1392    }
1393
1394    #[test]
1395    fn an_unrecognized_severity_still_outranks_critical() {
1396        // Deliberate and unchanged: a level this build cannot name is not one
1397        // to quietly filter out. A future `notice` at proto 4 would land here
1398        // and page — the accepted cost of failing loud on an open taxonomy.
1399        let future = StatusSeverity::from_proto(4);
1400        assert_eq!(future, StatusSeverity::Unknown(4));
1401        assert!(future > StatusSeverity::Critical);
1402        assert!(future >= StatusSeverity::Warning);
1403    }
1404
1405    #[test]
1406    fn ord_agrees_with_eq_for_distinct_unknowns() {
1407        // `Ord` must report `Equal` exactly when `Eq` does. Ranking every
1408        // `Unknown` as simply "above Critical" would call these equal while
1409        // `Eq` calls them different, breaking the total order.
1410        let a = StatusSeverity::Unknown(5);
1411        let b = StatusSeverity::Unknown(9);
1412        assert_ne!(a, b);
1413        assert_ne!(a.cmp(&b), std::cmp::Ordering::Equal);
1414        assert!(a < b);
1415        assert_eq!(a.cmp(&a), std::cmp::Ordering::Equal);
1416    }
1417
1418    #[test]
1419    fn sorting_puts_the_loudest_last_and_the_unset_first() {
1420        let mut all = vec![
1421            StatusSeverity::Critical,
1422            StatusSeverity::Unknown(7),
1423            StatusSeverity::Unspecified,
1424            StatusSeverity::Warning,
1425            StatusSeverity::Info,
1426        ];
1427        all.sort();
1428        assert_eq!(all, vec![
1429            StatusSeverity::Unspecified,
1430            StatusSeverity::Info,
1431            StatusSeverity::Warning,
1432            StatusSeverity::Critical,
1433            StatusSeverity::Unknown(7),
1434        ]);
1435    }
1436
1437    #[test]
1438    fn the_documented_filter_idiom_selects_what_it_should() {
1439        let page = |s: StatusSeverity| s >= StatusSeverity::Warning;
1440        assert!(!page(StatusSeverity::Unspecified));
1441        assert!(!page(StatusSeverity::Info));
1442        assert!(page(StatusSeverity::Warning));
1443        assert!(page(StatusSeverity::Critical));
1444        assert!(page(StatusSeverity::Unknown(4)));
1445    }
1446
1447    #[test]
1448    fn unspecified_and_unknown_zero_are_different_values() {
1449        // Both mean "proto 0", but only `from_proto` produces `Unspecified`.
1450        // `Unknown(0)` is still constructible by hand (`#[non_exhaustive]`
1451        // blocks exhaustive matching, not construction) and deliberately keeps
1452        // the loud rank — the asymmetry is documented rather than papered over,
1453        // because nothing in the decode path can produce it.
1454        assert_ne!(StatusSeverity::Unspecified, StatusSeverity::Unknown(0));
1455        assert!(StatusSeverity::Unknown(0) > StatusSeverity::Critical);
1456        assert!(StatusSeverity::Unspecified < StatusSeverity::Info);
1457    }
1458
1459    #[test]
1460    fn the_order_is_total_across_mixed_sign_unknowns() {
1461        // `rank()`'s second element exists to keep this a total order; a
1462        // rank-only key would report every `Unknown` equal and break sorting.
1463        let mut all = vec![
1464            StatusSeverity::Unknown(i32::MAX),
1465            StatusSeverity::Critical,
1466            StatusSeverity::Unknown(i32::MIN),
1467            StatusSeverity::Unspecified,
1468            StatusSeverity::Unknown(0),
1469            StatusSeverity::Info,
1470        ];
1471        all.sort();
1472        assert_eq!(all, vec![
1473            StatusSeverity::Unspecified,
1474            StatusSeverity::Info,
1475            StatusSeverity::Critical,
1476            StatusSeverity::Unknown(i32::MIN),
1477            StatusSeverity::Unknown(0),
1478            StatusSeverity::Unknown(i32::MAX),
1479        ]);
1480        // Every unknown, including the extremes, still outranks Critical.
1481        for v in [i32::MIN, -1, 0, 1, i32::MAX] {
1482            assert!(StatusSeverity::Unknown(v) > StatusSeverity::Critical, "Unknown({v})");
1483        }
1484        // Transitivity spot-check across the variant boundary.
1485        assert!(StatusSeverity::Unspecified < StatusSeverity::Warning);
1486        assert!(StatusSeverity::Warning < StatusSeverity::Unknown(i32::MIN));
1487        assert!(StatusSeverity::Unspecified < StatusSeverity::Unknown(i32::MIN));
1488    }
1489
1490    #[test]
1491    fn a_negative_severity_is_unrecognized_not_unset() {
1492        assert_eq!(StatusSeverity::from_proto(-1), StatusSeverity::Unknown(-1));
1493        assert!(StatusSeverity::from_proto(-1) > StatusSeverity::Critical);
1494    }
1495
1496    #[test]
1497    fn the_sibling_enums_split_unset_from_unrecognized_too() {
1498        assert_eq!(StatusKind::from_proto(0), StatusKind::Unspecified);
1499        assert_eq!(StatusKind::from_proto(99), StatusKind::Unknown(99));
1500        assert_eq!(StatusState::from_proto(0), StatusState::Unspecified);
1501        assert_eq!(StatusState::from_proto(99), StatusState::Unknown(99));
1502        assert_eq!(EvictReason::from_proto(0), EvictReason::Unspecified);
1503        assert_eq!(EvictReason::from_proto(99), EvictReason::Unknown(99));
1504        assert_eq!(EvictReason::from_proto(1), EvictReason::FullPool);
1505    }
1506}