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 mined word went stale; fetch a fresh proof.
132    Reorg {
133        /// The lowest replaced height, or the mined height when its block was
134        /// invalidated or its root no longer matches the active header.
135        fork_height: Height,
136    },
137    /// A fork, or a version-2 split, at or below the mined height; the word
138    /// is suspect. A split reuses this re-ask with its `forkHeight`: the host's
139    /// act, a recheck at its depth rule, is the same for both.
140    Fork {
141        /// The height at which the branches diverge (a split's `fork_height`).
142        height: Height,
143    },
144    /// A hint that disagrees with the word; reconcile it.
145    Hint(Hint),
146    /// A proof or competitor proof failed its check.
147    ProofFailed {
148        /// The height the failed proof named.
149        height: Height,
150    },
151    /// Recheck the mined word against the current headers.
152    Recheck,
153    /// The last hint is older than the age threshold.
154    Age,
155    /// The host is about to spend; recheck before it does.
156    Spend,
157}
158/// Host selected scheduling parameters.
159#[derive(Clone, Debug, PartialEq, Eq)]
160pub struct Params {
161    /// Seconds after the last hint before an announced or seen word is re-asked.
162    pub age_threshold: Timestamp,
163}
164/// The complete private state of one transaction.
165#[derive(Clone, Debug, PartialEq, Eq)]
166pub struct State {
167    txid: TxId,
168    word: Word,
169    evidence: Option<Arc<CheckedProof>>,
170    suspect: bool,
171    hints: Vec<Hint>,
172    last_hint_at: Option<Timestamp>,
173    reask: Option<Reask>,
174}
175impl State {
176    /// Birth with no chain claim or stored evidence, as in `Tracker.State.init`.
177    pub fn new(txid: impl Into<TxId>) -> Self {
178        Self {
179            txid: txid.into().to_ascii_lowercase(),
180            word: Word::Unknown,
181            evidence: None,
182            suspect: false,
183            hints: vec![],
184            last_hint_at: None,
185            reask: None,
186        }
187    }
188    /// The host's tracked transaction.
189    #[must_use]
190    pub fn txid(&self) -> &str {
191        &self.txid
192    }
193    /// The current word. It is immutable outside the transition function.
194    #[must_use]
195    pub fn word(&self) -> &Word {
196        &self.word
197    }
198    /// The retained record, including when the word has become stale.
199    #[must_use]
200    pub fn evidence(&self) -> Option<&CheckedProof> {
201        self.evidence.as_deref()
202    }
203    /// Whether an unresolved fork requires a header recheck before acting.
204    #[must_use]
205    pub fn suspect(&self) -> bool {
206        self.suspect
207    }
208    /// Observations in newest received order, matching the Lean list.
209    #[must_use]
210    pub fn hints(&self) -> &[Hint] {
211        &self.hints
212    }
213    /// Time of the most recently received hint, even for out of order clocks.
214    #[must_use]
215    pub fn last_hint_at(&self) -> Option<Timestamp> {
216        self.last_hint_at
217    }
218    /// The next re-ask, which the host must execute and feed back.
219    #[must_use]
220    pub fn reask(&self) -> Option<&Reask> {
221        self.reask.as_ref()
222    }
223    /// The compact byte form (bsv-stack-lean #92): a version byte, the txid,
224    /// the word, the evidence, the suspect mark, the hints, the last hint time
225    /// and the re-ask. The layout is in the source of `src/compact.rs`; the
226    /// version byte changes when the layout does, so a later release tells an
227    /// earlier layout from its own rather than misreading it.
228    #[must_use]
229    pub fn to_compact(&self) -> Vec<u8> {
230        crate::compact::encode(&crate::compact::Fields {
231            txid: &self.txid,
232            word: &self.word,
233            evidence: self.evidence.as_deref(),
234            suspect: self.suspect,
235            hints: &self.hints,
236            last_hint_at: self.last_hint_at,
237            reask: self.reask.as_ref(),
238        })
239    }
240    /// Restore a state from [`State::to_compact`]'s bytes.
241    ///
242    /// The rule: a restored `mined` word is still suspect until its proof is
243    /// re-checked against the host's headers. [`CheckedProof`] has no
244    /// deserializer, because bytes are not a check: the chain may have moved
245    /// while the state was stored. So the stored proof comes back as the
246    /// record (its path reduces for this txid to the stored root, which equals
247    /// the stored header's root), the word comes back `mined` on that record,
248    /// and the state is marked [`suspect`](State::suspect) with
249    /// [`Reask::Recheck`] due when no other re-ask was pending. A
250    /// [`Tracker`](crate::Tracker) holding it rechecks it on the next tip,
251    /// as it does any suspect word; a host holding the state alone feeds
252    /// [`Evidence::Recheck`] before it serves the inclusion.
253    ///
254    /// Every other field returns exactly: a state whose word is not `mined`,
255    /// or that was already suspect with a re-ask pending, restores equal to
256    /// the state that wrote the bytes, and a restored state writes bytes that
257    /// restore equal to it.
258    ///
259    /// # Errors
260    ///
261    /// [`CompactError::UnknownVersion`] for a version this crate does not
262    /// read, [`CompactError::Truncated`], [`CompactError::TrailingBytes`] and
263    /// [`CompactError::Malformed`] for bytes the encoder never writes (a
264    /// `mined` word with no stored proof among them), and
265    /// [`CompactError::Evidence`] for a stored proof that does not hold
266    /// together.
267    pub fn from_compact(bytes: &[u8]) -> Result<Self, CompactError> {
268        let parts = crate::compact::decode(bytes)?;
269        let evidence = parts.evidence.map(Arc::new);
270        let mined = parts.word.is_none();
271        let word = if let Some(word) = parts.word {
272            word
273        } else {
274            let checked = evidence
275                .as_ref()
276                .ok_or(CompactError::Malformed("mined word with no stored proof"))?;
277            Word::Mined(Mined {
278                checked: Arc::clone(checked),
279            })
280        };
281        let mut state = Self {
282            txid: parts.txid,
283            word,
284            evidence,
285            suspect: parts.suspect,
286            hints: parts.hints,
287            last_hint_at: parts.last_hint_at,
288            reask: parts.reask,
289        };
290        if mined {
291            state.suspect = true;
292            state.reask.get_or_insert(Reask::Recheck);
293        }
294        Ok(state)
295    }
296    /// Execute one Lean transition. Proof and header failures retain the word
297    /// and record the re-ask before returning an error to the host.
298    ///
299    /// # Errors
300    ///
301    /// The [`CheckError`] of a proof, competitor, chain event or recheck whose
302    /// check fails; the word is kept and the re-ask recorded.
303    pub fn step<H: Headers + ?Sized>(
304        &mut self,
305        params: &Params,
306        headers: &H,
307        input: Input,
308    ) -> Result<(), CheckError> {
309        match input {
310            Input::Hint(hint) => self.on_hint(hint),
311            Input::Host(action) => self.on_host(params, &action),
312            Input::Evidence(Evidence::Proof(proof)) => self.on_proof(headers, proof)?,
313            Input::Evidence(Evidence::Competitor(proof)) => self.on_competitor(headers, proof)?,
314            Input::Evidence(Evidence::Chain(event)) => self.on_chain(headers, &event)?,
315            Input::Evidence(Evidence::Verdict(verdict)) => self.on_verdict(verdict),
316            Input::Evidence(Evidence::Recheck) => self.recheck(headers)?,
317        }
318        Ok(())
319    }
320
321    fn on_hint(&mut self, hint: Hint) {
322        match (&self.word, &hint.status) {
323            (Word::Built, HintStatus::Accepted) => self.word = Word::Announced,
324            (Word::Built | Word::Announced, HintStatus::Seen) => self.word = Word::Seen,
325            _ => {}
326        }
327        if !agrees(&self.word, &hint.status) {
328            self.reask = Some(Reask::Hint(hint.clone()));
329        }
330        self.last_hint_at = Some(hint.observed);
331        self.hints.insert(0, hint);
332    }
333
334    fn on_host(&mut self, params: &Params, action: &HostAction) {
335        match *action {
336            HostAction::Build if self.word == Word::Unknown => self.word = Word::Built,
337            HostAction::Abandon if matches!(self.word, Word::Unknown | Word::Built) => {
338                self.word = Word::Abandoned;
339            }
340            HostAction::SpendAttempt => self.reask = Some(Reask::Spend),
341            // Lean adds unbounded naturals. Checked addition keeps an
342            // overflowing deadline from becoming prematurely due.
343            HostAction::Tick(now)
344                if matches!(self.word, Word::Announced | Word::Seen)
345                    && self
346                        .last_hint_at
347                        .and_then(|t| t.checked_add(params.age_threshold))
348                        .is_some_and(|deadline| deadline <= now) =>
349            {
350                self.reask = Some(Reask::Age);
351            }
352            _ => {}
353        }
354    }
355
356    fn on_proof<H: Headers + ?Sized>(
357        &mut self,
358        headers: &H,
359        proof: Proof,
360    ) -> Result<(), CheckError> {
361        let height = proof.height();
362        if proof.txid() != self.txid {
363            self.reask = Some(Reask::ProofFailed { height });
364            return Err(CheckError::TxidMismatch);
365        }
366        match crate::evidence::check_snapshot(headers, proof) {
367            Ok(checked) => {
368                let checked = Arc::new(checked);
369                self.word = Word::Mined(Mined {
370                    checked: Arc::clone(&checked),
371                });
372                self.evidence = Some(checked);
373                self.suspect = false;
374                self.reask = None;
375                Ok(())
376            }
377            Err(error) => {
378                self.reask = Some(Reask::ProofFailed { height });
379                Err(error)
380            }
381        }
382    }
383
384    fn on_competitor<H: Headers + ?Sized>(
385        &mut self,
386        headers: &H,
387        proof: Proof,
388    ) -> Result<(), CheckError> {
389        let height = proof.height();
390        let competitor = proof.txid().to_owned();
391        match crate::evidence::check_snapshot(headers, proof) {
392            Ok(_) => {
393                match &mut self.word {
394                    Word::Mined(_) => self.reask = Some(Reask::Recheck),
395                    Word::Conflicted { competitors } => competitors.insert(0, competitor),
396                    _ => {
397                        self.word = Word::Conflicted {
398                            competitors: vec![competitor],
399                        }
400                    }
401                }
402                Ok(())
403            }
404            Err(error) => {
405                self.reask = Some(Reask::ProofFailed { height });
406                Err(error)
407            }
408        }
409    }
410
411    fn recheck<H: Headers + ?Sized>(&mut self, headers: &H) -> Result<(), CheckError> {
412        let Word::Mined(mined) = &self.word else {
413            return Ok(());
414        };
415        let height = mined.height();
416        let answer = headers.header_at(height);
417        match answer {
418            Ok(Some(header)) if header.merkle_root == mined.checked().root() => {
419                self.suspect = false;
420                self.reask = None;
421            }
422            Ok(Some(_)) => self.stale(height),
423            Ok(None) => {
424                self.reask = Some(Reask::Recheck);
425                return Err(CheckError::Unavailable(height));
426            }
427            Err(error) => {
428                self.reask = Some(Reask::Recheck);
429                return Err(CheckError::Headers(error));
430            }
431        }
432        Ok(())
433    }
434
435    fn stale(&mut self, fork_height: Height) {
436        self.word = Word::Stale;
437        self.suspect = false;
438        self.reask = Some(Reask::Reorg { fork_height });
439    }
440
441    pub(crate) fn on_chain<H: Headers + ?Sized>(
442        &mut self,
443        headers: &H,
444        event: &ChainEvent,
445    ) -> Result<(), CheckError> {
446        match event {
447            ChainEvent::Tip { .. } if self.suspect => self.recheck(headers)?,
448            ChainEvent::Fork { height, .. } if self.word.height().is_some_and(|h| *height <= h) => {
449                self.suspect = true;
450                self.reask = Some(Reask::Fork { height: *height });
451            }
452            ChainEvent::Reorg { fork_height, .. }
453                if self.word.height().is_some_and(|h| *fork_height <= h) =>
454            {
455                self.stale(*fork_height);
456            }
457            // A split is an observation, not a reorg: the service still answers
458            // its most-work chain and depth is the host's (bsv-stack-lean
459            // docs/RULINGS-2026-10-12.md:323-329, 2026-10-16 19:3xZ). It writes no word.
460            ChainEvent::Split { fork_height, .. }
461                if self.word.height().is_some_and(|h| *fork_height <= h) =>
462            {
463                self.suspect = true;
464                self.reask = Some(Reask::Fork {
465                    height: *fork_height,
466                });
467            }
468            // A resolved split moves a word only through the checked snapshot.
469            ChainEvent::SplitResolved { fork_height, .. }
470                if self.suspect && self.word.height().is_some_and(|h| *fork_height <= h) =>
471            {
472                self.recheck(headers)?;
473            }
474            ChainEvent::Invalidated { block_hash } => {
475                if let Word::Mined(mined) = &self.word {
476                    if &mined.checked().header().hash == block_hash {
477                        self.stale(mined.height());
478                    }
479                }
480            }
481            _ => {}
482        }
483        Ok(())
484    }
485
486    fn on_verdict(&mut self, verdict: NodeVerdict) {
487        if matches!(self.word, Word::Mined(_)) {
488            self.reask = Some(Reask::Recheck);
489            return;
490        }
491        self.word = match verdict {
492            NodeVerdict::Rejected { reason } => Word::Rejected { reason },
493            NodeVerdict::Conflicted { mut competitors } => {
494                if let Word::Conflicted {
495                    competitors: previous,
496                } = &self.word
497                {
498                    competitors.extend(previous.iter().cloned());
499                }
500                Word::Conflicted { competitors }
501            }
502        };
503    }
504}
505
506fn agrees(word: &Word, status: &HintStatus) -> bool {
507    match (word, status) {
508        (
509            Word::Announced | Word::Seen,
510            HintStatus::Accepted | HintStatus::MempoolPresent { .. },
511        )
512        | (Word::Seen, HintStatus::Seen)
513        | (Word::Stale, HintStatus::StaleBlock)
514        | (Word::Rejected { .. }, HintStatus::Rejected { .. })
515        | (Word::Conflicted { .. }, HintStatus::DoubleSpend { .. }) => true,
516        (Word::Mined(mined), HintStatus::Mined { height }) => mined.height() == *height,
517        _ => false,
518    }
519}