Skip to main content

bsv_tracker/
state.rs

1use crate::{
2    ChainEvent, CheckError, CheckedProof, CompactError, Headers, Height, Hint, HintStatus, Proof,
3    Timestamp, TxId,
4};
5use std::sync::Arc;
6
7/// Inclusion backed by an immutable checked capability; height is derived.
8///
9/// ```compile_fail
10/// use bsv_tracker::Word;
11/// let _ = Word::Mined { height: 100 };
12/// ```
13#[derive(Clone, Debug, PartialEq, Eq)]
14pub struct Mined {
15    checked: Arc<CheckedProof>,
16}
17impl Mined {
18    /// The height of the checked proof. It cannot differ from the evidence.
19    #[must_use]
20    pub fn height(&self) -> Height {
21        self.checked.height()
22    }
23    /// The checked capability behind the word.
24    #[must_use]
25    pub fn checked(&self) -> &CheckedProof {
26        &self.checked
27    }
28}
29/// The nine words. `Mined` needs an unforgeable checked proof.
30#[derive(Clone, Debug, PartialEq, Eq)]
31pub enum Word {
32    /// Tracked, with no claim from any tier.
33    Unknown,
34    /// The host built the transaction.
35    Built,
36    /// A broadcaster accepted the transaction.
37    Announced,
38    /// A broadcaster reports the transaction seen on the network.
39    Seen,
40    /// Included, backed by a checked proof against the active headers.
41    Mined(Mined),
42    /// Was mined in a block that left the active chain.
43    Stale,
44    /// The host's trusted node rejected the transaction.
45    Rejected {
46        /// The node's reason.
47        reason: String,
48    },
49    /// A competing transaction is mined or named by the host's trusted node.
50    Conflicted {
51        /// The competing txids, the most recent at the front.
52        competitors: Vec<TxId>,
53    },
54    /// The host abandoned the transaction before any broadcaster accepted it.
55    Abandoned,
56}
57impl Word {
58    /// `Tracker.Word.chain`, ignoring the host and hint tiers.
59    #[must_use]
60    pub fn chain(&self) -> Option<&Self> {
61        match self {
62            Self::Mined(_) | Self::Stale | Self::Rejected { .. } | Self::Conflicted { .. } => {
63                Some(self)
64            }
65            _ => None,
66        }
67    }
68    /// A height only when this is an evidenced mined word.
69    #[must_use]
70    pub fn height(&self) -> Option<Height> {
71        if let Self::Mined(m) = self {
72            Some(m.height())
73        } else {
74            None
75        }
76    }
77}
78/// A verdict explicitly supplied by the host's trusted node, never a broadcaster.
79#[derive(Clone, Debug, PartialEq, Eq)]
80pub enum NodeVerdict {
81    /// The node rejected the transaction.
82    Rejected {
83        /// The node's reason.
84        reason: String,
85    },
86    /// The node names competing transactions.
87    Conflicted {
88        /// The competing txids the node names.
89        competitors: Vec<TxId>,
90    },
91}
92/// The inputs that may write a chain word.
93#[derive(Clone, Debug, PartialEq, Eq)]
94pub enum Evidence {
95    /// A proof of this transaction's inclusion.
96    Proof(Proof),
97    /// A proof of a competing transaction's inclusion.
98    Competitor(Proof),
99    /// A chain event from the host's header feed.
100    Chain(ChainEvent),
101    /// A verdict from the host's trusted node.
102    Verdict(NodeVerdict),
103    /// Recheck a mined word against the current headers.
104    Recheck,
105}
106/// Acts supplied by the host, including its clock and spend guard.
107#[derive(Clone, Debug, PartialEq, Eq)]
108pub enum HostAction {
109    /// The host built the transaction.
110    Build,
111    /// The host abandons the transaction.
112    Abandon,
113    /// The host is about to spend an output of the transaction.
114    SpendAttempt,
115    /// The host's clock reads this time.
116    Tick(Timestamp),
117}
118/// One input to `Tracker.step`.
119#[derive(Clone, Debug, PartialEq, Eq)]
120pub enum Input {
121    /// Evidence that may write a chain word.
122    Evidence(Evidence),
123    /// A broadcaster's hint, which never writes a chain word.
124    Hint(Hint),
125    /// An act of the host.
126    Host(HostAction),
127}
128/// A value for the host to execute; the crate never fetches anything.
129#[derive(Clone, Debug, PartialEq, Eq)]
130pub enum Reask {
131    /// The root moved: the active header at the mined height no longer
132    /// carries the stored proof's root, so the word is stale; fetch a fresh
133    /// proof. After a chain event a host fetches only for these, a few a pass;
134    /// the pacing is the host's.
135    Reorg {
136        /// The reorg's fork height when a reorg's recheck found the root
137        /// moved; otherwise the mined height (an invalidation, a recheck).
138        fork_height: Height,
139    },
140    /// A fork, or a version-2 split, at or below the mined height; the word
141    /// is suspect. A split reuses this re-ask with its `forkHeight`: the host's
142    /// act, a recheck at its depth rule, is the same for both.
143    Fork {
144        /// The height at which the branches diverge (a split's `fork_height`).
145        height: Height,
146    },
147    /// A hint that disagrees with the word; reconcile it.
148    Hint(Hint),
149    /// A proof or competitor proof failed its check.
150    ProofFailed {
151        /// The height the failed proof named.
152        height: Height,
153    },
154    /// Recheck the mined word against the current headers.
155    Recheck,
156    /// The last hint is older than the age threshold.
157    Age,
158    /// The host is about to spend; recheck before it does.
159    Spend,
160}
161/// Host selected scheduling parameters.
162#[derive(Clone, Debug, PartialEq, Eq)]
163pub struct Params {
164    /// Seconds after the last hint before an announced or seen word is re-asked.
165    pub age_threshold: Timestamp,
166}
167/// The complete private state of one transaction.
168#[derive(Clone, Debug, PartialEq, Eq)]
169pub struct State {
170    txid: TxId,
171    word: Word,
172    evidence: Option<Arc<CheckedProof>>,
173    suspect: bool,
174    hints: Vec<Hint>,
175    last_hint_at: Option<Timestamp>,
176    reask: Option<Reask>,
177}
178impl State {
179    /// Birth with no chain claim or stored evidence, as in `Tracker.State.init`.
180    pub fn new(txid: impl Into<TxId>) -> Self {
181        Self {
182            txid: txid.into().to_ascii_lowercase(),
183            word: Word::Unknown,
184            evidence: None,
185            suspect: false,
186            hints: vec![],
187            last_hint_at: None,
188            reask: None,
189        }
190    }
191    /// The host's tracked transaction.
192    #[must_use]
193    pub fn txid(&self) -> &str {
194        &self.txid
195    }
196    /// The current word. It is immutable outside the transition function.
197    #[must_use]
198    pub fn word(&self) -> &Word {
199        &self.word
200    }
201    /// The retained record, including when the word has become stale.
202    #[must_use]
203    pub fn evidence(&self) -> Option<&CheckedProof> {
204        self.evidence.as_deref()
205    }
206    /// Whether an unresolved fork requires a header recheck before acting.
207    #[must_use]
208    pub fn suspect(&self) -> bool {
209        self.suspect
210    }
211    /// Observations in newest received order, matching the Lean list.
212    #[must_use]
213    pub fn hints(&self) -> &[Hint] {
214        &self.hints
215    }
216    /// Time of the most recently received hint, even for out of order clocks.
217    #[must_use]
218    pub fn last_hint_at(&self) -> Option<Timestamp> {
219        self.last_hint_at
220    }
221    /// The next re-ask, which the host must execute and feed back.
222    #[must_use]
223    pub fn reask(&self) -> Option<&Reask> {
224        self.reask.as_ref()
225    }
226    /// The compact byte form (bsv-stack-lean #92): a version byte, the txid,
227    /// the word, the evidence, the suspect mark, the hints, the last hint time
228    /// and the re-ask. The layout is in the source of `src/compact.rs`; the
229    /// version byte changes when the layout does, so a later release tells an
230    /// earlier layout from its own rather than misreading it.
231    #[must_use]
232    pub fn to_compact(&self) -> Vec<u8> {
233        crate::compact::encode(&crate::compact::Fields {
234            txid: &self.txid,
235            word: &self.word,
236            evidence: self.evidence.as_deref(),
237            suspect: self.suspect,
238            hints: &self.hints,
239            last_hint_at: self.last_hint_at,
240            reask: self.reask.as_ref(),
241        })
242    }
243    /// Restore a state from [`State::to_compact`]'s bytes.
244    ///
245    /// The rule: a restored `mined` word is still suspect until its proof is
246    /// re-checked against the host's headers. [`CheckedProof`] has no
247    /// deserializer, because bytes are not a check: the chain may have moved
248    /// while the state was stored. So the stored proof comes back as the
249    /// record (its path reduces for this txid to the stored root, which equals
250    /// the stored header's root), the word comes back `mined` on that record,
251    /// and the state is marked [`suspect`](State::suspect) with
252    /// [`Reask::Recheck`] due when no other re-ask was pending. A
253    /// [`Tracker`](crate::Tracker) holding it rechecks it on the next tip,
254    /// as it does any suspect word; a host holding the state alone feeds
255    /// [`Evidence::Recheck`] before it serves the inclusion.
256    ///
257    /// Every other field returns exactly: a state whose word is not `mined`,
258    /// or that was already suspect with a re-ask pending, restores equal to
259    /// the state that wrote the bytes, and a restored state writes bytes that
260    /// restore equal to it.
261    ///
262    /// # Errors
263    ///
264    /// [`CompactError::UnknownVersion`] for a version this crate does not
265    /// read, [`CompactError::Truncated`], [`CompactError::TrailingBytes`] and
266    /// [`CompactError::Malformed`] for bytes the encoder never writes (a
267    /// `mined` word with no stored proof among them), and
268    /// [`CompactError::Evidence`] for a stored proof that does not hold
269    /// together.
270    pub fn from_compact(bytes: &[u8]) -> Result<Self, CompactError> {
271        let parts = crate::compact::decode(bytes)?;
272        let evidence = parts.evidence.map(Arc::new);
273        let mined = parts.word.is_none();
274        let word = if let Some(word) = parts.word {
275            word
276        } else {
277            let checked = evidence
278                .as_ref()
279                .ok_or(CompactError::Malformed("mined word with no stored proof"))?;
280            Word::Mined(Mined {
281                checked: Arc::clone(checked),
282            })
283        };
284        let mut state = Self {
285            txid: parts.txid,
286            word,
287            evidence,
288            suspect: parts.suspect,
289            hints: parts.hints,
290            last_hint_at: parts.last_hint_at,
291            reask: parts.reask,
292        };
293        if mined {
294            state.suspect = true;
295            state.reask.get_or_insert(Reask::Recheck);
296        }
297        Ok(state)
298    }
299    /// Execute one Lean transition. Proof and header failures retain the word
300    /// and record the re-ask before returning an error to the host.
301    ///
302    /// # Errors
303    ///
304    /// The [`CheckError`] of a proof, competitor, chain event or recheck whose
305    /// check fails; the word is kept and the re-ask recorded.
306    pub fn step<H: Headers + ?Sized>(
307        &mut self,
308        params: &Params,
309        headers: &H,
310        input: Input,
311    ) -> Result<(), CheckError> {
312        match input {
313            Input::Hint(hint) => self.on_hint(hint),
314            Input::Host(action) => self.on_host(params, &action),
315            Input::Evidence(Evidence::Proof(proof)) => self.on_proof(headers, proof)?,
316            Input::Evidence(Evidence::Competitor(proof)) => self.on_competitor(headers, proof)?,
317            Input::Evidence(Evidence::Chain(event)) => self.on_chain(headers, &event)?,
318            Input::Evidence(Evidence::Verdict(verdict)) => self.on_verdict(verdict),
319            Input::Evidence(Evidence::Recheck) => self.recheck(headers, None)?,
320        }
321        Ok(())
322    }
323
324    fn on_hint(&mut self, hint: Hint) {
325        match (&self.word, &hint.status) {
326            // A node we did not broadcast through holds the bytes, so someone
327            // announced it; the chain has no author (ruling (2), bsv-stack-lean
328            // docs/RULINGS-2026-10-12.md, 2026-10-20 19:4xZ).
329            (Word::Built, HintStatus::Accepted | HintStatus::MempoolPresent { .. }) => {
330                self.word = Word::Announced;
331            }
332            (Word::Built | Word::Announced, HintStatus::Seen) => self.word = Word::Seen,
333            _ => {}
334        }
335        if !agrees(&self.word, &hint.status) {
336            self.reask = Some(Reask::Hint(hint.clone()));
337        }
338        self.last_hint_at = Some(hint.observed);
339        self.hints.insert(0, hint);
340    }
341
342    fn on_host(&mut self, params: &Params, action: &HostAction) {
343        match *action {
344            HostAction::Build if self.word == Word::Unknown => self.word = Word::Built,
345            HostAction::Abandon if matches!(self.word, Word::Unknown | Word::Built) => {
346                self.word = Word::Abandoned;
347            }
348            HostAction::SpendAttempt => self.reask = Some(Reask::Spend),
349            // Lean adds unbounded naturals. Checked addition keeps an
350            // overflowing deadline from becoming prematurely due.
351            HostAction::Tick(now)
352                if matches!(self.word, Word::Announced | Word::Seen)
353                    && self
354                        .last_hint_at
355                        .and_then(|t| t.checked_add(params.age_threshold))
356                        .is_some_and(|deadline| deadline <= now) =>
357            {
358                self.reask = Some(Reask::Age);
359            }
360            _ => {}
361        }
362    }
363
364    fn on_proof<H: Headers + ?Sized>(
365        &mut self,
366        headers: &H,
367        proof: Proof,
368    ) -> Result<(), CheckError> {
369        let height = proof.height();
370        if proof.txid() != self.txid {
371            self.reask = Some(Reask::ProofFailed { height });
372            return Err(CheckError::TxidMismatch);
373        }
374        match crate::evidence::check_snapshot(headers, proof) {
375            Ok(checked) => {
376                let checked = Arc::new(checked);
377                self.word = Word::Mined(Mined {
378                    checked: Arc::clone(&checked),
379                });
380                self.evidence = Some(checked);
381                self.suspect = false;
382                self.reask = None;
383                Ok(())
384            }
385            Err(error) => {
386                self.reask = Some(Reask::ProofFailed { height });
387                Err(error)
388            }
389        }
390    }
391
392    fn on_competitor<H: Headers + ?Sized>(
393        &mut self,
394        headers: &H,
395        proof: Proof,
396    ) -> Result<(), CheckError> {
397        let height = proof.height();
398        let competitor = proof.txid().to_owned();
399        match crate::evidence::check_snapshot(headers, proof) {
400            Ok(_) => {
401                match &mut self.word {
402                    Word::Mined(_) => self.reask = Some(Reask::Recheck),
403                    Word::Conflicted { competitors } => competitors.insert(0, competitor),
404                    _ => {
405                        self.word = Word::Conflicted {
406                            competitors: vec![competitor],
407                        }
408                    }
409                }
410                Ok(())
411            }
412            Err(error) => {
413                self.reask = Some(Reask::ProofFailed { height });
414                Err(error)
415            }
416        }
417    }
418
419    /// The local recheck: the stored proof's root against the active header's
420    /// root at the word's height, a header read and never a fetch. Equal, the
421    /// word stands and the marks clear; different, the word is stale with
422    /// [`Reask::Reorg`] at `fork_height` (the word's height when none is
423    /// given); no header, the word stands with [`Reask::Recheck`].
424    fn recheck<H: Headers + ?Sized>(
425        &mut self,
426        headers: &H,
427        fork_height: Option<Height>,
428    ) -> Result<(), CheckError> {
429        let Word::Mined(mined) = &self.word else {
430            return Ok(());
431        };
432        let height = mined.height();
433        let answer = headers.header_at(height);
434        match answer {
435            Ok(Some(header)) if header.merkle_root == mined.checked().root() => {
436                self.suspect = false;
437                self.reask = None;
438            }
439            Ok(Some(_)) => self.stale(fork_height.unwrap_or(height)),
440            Ok(None) => {
441                self.reask = Some(Reask::Recheck);
442                return Err(CheckError::Unavailable(height));
443            }
444            Err(error) => {
445                self.reask = Some(Reask::Recheck);
446                return Err(CheckError::Headers(error));
447            }
448        }
449        Ok(())
450    }
451
452    fn stale(&mut self, fork_height: Height) {
453        self.word = Word::Stale;
454        self.suspect = false;
455        self.reask = Some(Reask::Reorg { fork_height });
456    }
457
458    /// A chain event names the words to recheck; only the headers move one.
459    ///
460    /// The rule by the root (ruling (5), bsv-stack-lean
461    /// docs/RULINGS-2026-10-12.md, 2026-10-20 19:4xZ): a `mined` word is
462    /// `stale` when, and only when, its stored proof's root is not the active
463    /// header's root at its height in the host's headers. A reorg at or below
464    /// the word's height and an invalidation of its checked header both run
465    /// the local recheck; the event's numbers are the bound of that recheck,
466    /// never the stale mark. The word is suspect while the recheck is open, so
467    /// a missing header leaves it suspect with [`Reask::Recheck`] (fail
468    /// closed) and the next tip rechecks it. A fork or a split marks the word
469    /// suspect with [`Reask::Fork`]; a resolved split and a tip recheck the
470    /// suspect words.
471    pub(crate) fn on_chain<H: Headers + ?Sized>(
472        &mut self,
473        headers: &H,
474        event: &ChainEvent,
475    ) -> Result<(), CheckError> {
476        match event {
477            ChainEvent::Tip { .. } if self.suspect => self.recheck(headers, None)?,
478            ChainEvent::Fork { height, .. } if self.word.height().is_some_and(|h| *height <= h) => {
479                self.suspect = true;
480                self.reask = Some(Reask::Fork { height: *height });
481            }
482            ChainEvent::Reorg { fork_height, .. }
483                if self.word.height().is_some_and(|h| *fork_height <= h) =>
484            {
485                self.suspect = true;
486                self.recheck(headers, Some(*fork_height))?;
487            }
488            // A split is an observation, not a reorg: the service still answers
489            // its most-work chain and depth is the host's (bsv-stack-lean
490            // docs/RULINGS-2026-10-12.md:323-329, 2026-10-16 19:3xZ). It writes no word.
491            ChainEvent::Split { fork_height, .. }
492                if self.word.height().is_some_and(|h| *fork_height <= h) =>
493            {
494                self.suspect = true;
495                self.reask = Some(Reask::Fork {
496                    height: *fork_height,
497                });
498            }
499            // A resolved split moves a word only through the checked snapshot.
500            ChainEvent::SplitResolved { fork_height, .. }
501                if self.suspect && self.word.height().is_some_and(|h| *fork_height <= h) =>
502            {
503                self.recheck(headers, None)?;
504            }
505            ChainEvent::Invalidated { block_hash } if self.checked_against(block_hash) => {
506                self.suspect = true;
507                self.recheck(headers, None)?;
508            }
509            _ => {}
510        }
511        Ok(())
512    }
513
514    fn checked_against(&self, block_hash: &str) -> bool {
515        matches!(&self.word, Word::Mined(m) if m.checked().header().hash == block_hash)
516    }
517
518    fn on_verdict(&mut self, verdict: NodeVerdict) {
519        if matches!(self.word, Word::Mined(_)) {
520            self.reask = Some(Reask::Recheck);
521            return;
522        }
523        self.word = match verdict {
524            NodeVerdict::Rejected { reason } => Word::Rejected { reason },
525            NodeVerdict::Conflicted { mut competitors } => {
526                if let Word::Conflicted {
527                    competitors: previous,
528                } = &self.word
529                {
530                    competitors.extend(previous.iter().cloned());
531                }
532                Word::Conflicted { competitors }
533            }
534        };
535    }
536}
537
538fn agrees(word: &Word, status: &HintStatus) -> bool {
539    match (word, status) {
540        (
541            Word::Announced | Word::Seen,
542            HintStatus::Accepted | HintStatus::MempoolPresent { .. },
543        )
544        | (Word::Seen, HintStatus::Seen)
545        | (Word::Stale, HintStatus::StaleBlock)
546        | (Word::Rejected { .. }, HintStatus::Rejected { .. })
547        | (Word::Conflicted { .. }, HintStatus::DoubleSpend { .. }) => true,
548        (Word::Mined(mined), HintStatus::Mined { height }) => mined.height() == *height,
549        _ => false,
550    }
551}