Skip to main content

bsv_tracker/
events.rs

1use std::fmt;
2
3use crate::{Hash, Height};
4use serde::{Deserialize, Serialize};
5
6/// The eight public header fields and cumulative work in the #32 envelope.
7/// \[SRC\] rust-chaintracks@a62f9ed docs/CHAIN-EVENTS.md:13-23;
8/// src/events.rs:13-25 fixes the Rust integer widths.
9/// These are wire data, never a checked inclusion capability.
10#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
11#[serde(rename_all = "camelCase", deny_unknown_fields)]
12pub struct EventHeader {
13    /// The block version.
14    pub version: u32,
15    /// The display order hash of the parent block.
16    pub previous_hash: Hash,
17    /// The display order merkle root the header commits to.
18    pub merkle_root: Hash,
19    /// The block timestamp, in seconds.
20    pub time: u32,
21    /// The compact difficulty target.
22    pub bits: u32,
23    /// The proof of work nonce.
24    pub nonce: u32,
25    /// The block's height on the emitter's chain.
26    pub height: Height,
27    /// The display order hash of this header.
28    pub hash: Hash,
29    /// The cumulative chain work at this header, as the emitter's string.
30    pub chain_work: String,
31}
32
33/// A frozen output's display-order txid and output index.
34/// \[SRC\] rust-chaintracks@a62f9ed src/events.rs:43-48.
35#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
36#[serde(deny_unknown_fields)]
37pub struct Outpoint {
38    /// The display order txid of the transaction that created the output.
39    pub txid: Hash,
40    /// The output index within that transaction.
41    pub vout: u32,
42}
43
44/// One tip of a split, as the header service observed it over its rungs.
45/// \[SRC\] chaintracks-cloudflare@v0.4.1 src/events.rs:50-57, docs/SPLITS.md:75-92.
46/// These are wire data: neither `chain_work` nor `rungs` is checked here.
47#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
48#[serde(rename_all = "camelCase", deny_unknown_fields)]
49pub struct SplitTip {
50    /// The display order hash of this branch's witness tip.
51    pub hash: Hash,
52    /// The witness tip's height, at or above the split's `fork_height`.
53    pub height: Height,
54    /// The cumulative chain work at the tip, as the emitter's string.
55    pub chain_work: String,
56    /// The rung names reporting exactly this tip, sorted and non-empty.
57    pub rungs: Vec<String>,
58}
59
60/// The six #32 events of version 1 and the two split events of version 2,
61/// serialized and deserialized with `v` and `kind`; each kind selects its
62/// version. \[SRC\] rust-chaintracks@a62f9ed docs/CHAIN-EVENTS.md:24-35;
63/// chaintracks-cloudflare@v0.4.1 src/events.rs:59-108, docs/SPLITS.md:68-83.
64/// For typed host faults, use [`decode_envelope`].
65#[derive(Clone, Debug, PartialEq, Eq)]
66pub enum ChainEvent {
67    /// A new active tip. Only suspect transactions need a recheck.
68    Tip {
69        /// The new tip's height.
70        height: Height,
71        /// The new tip's display order hash.
72        hash: Hash,
73        /// The new tip's block timestamp, in seconds.
74        time: u32,
75        /// The new tip's header.
76        header: EventHeader,
77    },
78    /// A fork that has not resolved.
79    Fork {
80        /// The height at which the two branches diverge.
81        height: Height,
82        /// The tips of the two competing branches.
83        competing_tips: [EventHeader; 2],
84        /// The fork's depth, in blocks, as the emitter reports it.
85        depth: u32,
86    },
87    /// Blocks at or above a fork point have been replaced.
88    Reorg {
89        /// The lowest height whose block was replaced.
90        fork_height: Height,
91        /// The reorg's depth, in blocks, as the emitter reports it.
92        depth: u32,
93        /// The headers that left the active chain.
94        deactivated_headers: Vec<EventHeader>,
95        /// The active tip after the reorg.
96        new_tip: EventHeader,
97    },
98    /// The alert system invalidated a block.
99    Invalidated {
100        /// The display order hash of the invalidated block.
101        block_hash: Hash,
102    },
103    /// A frozen output. The wallet owns its meaning; it moves no tracker word.
104    Frozen {
105        /// The frozen output.
106        outpoint: Outpoint,
107    },
108    /// Tip age. The header snapshot implements the fail closed response.
109    TipAge {
110        /// The tip's age, in seconds, as the emitter measured it.
111        seconds: u64,
112        /// The tip whose age is reported.
113        tip: EventHeader,
114    },
115    /// Version 2: two rungs follow different branches past the split depth.
116    /// An observation, not a reorg: it changes no tip and writes no word.
117    Split {
118        /// `split:` and the common ancestor's display order hash.
119        key: String,
120        /// The lowest affected height, one above the common ancestor.
121        fork_height: Height,
122        /// A witness tip on each branch, with the rungs reporting it.
123        tips: [SplitTip; 2],
124    },
125    /// Version 2: no pair of rungs still straddles the split with this key.
126    SplitResolved {
127        /// The key of the split that resolved.
128        key: String,
129        /// The split's lowest affected height.
130        fork_height: Height,
131    },
132}
133
134impl ChainEvent {
135    /// The envelope version this kind is carried in: 2 for the split kinds,
136    /// 1 for the six original kinds. \[SRC\] chaintracks-cloudflare@v0.4.1
137    /// docs/SPLITS.md:68-73.
138    #[must_use]
139    pub fn version(&self) -> u64 {
140        match self {
141            Self::Split { .. } | Self::SplitResolved { .. } => 2,
142            _ => 1,
143        }
144    }
145}
146
147// Remote serde derives keep the unversioned body private. Both public decode
148// paths pass through decode_value before a ChainEvent can be delivered.
149#[derive(Serialize, Deserialize)]
150#[serde(
151    remote = "ChainEvent",
152    tag = "kind",
153    rename_all = "camelCase",
154    deny_unknown_fields
155)]
156enum EventFields {
157    Tip {
158        height: Height,
159        hash: Hash,
160        time: u32,
161        header: EventHeader,
162    },
163    Fork {
164        height: Height,
165        #[serde(rename = "competingTips")]
166        competing_tips: [EventHeader; 2],
167        depth: u32,
168    },
169    Reorg {
170        #[serde(rename = "forkHeight")]
171        fork_height: Height,
172        depth: u32,
173        #[serde(rename = "deactivatedHeaders")]
174        deactivated_headers: Vec<EventHeader>,
175        #[serde(rename = "newTip")]
176        new_tip: EventHeader,
177    },
178    Invalidated {
179        #[serde(rename = "blockHash")]
180        block_hash: Hash,
181    },
182    Frozen {
183        outpoint: Outpoint,
184    },
185    TipAge {
186        seconds: u64,
187        tip: EventHeader,
188    },
189    Split {
190        key: String,
191        #[serde(rename = "forkHeight")]
192        fork_height: Height,
193        tips: [SplitTip; 2],
194    },
195    SplitResolved {
196        key: String,
197        #[serde(rename = "forkHeight")]
198        fork_height: Height,
199    },
200}
201
202impl Serialize for ChainEvent {
203    fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
204        #[derive(Serialize)]
205        struct EnvelopeRef<'a> {
206            v: u64,
207            #[serde(flatten, with = "EventFields")]
208            event: &'a ChainEvent,
209        }
210        EnvelopeRef {
211            v: self.version(),
212            event: self,
213        }
214        .serialize(serializer)
215    }
216}
217
218impl<'de> Deserialize<'de> for ChainEvent {
219    fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
220        let value = serde_json::Value::deserialize(deserializer)?;
221        decode_value(value).map_err(serde::de::Error::custom)
222    }
223}
224
225/// The flat chain-event envelope: version 1 from rust-chaintracks@a62f9ed,
226/// version 2 from chaintracks-cloudflare@v0.4.1.
227#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
228pub struct ChainEnvelope {
229    /// The envelope version: 1, or 2 for the split kinds.
230    v: u64,
231    /// The event's kind and fields, flattened beside the version.
232    #[serde(flatten, with = "EventFields")]
233    event: ChainEvent,
234}
235
236impl ChainEnvelope {
237    /// Wrap an event in the version its kind selects.
238    #[must_use]
239    pub fn new(event: ChainEvent) -> Self {
240        Self {
241            v: event.version(),
242            event,
243        }
244    }
245    /// The validated wire version.
246    #[must_use]
247    pub fn version(&self) -> u64 {
248        self.v
249    }
250    /// The decoded event.
251    #[must_use]
252    pub fn event(&self) -> &ChainEvent {
253        &self.event
254    }
255    /// Consume the envelope to deliver its decoded event.
256    #[must_use]
257    pub fn into_event(self) -> ChainEvent {
258        self.event
259    }
260}
261
262impl<'de> Deserialize<'de> for ChainEnvelope {
263    fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
264        ChainEvent::deserialize(deserializer).map(Self::new)
265    }
266}
267
268/// A fault the host must report, without feeding an event to the tracker.
269#[derive(Clone, Debug, PartialEq, Eq)]
270pub enum DecodeError {
271    /// A missing, malformed or unrecognized body.
272    UnknownShape {
273        /// The envelope's version, when it was read before the fault.
274        version: Option<u64>,
275        /// What was wrong with the body.
276        detail: String,
277    },
278    /// The sender uses a version this client does not know.
279    UnknownVersion(u64),
280    /// The sender uses an event kind this client does not know.
281    UnknownKind(String),
282}
283impl fmt::Display for DecodeError {
284    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
285        write!(f, "{self:?}")
286    }
287}
288impl std::error::Error for DecodeError {}
289
290/// Pure decoding, with the version gated before the shape, independent of JSON
291/// key order. Unknown fields, kinds and versions are reported errors.
292///
293/// # Errors
294///
295/// [`DecodeError::UnknownVersion`] when `v` is a number other than 1 or 2,
296/// [`DecodeError::UnknownKind`] for an event kind its version does not carry
297/// (the six original kinds are version 1 only, the two split kinds version 2
298/// only),
299/// and [`DecodeError::UnknownShape`] for bytes that are not JSON, a missing or
300/// malformed `v` or `kind`, an unknown field, or fields that are inconsistent
301/// with each other.
302pub fn decode_envelope(bytes: &[u8]) -> Result<ChainEvent, DecodeError> {
303    let value: serde_json::Value =
304        serde_json::from_slice(bytes).map_err(|e| DecodeError::UnknownShape {
305            version: None,
306            detail: e.to_string(),
307        })?;
308    decode_value(value)
309}
310
311fn decode_value(mut value: serde_json::Value) -> Result<ChainEvent, DecodeError> {
312    let version = value.get("v").and_then(serde_json::Value::as_u64);
313    let Some(v) = version else {
314        return Err(DecodeError::UnknownShape {
315            version,
316            detail: "missing or malformed v".into(),
317        });
318    };
319    if !matches!(v, 1 | 2) {
320        return Err(DecodeError::UnknownVersion(v));
321    }
322    // `v` was read through `get`, so the value is an object.
323    if let Some(object) = value.as_object_mut() {
324        object.remove("v");
325    }
326    let kind = value
327        .get("kind")
328        .and_then(serde_json::Value::as_str)
329        .ok_or_else(|| DecodeError::UnknownShape {
330            version: Some(v),
331            detail: "missing or malformed kind".into(),
332        })?;
333    // Each version carries exactly its own kinds. The emitter's reader also
334    // admits a version-1 kind under `v: 2` (chaintracks-cloudflare@v0.4.1
335    // src/events.rs:139-146); this gate is stricter, since the emitter writes
336    // the six original kinds as version 1 only (docs/SPLITS.md:116).
337    let known = match v {
338        1 => matches!(
339            kind,
340            "tip" | "fork" | "reorg" | "invalidated" | "frozen" | "tipAge"
341        ),
342        _ => matches!(kind, "split" | "splitResolved"),
343    };
344    if !known {
345        return Err(DecodeError::UnknownKind(kind.into()));
346    }
347    let event = EventFields::deserialize(value).map_err(|e| DecodeError::UnknownShape {
348        version: Some(v),
349        detail: e.to_string(),
350    })?;
351    if !valid_event(&event) {
352        return Err(DecodeError::UnknownShape {
353            version: Some(v),
354            detail: "inconsistent chain event".into(),
355        });
356    }
357    Ok(event)
358}
359
360// Match the emitter's shape/relationship checks, without establishing PoW,
361// active-chain membership or inclusion. [SRC] rust-chaintracks@a62f9ed
362// src/events.rs:125-185; the split kinds chaintracks-cloudflare@v0.4.1
363// src/events.rs:157-161,210-232. The tracker still obtains evidence via
364// Headers::check.
365fn valid_event(event: &ChainEvent) -> bool {
366    match event {
367        ChainEvent::Tip {
368            height,
369            hash,
370            time,
371            header,
372        } => {
373            valid_header(header)
374                && *height == header.height
375                && *hash == header.hash
376                && *time == header.time
377        }
378        ChainEvent::Fork {
379            height,
380            competing_tips,
381            depth,
382        } => {
383            *depth > 0
384                && competing_tips.iter().all(valid_header)
385                && competing_tips[0].hash != competing_tips[1].hash
386                && competing_tips[0]
387                    .height
388                    .checked_sub(*depth)
389                    .and_then(|h| h.checked_add(1))
390                    == Some(*height)
391        }
392        ChainEvent::Reorg {
393            fork_height,
394            depth,
395            deactivated_headers,
396            new_tip,
397        } => {
398            *depth > 0
399                && *depth <= 400
400                && deactivated_headers.len() == *depth as usize
401                && valid_header(new_tip)
402                && deactivated_headers.iter().all(valid_header)
403                && deactivated_headers
404                    .last()
405                    .is_some_and(|h| h.height == *fork_height)
406                && deactivated_headers
407                    .windows(2)
408                    .all(|p| p[0].height == p[1].height + 1 && p[0].previous_hash == p[1].hash)
409        }
410        ChainEvent::Invalidated { block_hash } => valid_hash(block_hash),
411        ChainEvent::Frozen { outpoint } => valid_hash(&outpoint.txid),
412        ChainEvent::TipAge { tip, .. } => valid_header(tip),
413        // Two distinct tips at or above the fork height, each with non-empty,
414        // strictly sorted, non-empty rung names, no rung on both tips.
415        // [SRC] chaintracks-cloudflare@v0.4.1 src/events.rs:210-231.
416        ChainEvent::Split {
417            key,
418            fork_height,
419            tips,
420        } => {
421            valid_split_key(key, *fork_height)
422                && tips.iter().all(|tip| valid_split_tip(tip, *fork_height))
423                && tips[0].hash != tips[1].hash
424                && tips[0]
425                    .rungs
426                    .iter()
427                    .all(|name| !tips[1].rungs.contains(name))
428        }
429        // [SRC] chaintracks-cloudflare@v0.4.1 src/events.rs:232.
430        ChainEvent::SplitResolved { key, fork_height } => valid_split_key(key, *fork_height),
431    }
432}
433
434// `split:` and the common ancestor's hash, above a non-zero fork height.
435// [SRC] chaintracks-cloudflare@v0.4.1 src/events.rs:157-161, docs/SPLITS.md:88.
436fn valid_split_key(key: &str, fork_height: Height) -> bool {
437    fork_height > 0
438        && i32::try_from(fork_height).is_ok()
439        && key.strip_prefix("split:").is_some_and(valid_hash)
440}
441
442// [SRC] chaintracks-cloudflare@v0.4.1 src/events.rs:217-225.
443fn valid_split_tip(tip: &SplitTip, fork_height: Height) -> bool {
444    valid_hash(&tip.hash)
445        && valid_hash(&tip.chain_work)
446        && tip.height >= fork_height
447        && i32::try_from(tip.height).is_ok()
448        && !tip.rungs.is_empty()
449        && tip.rungs.iter().all(|name| !name.is_empty())
450        && tip.rungs.windows(2).all(|names| names[0] < names[1])
451}
452
453fn valid_header(header: &EventHeader) -> bool {
454    i32::try_from(header.height).is_ok()
455        && valid_hash(&header.hash)
456        && valid_hash(&header.previous_hash)
457        && valid_hash(&header.merkle_root)
458        && valid_hash(&header.chain_work)
459}
460
461fn valid_hash(hash: &str) -> bool {
462    hash.len() == 64 && hash.bytes().all(|b| b.is_ascii_hexdigit())
463}