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