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