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}