use satd_events_proto::v1 as pb;
pub fn display_hex(internal_hash: &[u8]) -> String {
internal_hash
.iter()
.rev()
.map(|b| format!("{b:02x}"))
.collect()
}
#[cfg(feature = "bitcoin")]
pub fn parse_txid(raw: &[u8]) -> Result<bitcoin::Txid, bitcoin::hashes::FromSliceError> {
use bitcoin::hashes::Hash as _;
bitcoin::Txid::from_slice(raw)
}
pub use satd_events_proto::v1::Cursor;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Outpoint {
pub txid: Vec<u8>,
pub vout: u32,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct DescriptorMatch {
pub descriptor: String,
pub branch: u32,
pub derivation_index: u32,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[non_exhaustive]
pub enum EvictReason {
Unspecified,
FullPool,
Expiry,
BlockConflict,
Policy,
Reorg,
Unknown(i32),
}
#[non_exhaustive]
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum StatusKind {
Unspecified,
IbdComplete,
TipStall,
DiskLow,
MempoolCongested,
PeerFloor,
DeepReorg,
Unknown(i32),
}
impl StatusKind {
fn from_proto(v: i32) -> Self {
match pb::StatusKind::try_from(v) {
Ok(pb::StatusKind::Unspecified) => StatusKind::Unspecified,
Ok(pb::StatusKind::IbdComplete) => StatusKind::IbdComplete,
Ok(pb::StatusKind::TipStall) => StatusKind::TipStall,
Ok(pb::StatusKind::DiskLow) => StatusKind::DiskLow,
Ok(pb::StatusKind::MempoolCongested) => StatusKind::MempoolCongested,
Ok(pb::StatusKind::PeerFloor) => StatusKind::PeerFloor,
Ok(pb::StatusKind::DeepReorg) => StatusKind::DeepReorg,
_ => StatusKind::Unknown(v),
}
}
}
#[non_exhaustive]
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum StatusState {
Unspecified,
Raised,
Cleared,
Edge,
Unknown(i32),
}
impl StatusState {
fn from_proto(v: i32) -> Self {
match pb::StatusState::try_from(v) {
Ok(pb::StatusState::Unspecified) => StatusState::Unspecified,
Ok(pb::StatusState::Raised) => StatusState::Raised,
Ok(pb::StatusState::Cleared) => StatusState::Cleared,
Ok(pb::StatusState::Edge) => StatusState::Edge,
_ => StatusState::Unknown(v),
}
}
}
#[non_exhaustive]
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum StatusSeverity {
Unspecified,
Info,
Warning,
Critical,
Unknown(i32),
}
impl StatusSeverity {
fn from_proto(v: i32) -> Self {
match pb::StatusSeverity::try_from(v) {
Ok(pb::StatusSeverity::Unspecified) => StatusSeverity::Unspecified,
Ok(pb::StatusSeverity::Info) => StatusSeverity::Info,
Ok(pb::StatusSeverity::Warning) => StatusSeverity::Warning,
Ok(pb::StatusSeverity::Critical) => StatusSeverity::Critical,
_ => StatusSeverity::Unknown(v),
}
}
fn rank(self) -> (u8, i32) {
match self {
StatusSeverity::Unspecified => (0, 0),
StatusSeverity::Info => (1, 0),
StatusSeverity::Warning => (2, 0),
StatusSeverity::Critical => (3, 0),
StatusSeverity::Unknown(v) => (4, v),
}
}
}
impl Ord for StatusSeverity {
fn cmp(&self, other: &Self) -> std::cmp::Ordering {
self.rank().cmp(&other.rank())
}
}
impl PartialOrd for StatusSeverity {
fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
Some(self.cmp(other))
}
}
impl From<pb::EvictReason> for EvictReason {
fn from(r: pb::EvictReason) -> Self {
match r {
pb::EvictReason::Unspecified => EvictReason::Unspecified,
pb::EvictReason::FullPool => EvictReason::FullPool,
pb::EvictReason::Expiry => EvictReason::Expiry,
pb::EvictReason::BlockConflict => EvictReason::BlockConflict,
pb::EvictReason::Policy => EvictReason::Policy,
pb::EvictReason::Reorg => EvictReason::Reorg,
}
}
}
impl EvictReason {
fn from_proto(v: i32) -> Self {
match pb::EvictReason::try_from(v) {
Ok(r) => r.into(),
Err(_) => EvictReason::Unknown(v),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ScriptPrefix {
pub prefix: Vec<u8>,
pub bits: u32,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct TaprootOutput {
pub vout: u32,
pub output_pubkey: Vec<u8>,
pub value: u64,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct TweakEntry {
pub tweak: Vec<u8>,
pub txid: Vec<u8>,
pub max_value: u64,
pub taproot_outputs: Vec<TaprootOutput>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SpentPrevout {
pub outpoint: Outpoint,
pub script_pubkey: Vec<u8>,
pub amount: Option<u64>,
}
impl From<pb::SpentPrevout> for SpentPrevout {
fn from(s: pb::SpentPrevout) -> Self {
SpentPrevout {
outpoint: Outpoint { txid: s.outpoint_txid, vout: s.outpoint_vout },
script_pubkey: s.script_pubkey,
amount: s.has_amount.then_some(s.amount),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PrefixMatch {
pub prefix: ScriptPrefix,
pub raw_tx: Vec<u8>,
pub confirmed: bool,
pub height: u32,
pub matched_prevouts: Vec<SpentPrevout>,
}
#[derive(Debug, Clone, PartialEq)]
#[non_exhaustive]
pub enum Event {
MempoolEnter {
txid: Vec<u8>,
fee: u64,
vsize: u64,
fee_rate_sat_per_kvb: u64,
time: u64,
},
MempoolLeaveConfirmed {
txid: Vec<u8>,
block_hash: Vec<u8>,
height: u32,
},
MempoolLeaveEvicted {
txid: Vec<u8>,
reason: EvictReason,
},
MempoolLeaveReplaced {
txid: Vec<u8>,
replacing_txid: Vec<u8>,
},
BlockConnected {
hash: Vec<u8>,
height: u32,
},
BlockDisconnected {
hash: Vec<u8>,
height: u32,
},
Reorg {
from_height: u32,
old_tip: Vec<u8>,
to_height: u32,
new_tip: Vec<u8>,
},
Heartbeat {
uptime_ns: u64,
},
Status {
kind: StatusKind,
state: StatusState,
severity: StatusSeverity,
message: String,
details: std::collections::BTreeMap<String, String>,
},
OutpointSpent {
outpoint: Outpoint,
spending_txid: Vec<u8>,
spending_vin: u32,
confirmed: bool,
},
ScriptMatched {
scripthash: Vec<u8>,
txid: Vec<u8>,
is_output: bool,
index: u32,
confirmed: bool,
amount: Option<u64>,
raw_tx: Option<Vec<u8>>,
descriptors: Vec<DescriptorMatch>,
},
TxidMatched {
txid: Vec<u8>,
confirmed: bool,
height: u32,
},
TxidReplaced {
txid: Vec<u8>,
replacing_txid: Vec<u8>,
},
TxidEvicted {
txid: Vec<u8>,
reason: String,
},
TxidUnconfirmed {
txid: Vec<u8>,
prev_height: u32,
},
TxidDepthReached {
txid: Vec<u8>,
depth: u32,
height: u32,
},
TxidFinalized {
txid: Vec<u8>,
depth: u32,
height: u32,
},
PrefixMatched(PrefixMatch),
Lagged {
dropped_count: u64,
resume_cursor: Option<Cursor>,
},
ReplayGap {
resume_height: u32,
first_height: u32,
},
CursorAccepted {
from: Option<Cursor>,
clamped: bool,
earliest_replayed: u32,
},
CursorRejected {
reason: CursorRejectReason,
current_head: Option<Cursor>,
},
WatchSetReplaced {
added: u32,
removed: u32,
unchanged: u32,
},
WatchSetRejected {
reason: WatchSetRejectReason,
required: u64,
quota: u64,
},
RescanAccepted {
from_height: u32,
to_height: u32,
clamped: bool,
},
RescanRejected {
reason: RescanRejectReason,
tip_height: u32,
},
RescanComplete {
from_height: u32,
to_height: u32,
matches: u64,
},
BlockTweaks {
block_hash: Vec<u8>,
height: u32,
entries: Vec<TweakEntry>,
filtered: bool,
},
MempoolTweak {
entry: TweakEntry,
},
SilentPaymentMatched {
scan_pubkey: Vec<u8>,
txid: Vec<u8>,
vout: u32,
output_pubkey: Vec<u8>,
amount: u64,
tweak: Vec<u8>,
k: u32,
label: Option<u32>,
confirmed: bool,
height: Option<u32>,
raw_tx: Option<Vec<u8>>,
},
Unknown,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[non_exhaustive]
pub enum CursorRejectReason {
RateLimited,
ConcurrentReanchor,
EmptyCursor,
NoSource,
Unknown,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[non_exhaustive]
pub enum WatchSetRejectReason {
QuotaExceeded,
CapExceeded,
Malformed,
Unknown,
}
fn watch_set_reject_reason(reason: i32) -> WatchSetRejectReason {
match pb::watch_set_rejected::Reason::try_from(reason) {
Ok(pb::watch_set_rejected::Reason::QuotaExceeded) => WatchSetRejectReason::QuotaExceeded,
Ok(pb::watch_set_rejected::Reason::CapExceeded) => WatchSetRejectReason::CapExceeded,
Ok(pb::watch_set_rejected::Reason::Malformed) => WatchSetRejectReason::Malformed,
Ok(pb::watch_set_rejected::Reason::Unspecified) | Err(_) => WatchSetRejectReason::Unknown,
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[non_exhaustive]
pub enum RescanRejectReason {
RateLimited,
ConcurrentRescan,
InvalidRange,
RangeTooLarge,
NoSource,
EmptyWatchSet,
Unknown,
}
fn rescan_reject_reason(reason: i32) -> RescanRejectReason {
match pb::rescan_rejected::Reason::try_from(reason) {
Ok(pb::rescan_rejected::Reason::RateLimited) => RescanRejectReason::RateLimited,
Ok(pb::rescan_rejected::Reason::ConcurrentRescan) => RescanRejectReason::ConcurrentRescan,
Ok(pb::rescan_rejected::Reason::InvalidRange) => RescanRejectReason::InvalidRange,
Ok(pb::rescan_rejected::Reason::RangeTooLarge) => RescanRejectReason::RangeTooLarge,
Ok(pb::rescan_rejected::Reason::NoSource) => RescanRejectReason::NoSource,
Ok(pb::rescan_rejected::Reason::EmptyWatchSet) => RescanRejectReason::EmptyWatchSet,
Ok(pb::rescan_rejected::Reason::Unspecified) | Err(_) => RescanRejectReason::Unknown,
}
}
impl From<pb::NodeEvent> for Event {
fn from(ev: pb::NodeEvent) -> Self {
use pb::node_event::Body;
let Some(body) = ev.body else { return Event::Unknown };
match body {
Body::Mempool(m) => mempool_event(m),
Body::Chain(c) => chain_event(c),
Body::Heartbeat(h) => Event::Heartbeat { uptime_ns: h.uptime_ns },
Body::Status(s) => Event::Status {
kind: StatusKind::from_proto(s.kind),
state: StatusState::from_proto(s.state),
severity: StatusSeverity::from_proto(s.severity),
message: s.message,
details: s.details.into_iter().collect(),
},
Body::OutpointSpent(o) => Event::OutpointSpent {
outpoint: Outpoint { txid: o.outpoint_txid, vout: o.outpoint_vout },
spending_txid: o.spending_txid,
spending_vin: o.spending_vin,
confirmed: o.confirmed,
},
Body::ScriptMatched(s) => Event::ScriptMatched {
scripthash: s.scripthash,
txid: s.txid,
is_output: s.is_output,
index: s.index,
confirmed: s.confirmed,
amount: s.has_amount.then_some(s.amount),
raw_tx: (!s.raw_tx.is_empty()).then_some(s.raw_tx),
descriptors: s
.descriptor_matches
.into_iter()
.map(|d| DescriptorMatch {
descriptor: d.descriptor,
branch: d.branch,
derivation_index: d.derivation_index,
})
.collect(),
},
Body::TxidMatched(t) => Event::TxidMatched {
txid: t.txid,
confirmed: t.confirmed,
height: t.height,
},
Body::TxidReplaced(t) => Event::TxidReplaced {
txid: t.txid,
replacing_txid: t.replacing_txid,
},
Body::TxidEvicted(t) => Event::TxidEvicted { txid: t.txid, reason: t.reason },
Body::TxidUnconfirmed(t) => Event::TxidUnconfirmed {
txid: t.txid,
prev_height: t.prev_height,
},
Body::TxidDepthReached(t) => Event::TxidDepthReached {
txid: t.txid,
depth: t.depth,
height: t.height,
},
Body::TxidFinalized(t) => Event::TxidFinalized {
txid: t.txid,
depth: t.depth,
height: t.height,
},
Body::PrefixMatched(p) => match p.prefix {
Some(sp) => Event::PrefixMatched(PrefixMatch {
prefix: ScriptPrefix { prefix: sp.prefix, bits: sp.bits },
raw_tx: p.raw_tx,
confirmed: p.confirmed,
height: p.height,
matched_prevouts: p.matched_prevouts.into_iter().map(Into::into).collect(),
}),
None => Event::Unknown,
},
Body::Lagged(l) => Event::Lagged {
dropped_count: l.dropped_count,
resume_cursor: l.resume_cursor,
},
Body::SetCursorResult(r) => match r.outcome {
Some(pb::set_cursor_result::Outcome::Accepted(a)) => Event::CursorAccepted {
from: a.from,
clamped: a.clamped,
earliest_replayed: a.earliest_replayed,
},
Some(pb::set_cursor_result::Outcome::Rejected(rj)) => Event::CursorRejected {
reason: cursor_reject_reason(rj.reason),
current_head: rj.current_head,
},
None => Event::Unknown,
},
Body::SetWatchSetResult(r) => match r.outcome {
Some(pb::watch_set_result::Outcome::Accepted(a)) => Event::WatchSetReplaced {
added: a.added,
removed: a.removed,
unchanged: a.unchanged,
},
Some(pb::watch_set_result::Outcome::Rejected(rj)) => Event::WatchSetRejected {
reason: watch_set_reject_reason(rj.reason),
required: rj.required,
quota: rj.quota,
},
None => Event::Unknown,
},
Body::RescanResult(r) => match r.outcome {
Some(pb::rescan_result::Outcome::Accepted(a)) => Event::RescanAccepted {
from_height: a.from_height,
to_height: a.to_height,
clamped: a.clamped,
},
Some(pb::rescan_result::Outcome::Rejected(rj)) => Event::RescanRejected {
reason: rescan_reject_reason(rj.reason),
tip_height: rj.tip_height,
},
None => Event::Unknown,
},
Body::RescanComplete(c) => Event::RescanComplete {
from_height: c.from_height,
to_height: c.to_height,
matches: c.matches,
},
Body::BlockTweaks(b) => Event::BlockTweaks {
block_hash: b.block_hash,
height: b.height,
entries: b
.entries
.into_iter()
.map(|e| TweakEntry {
tweak: e.tweak,
txid: e.txid,
max_value: e.max_value,
taproot_outputs: e
.taproot_outputs
.into_iter()
.map(taproot_output_from_proto)
.collect(),
})
.collect(),
filtered: b.filtered,
},
Body::MempoolTweak(m) => Event::MempoolTweak {
entry: m
.entry
.map(|e| TweakEntry {
tweak: e.tweak,
txid: e.txid,
max_value: e.max_value,
taproot_outputs: e
.taproot_outputs
.into_iter()
.map(taproot_output_from_proto)
.collect(),
})
.unwrap_or(TweakEntry {
tweak: Vec::new(),
txid: Vec::new(),
max_value: 0,
taproot_outputs: Vec::new(),
}),
},
Body::SilentPaymentMatched(s) => Event::SilentPaymentMatched {
scan_pubkey: s.scan_pubkey,
txid: s.txid,
vout: s.vout,
output_pubkey: s.output_pubkey,
amount: s.amount,
tweak: s.tweak,
k: s.k,
label: s.has_label.then_some(s.label),
confirmed: s.confirmed,
height: s.confirmed.then_some(s.height),
raw_tx: (!s.raw_tx.is_empty()).then_some(s.raw_tx),
},
#[allow(unreachable_patterns)]
_ => Event::Unknown,
}
}
}
fn taproot_output_from_proto(o: pb::TaprootOutput) -> TaprootOutput {
TaprootOutput {
vout: o.vout,
output_pubkey: o.output_pubkey,
value: o.value,
}
}
fn cursor_reject_reason(reason: i32) -> CursorRejectReason {
match pb::cursor_rejected::Reason::try_from(reason) {
Ok(pb::cursor_rejected::Reason::RateLimited) => CursorRejectReason::RateLimited,
Ok(pb::cursor_rejected::Reason::ConcurrentReanchor) => {
CursorRejectReason::ConcurrentReanchor
}
Ok(pb::cursor_rejected::Reason::EmptyCursor) => CursorRejectReason::EmptyCursor,
Ok(pb::cursor_rejected::Reason::NoSource) => CursorRejectReason::NoSource,
Ok(pb::cursor_rejected::Reason::Unspecified) | Err(_) => CursorRejectReason::Unknown,
}
}
fn mempool_event(m: pb::MempoolEvent) -> Event {
use pb::mempool_event::Body;
match m.body {
Some(Body::Enter(e)) => Event::MempoolEnter {
txid: e.txid,
fee: e.fee,
vsize: e.vsize,
fee_rate_sat_per_kvb: e.fee_rate_sat_per_kvb,
time: e.time,
},
Some(Body::LeaveConfirmed(e)) => Event::MempoolLeaveConfirmed {
txid: e.txid,
block_hash: e.block_hash,
height: e.height,
},
Some(Body::LeaveEvicted(e)) => {
let reason = EvictReason::from_proto(e.reason);
Event::MempoolLeaveEvicted { txid: e.txid, reason }
}
Some(Body::LeaveReplaced(e)) => Event::MempoolLeaveReplaced {
txid: e.txid,
replacing_txid: e.replacing_txid,
},
None => Event::Unknown,
}
}
fn chain_event(c: pb::ChainEvent) -> Event {
use pb::chain_event::Body;
match c.body {
Some(Body::BlockConnected(b)) => Event::BlockConnected { hash: b.hash, height: b.height },
Some(Body::BlockDisconnected(b)) => {
Event::BlockDisconnected { hash: b.hash, height: b.height }
}
Some(Body::Reorg(r)) => Event::Reorg {
from_height: r.from_height,
old_tip: r.old_tip,
to_height: r.to_height,
new_tip: r.new_tip,
},
None => Event::Unknown,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn an_unset_severity_decodes_to_unspecified_not_unknown() {
assert_eq!(StatusSeverity::from_proto(0), StatusSeverity::Unspecified);
}
#[test]
fn an_unset_severity_ranks_below_info() {
assert!(StatusSeverity::Unspecified < StatusSeverity::Info);
assert!(StatusSeverity::Unspecified < StatusSeverity::Critical);
assert!(StatusSeverity::from_proto(0) < StatusSeverity::Warning);
}
#[test]
fn known_severities_order_by_loudness() {
assert!(StatusSeverity::Info < StatusSeverity::Warning);
assert!(StatusSeverity::Warning < StatusSeverity::Critical);
for (v, want) in [
(1, StatusSeverity::Info),
(2, StatusSeverity::Warning),
(3, StatusSeverity::Critical),
] {
assert_eq!(StatusSeverity::from_proto(v), want);
}
}
#[test]
fn an_unrecognized_severity_still_outranks_critical() {
let future = StatusSeverity::from_proto(4);
assert_eq!(future, StatusSeverity::Unknown(4));
assert!(future > StatusSeverity::Critical);
assert!(future >= StatusSeverity::Warning);
}
#[test]
fn ord_agrees_with_eq_for_distinct_unknowns() {
let a = StatusSeverity::Unknown(5);
let b = StatusSeverity::Unknown(9);
assert_ne!(a, b);
assert_ne!(a.cmp(&b), std::cmp::Ordering::Equal);
assert!(a < b);
assert_eq!(a.cmp(&a), std::cmp::Ordering::Equal);
}
#[test]
fn sorting_puts_the_loudest_last_and_the_unset_first() {
let mut all = vec![
StatusSeverity::Critical,
StatusSeverity::Unknown(7),
StatusSeverity::Unspecified,
StatusSeverity::Warning,
StatusSeverity::Info,
];
all.sort();
assert_eq!(all, vec![
StatusSeverity::Unspecified,
StatusSeverity::Info,
StatusSeverity::Warning,
StatusSeverity::Critical,
StatusSeverity::Unknown(7),
]);
}
#[test]
fn the_documented_filter_idiom_selects_what_it_should() {
let page = |s: StatusSeverity| s >= StatusSeverity::Warning;
assert!(!page(StatusSeverity::Unspecified));
assert!(!page(StatusSeverity::Info));
assert!(page(StatusSeverity::Warning));
assert!(page(StatusSeverity::Critical));
assert!(page(StatusSeverity::Unknown(4)));
}
#[test]
fn unspecified_and_unknown_zero_are_different_values() {
assert_ne!(StatusSeverity::Unspecified, StatusSeverity::Unknown(0));
assert!(StatusSeverity::Unknown(0) > StatusSeverity::Critical);
assert!(StatusSeverity::Unspecified < StatusSeverity::Info);
}
#[test]
fn the_order_is_total_across_mixed_sign_unknowns() {
let mut all = vec![
StatusSeverity::Unknown(i32::MAX),
StatusSeverity::Critical,
StatusSeverity::Unknown(i32::MIN),
StatusSeverity::Unspecified,
StatusSeverity::Unknown(0),
StatusSeverity::Info,
];
all.sort();
assert_eq!(all, vec![
StatusSeverity::Unspecified,
StatusSeverity::Info,
StatusSeverity::Critical,
StatusSeverity::Unknown(i32::MIN),
StatusSeverity::Unknown(0),
StatusSeverity::Unknown(i32::MAX),
]);
for v in [i32::MIN, -1, 0, 1, i32::MAX] {
assert!(StatusSeverity::Unknown(v) > StatusSeverity::Critical, "Unknown({v})");
}
assert!(StatusSeverity::Unspecified < StatusSeverity::Warning);
assert!(StatusSeverity::Warning < StatusSeverity::Unknown(i32::MIN));
assert!(StatusSeverity::Unspecified < StatusSeverity::Unknown(i32::MIN));
}
#[test]
fn a_negative_severity_is_unrecognized_not_unset() {
assert_eq!(StatusSeverity::from_proto(-1), StatusSeverity::Unknown(-1));
assert!(StatusSeverity::from_proto(-1) > StatusSeverity::Critical);
}
#[test]
fn the_sibling_enums_split_unset_from_unrecognized_too() {
assert_eq!(StatusKind::from_proto(0), StatusKind::Unspecified);
assert_eq!(StatusKind::from_proto(99), StatusKind::Unknown(99));
assert_eq!(StatusState::from_proto(0), StatusState::Unspecified);
assert_eq!(StatusState::from_proto(99), StatusState::Unknown(99));
assert_eq!(EvictReason::from_proto(0), EvictReason::Unspecified);
assert_eq!(EvictReason::from_proto(99), EvictReason::Unknown(99));
assert_eq!(EvictReason::from_proto(1), EvictReason::FullPool);
}
}