Skip to main content

bsv_tracker/
state.rs

1use crate::{
2    ChainEvent, CheckError, CheckedProof, Headers, Height, Hint, HintStatus, Proof, Timestamp, TxId,
3};
4use std::sync::Arc;
5
6/// Inclusion backed by an immutable checked capability; height is derived.
7///
8/// ```compile_fail
9/// use bsv_tracker::Word;
10/// let _ = Word::Mined { height: 100 };
11/// ```
12#[derive(Clone, Debug, PartialEq, Eq)]
13pub struct Mined {
14    checked: Arc<CheckedProof>,
15}
16impl Mined {
17    /// The height of the checked proof. It cannot differ from the evidence.
18    #[must_use]
19    pub fn height(&self) -> Height {
20        self.checked.height()
21    }
22    /// The checked capability behind the word.
23    #[must_use]
24    pub fn checked(&self) -> &CheckedProof {
25        &self.checked
26    }
27}
28/// The nine words. `Mined` needs an unforgeable checked proof.
29#[derive(Clone, Debug, PartialEq, Eq)]
30pub enum Word {
31    /// Tracked, with no claim from any tier.
32    Unknown,
33    /// The host built the transaction.
34    Built,
35    /// A broadcaster accepted the transaction.
36    Announced,
37    /// A broadcaster reports the transaction seen on the network.
38    Seen,
39    /// Included, backed by a checked proof against the active headers.
40    Mined(Mined),
41    /// Was mined in a block that left the active chain.
42    Stale,
43    /// The host's trusted node rejected the transaction.
44    Rejected {
45        /// The node's reason.
46        reason: String,
47    },
48    /// A competing transaction is mined or named by the host's trusted node.
49    Conflicted {
50        /// The competing txids, the most recent at the front.
51        competitors: Vec<TxId>,
52    },
53    /// The host abandoned the transaction before any broadcaster accepted it.
54    Abandoned,
55}
56impl Word {
57    /// `Tracker.Word.chain`, ignoring the host and hint tiers.
58    #[must_use]
59    pub fn chain(&self) -> Option<&Self> {
60        match self {
61            Self::Mined(_) | Self::Stale | Self::Rejected { .. } | Self::Conflicted { .. } => {
62                Some(self)
63            }
64            _ => None,
65        }
66    }
67    /// A height only when this is an evidenced mined word.
68    #[must_use]
69    pub fn height(&self) -> Option<Height> {
70        if let Self::Mined(m) = self {
71            Some(m.height())
72        } else {
73            None
74        }
75    }
76}
77/// A verdict explicitly supplied by the host's trusted node, never a broadcaster.
78#[derive(Clone, Debug, PartialEq, Eq)]
79pub enum NodeVerdict {
80    /// The node rejected the transaction.
81    Rejected {
82        /// The node's reason.
83        reason: String,
84    },
85    /// The node names competing transactions.
86    Conflicted {
87        /// The competing txids the node names.
88        competitors: Vec<TxId>,
89    },
90}
91/// The inputs that may write a chain word.
92#[derive(Clone, Debug, PartialEq, Eq)]
93pub enum Evidence {
94    /// A proof of this transaction's inclusion.
95    Proof(Proof),
96    /// A proof of a competing transaction's inclusion.
97    Competitor(Proof),
98    /// A chain event from the host's header feed.
99    Chain(ChainEvent),
100    /// A verdict from the host's trusted node.
101    Verdict(NodeVerdict),
102    /// Recheck a mined word against the current headers.
103    Recheck,
104}
105/// Acts supplied by the host, including its clock and spend guard.
106#[derive(Clone, Debug, PartialEq, Eq)]
107pub enum HostAction {
108    /// The host built the transaction.
109    Build,
110    /// The host abandons the transaction.
111    Abandon,
112    /// The host is about to spend an output of the transaction.
113    SpendAttempt,
114    /// The host's clock reads this time.
115    Tick(Timestamp),
116}
117/// One input to `Tracker.step`.
118#[derive(Clone, Debug, PartialEq, Eq)]
119pub enum Input {
120    /// Evidence that may write a chain word.
121    Evidence(Evidence),
122    /// A broadcaster's hint, which never writes a chain word.
123    Hint(Hint),
124    /// An act of the host.
125    Host(HostAction),
126}
127/// A value for the host to execute; the crate never fetches anything.
128#[derive(Clone, Debug, PartialEq, Eq)]
129pub enum Reask {
130    /// The mined word went stale; fetch a fresh proof.
131    Reorg {
132        /// The lowest replaced height, or the mined height when its block was
133        /// invalidated or its root no longer matches the active header.
134        fork_height: Height,
135    },
136    /// A fork, or a version-2 split, at or below the mined height; the word
137    /// is suspect. A split reuses this re-ask with its `forkHeight`: the host's
138    /// act, a recheck at its depth rule, is the same for both.
139    Fork {
140        /// The height at which the branches diverge (a split's `fork_height`).
141        height: Height,
142    },
143    /// A hint that disagrees with the word; reconcile it.
144    Hint(Hint),
145    /// A proof or competitor proof failed its check.
146    ProofFailed {
147        /// The height the failed proof named.
148        height: Height,
149    },
150    /// Recheck the mined word against the current headers.
151    Recheck,
152    /// The last hint is older than the age threshold.
153    Age,
154    /// The host is about to spend; recheck before it does.
155    Spend,
156}
157/// Host selected scheduling parameters.
158#[derive(Clone, Debug, PartialEq, Eq)]
159pub struct Params {
160    /// Seconds after the last hint before an announced or seen word is re-asked.
161    pub age_threshold: Timestamp,
162}
163/// The complete private state of one transaction.
164#[derive(Clone, Debug, PartialEq, Eq)]
165pub struct State {
166    txid: TxId,
167    word: Word,
168    evidence: Option<Arc<CheckedProof>>,
169    suspect: bool,
170    hints: Vec<Hint>,
171    last_hint_at: Option<Timestamp>,
172    reask: Option<Reask>,
173}
174impl State {
175    /// Birth with no chain claim or stored evidence, as in `Tracker.State.init`.
176    pub fn new(txid: impl Into<TxId>) -> Self {
177        Self {
178            txid: txid.into().to_ascii_lowercase(),
179            word: Word::Unknown,
180            evidence: None,
181            suspect: false,
182            hints: vec![],
183            last_hint_at: None,
184            reask: None,
185        }
186    }
187    /// The host's tracked transaction.
188    #[must_use]
189    pub fn txid(&self) -> &str {
190        &self.txid
191    }
192    /// The current word. It is immutable outside the transition function.
193    #[must_use]
194    pub fn word(&self) -> &Word {
195        &self.word
196    }
197    /// The retained record, including when the word has become stale.
198    #[must_use]
199    pub fn evidence(&self) -> Option<&CheckedProof> {
200        self.evidence.as_deref()
201    }
202    /// Whether an unresolved fork requires a header recheck before acting.
203    #[must_use]
204    pub fn suspect(&self) -> bool {
205        self.suspect
206    }
207    /// Observations in newest received order, matching the Lean list.
208    #[must_use]
209    pub fn hints(&self) -> &[Hint] {
210        &self.hints
211    }
212    /// Time of the most recently received hint, even for out of order clocks.
213    #[must_use]
214    pub fn last_hint_at(&self) -> Option<Timestamp> {
215        self.last_hint_at
216    }
217    /// The next re-ask, which the host must execute and feed back.
218    #[must_use]
219    pub fn reask(&self) -> Option<&Reask> {
220        self.reask.as_ref()
221    }
222    /// Execute one Lean transition. Proof and header failures retain the word
223    /// and record the re-ask before returning an error to the host.
224    ///
225    /// # Errors
226    ///
227    /// The [`CheckError`] of a proof, competitor, chain event or recheck whose
228    /// check fails; the word is kept and the re-ask recorded.
229    pub fn step<H: Headers + ?Sized>(
230        &mut self,
231        params: &Params,
232        headers: &H,
233        input: Input,
234    ) -> Result<(), CheckError> {
235        match input {
236            Input::Hint(hint) => self.on_hint(hint),
237            Input::Host(action) => self.on_host(params, &action),
238            Input::Evidence(Evidence::Proof(proof)) => self.on_proof(headers, proof)?,
239            Input::Evidence(Evidence::Competitor(proof)) => self.on_competitor(headers, proof)?,
240            Input::Evidence(Evidence::Chain(event)) => self.on_chain(headers, &event)?,
241            Input::Evidence(Evidence::Verdict(verdict)) => self.on_verdict(verdict),
242            Input::Evidence(Evidence::Recheck) => self.recheck(headers)?,
243        }
244        Ok(())
245    }
246
247    fn on_hint(&mut self, hint: Hint) {
248        match (&self.word, &hint.status) {
249            (Word::Built, HintStatus::Accepted) => self.word = Word::Announced,
250            (Word::Built | Word::Announced, HintStatus::Seen) => self.word = Word::Seen,
251            _ => {}
252        }
253        if !agrees(&self.word, &hint.status) {
254            self.reask = Some(Reask::Hint(hint.clone()));
255        }
256        self.last_hint_at = Some(hint.observed);
257        self.hints.insert(0, hint);
258    }
259
260    fn on_host(&mut self, params: &Params, action: &HostAction) {
261        match *action {
262            HostAction::Build if self.word == Word::Unknown => self.word = Word::Built,
263            HostAction::Abandon if matches!(self.word, Word::Unknown | Word::Built) => {
264                self.word = Word::Abandoned;
265            }
266            HostAction::SpendAttempt => self.reask = Some(Reask::Spend),
267            // Lean adds unbounded naturals. Checked addition keeps an
268            // overflowing deadline from becoming prematurely due.
269            HostAction::Tick(now)
270                if matches!(self.word, Word::Announced | Word::Seen)
271                    && self
272                        .last_hint_at
273                        .and_then(|t| t.checked_add(params.age_threshold))
274                        .is_some_and(|deadline| deadline <= now) =>
275            {
276                self.reask = Some(Reask::Age);
277            }
278            _ => {}
279        }
280    }
281
282    fn on_proof<H: Headers + ?Sized>(
283        &mut self,
284        headers: &H,
285        proof: Proof,
286    ) -> Result<(), CheckError> {
287        let height = proof.height();
288        if proof.txid() != self.txid {
289            self.reask = Some(Reask::ProofFailed { height });
290            return Err(CheckError::TxidMismatch);
291        }
292        match crate::evidence::check_snapshot(headers, proof) {
293            Ok(checked) => {
294                let checked = Arc::new(checked);
295                self.word = Word::Mined(Mined {
296                    checked: Arc::clone(&checked),
297                });
298                self.evidence = Some(checked);
299                self.suspect = false;
300                self.reask = None;
301                Ok(())
302            }
303            Err(error) => {
304                self.reask = Some(Reask::ProofFailed { height });
305                Err(error)
306            }
307        }
308    }
309
310    fn on_competitor<H: Headers + ?Sized>(
311        &mut self,
312        headers: &H,
313        proof: Proof,
314    ) -> Result<(), CheckError> {
315        let height = proof.height();
316        let competitor = proof.txid().to_owned();
317        match crate::evidence::check_snapshot(headers, proof) {
318            Ok(_) => {
319                match &mut self.word {
320                    Word::Mined(_) => self.reask = Some(Reask::Recheck),
321                    Word::Conflicted { competitors } => competitors.insert(0, competitor),
322                    _ => {
323                        self.word = Word::Conflicted {
324                            competitors: vec![competitor],
325                        }
326                    }
327                }
328                Ok(())
329            }
330            Err(error) => {
331                self.reask = Some(Reask::ProofFailed { height });
332                Err(error)
333            }
334        }
335    }
336
337    fn recheck<H: Headers + ?Sized>(&mut self, headers: &H) -> Result<(), CheckError> {
338        let Word::Mined(mined) = &self.word else {
339            return Ok(());
340        };
341        let height = mined.height();
342        let answer = headers.header_at(height);
343        match answer {
344            Ok(Some(header)) if header.merkle_root == mined.checked().root() => {
345                self.suspect = false;
346                self.reask = None;
347            }
348            Ok(Some(_)) => self.stale(height),
349            Ok(None) => {
350                self.reask = Some(Reask::Recheck);
351                return Err(CheckError::Unavailable(height));
352            }
353            Err(error) => {
354                self.reask = Some(Reask::Recheck);
355                return Err(CheckError::Headers(error));
356            }
357        }
358        Ok(())
359    }
360
361    fn stale(&mut self, fork_height: Height) {
362        self.word = Word::Stale;
363        self.suspect = false;
364        self.reask = Some(Reask::Reorg { fork_height });
365    }
366
367    pub(crate) fn on_chain<H: Headers + ?Sized>(
368        &mut self,
369        headers: &H,
370        event: &ChainEvent,
371    ) -> Result<(), CheckError> {
372        match event {
373            ChainEvent::Tip { .. } if self.suspect => self.recheck(headers)?,
374            ChainEvent::Fork { height, .. } if self.word.height().is_some_and(|h| *height <= h) => {
375                self.suspect = true;
376                self.reask = Some(Reask::Fork { height: *height });
377            }
378            ChainEvent::Reorg { fork_height, .. }
379                if self.word.height().is_some_and(|h| *fork_height <= h) =>
380            {
381                self.stale(*fork_height);
382            }
383            // A split is an observation, not a reorg: the service still answers
384            // its most-work chain and depth is the host's (bsv-stack-lean
385            // docs/RULINGS-2026-10-12.md:323-329, 2026-10-16 19:3xZ). It writes no word.
386            ChainEvent::Split { fork_height, .. }
387                if self.word.height().is_some_and(|h| *fork_height <= h) =>
388            {
389                self.suspect = true;
390                self.reask = Some(Reask::Fork {
391                    height: *fork_height,
392                });
393            }
394            // A resolved split moves a word only through the checked snapshot.
395            ChainEvent::SplitResolved { fork_height, .. }
396                if self.suspect && self.word.height().is_some_and(|h| *fork_height <= h) =>
397            {
398                self.recheck(headers)?;
399            }
400            ChainEvent::Invalidated { block_hash } => {
401                if let Word::Mined(mined) = &self.word {
402                    if &mined.checked().header().hash == block_hash {
403                        self.stale(mined.height());
404                    }
405                }
406            }
407            _ => {}
408        }
409        Ok(())
410    }
411
412    fn on_verdict(&mut self, verdict: NodeVerdict) {
413        if matches!(self.word, Word::Mined(_)) {
414            self.reask = Some(Reask::Recheck);
415            return;
416        }
417        self.word = match verdict {
418            NodeVerdict::Rejected { reason } => Word::Rejected { reason },
419            NodeVerdict::Conflicted { mut competitors } => {
420                if let Word::Conflicted {
421                    competitors: previous,
422                } = &self.word
423                {
424                    competitors.extend(previous.iter().cloned());
425                }
426                Word::Conflicted { competitors }
427            }
428        };
429    }
430}
431
432fn agrees(word: &Word, status: &HintStatus) -> bool {
433    match (word, status) {
434        (Word::Announced | Word::Seen, HintStatus::Accepted)
435        | (Word::Seen, HintStatus::Seen)
436        | (Word::Stale, HintStatus::StaleBlock)
437        | (Word::Rejected { .. }, HintStatus::Rejected { .. })
438        | (Word::Conflicted { .. }, HintStatus::DoubleSpend { .. }) => true,
439        (Word::Mined(mined), HintStatus::Mined { height }) => mined.height() == *height,
440        _ => false,
441    }
442}