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    pub fn height(&self) -> Height {
19        self.checked.height()
20    }
21    /// The checked capability behind the word.
22    pub fn checked(&self) -> &CheckedProof {
23        &self.checked
24    }
25}
26/// The nine words. `Mined` needs an unforgeable checked proof.
27#[derive(Clone, Debug, PartialEq, Eq)]
28pub enum Word {
29    Unknown,
30    Built,
31    Announced,
32    Seen,
33    Mined(Mined),
34    Stale,
35    Rejected { reason: String },
36    Conflicted { competitors: Vec<TxId> },
37    Abandoned,
38}
39impl Word {
40    /// `Tracker.Word.chain`, ignoring the host and hint tiers.
41    pub fn chain(&self) -> Option<&Self> {
42        match self {
43            Self::Mined(_) | Self::Stale | Self::Rejected { .. } | Self::Conflicted { .. } => {
44                Some(self)
45            }
46            _ => None,
47        }
48    }
49    /// A height only when this is an evidenced mined word.
50    pub fn height(&self) -> Option<Height> {
51        if let Self::Mined(m) = self {
52            Some(m.height())
53        } else {
54            None
55        }
56    }
57}
58/// A verdict explicitly supplied by the host's trusted node, never a broadcaster.
59#[derive(Clone, Debug, PartialEq, Eq)]
60pub enum NodeVerdict {
61    Rejected { reason: String },
62    Conflicted { competitors: Vec<TxId> },
63}
64/// The inputs that may write a chain word.
65#[derive(Clone, Debug, PartialEq, Eq)]
66pub enum Evidence {
67    Proof(Proof),
68    Competitor(Proof),
69    Chain(ChainEvent),
70    Verdict(NodeVerdict),
71    Recheck,
72}
73/// Acts supplied by the host, including its clock and spend guard.
74#[derive(Clone, Debug, PartialEq, Eq)]
75pub enum HostAction {
76    Build,
77    Abandon,
78    SpendAttempt,
79    Tick(Timestamp),
80}
81/// One input to `Tracker.step`.
82#[derive(Clone, Debug, PartialEq, Eq)]
83pub enum Input {
84    Evidence(Evidence),
85    Hint(Hint),
86    Host(HostAction),
87}
88/// A value for the host to execute; the crate never fetches anything.
89#[derive(Clone, Debug, PartialEq, Eq)]
90pub enum Reask {
91    Reorg { fork_height: Height },
92    Fork { height: Height },
93    Hint(Hint),
94    ProofFailed { height: Height },
95    Recheck,
96    Age,
97    Spend,
98}
99/// Host selected scheduling parameters.
100#[derive(Clone, Debug, PartialEq, Eq)]
101pub struct Params {
102    pub age_threshold: Timestamp,
103}
104/// The complete private state of one transaction.
105#[derive(Clone, Debug, PartialEq, Eq)]
106pub struct State {
107    txid: TxId,
108    word: Word,
109    evidence: Option<Arc<CheckedProof>>,
110    suspect: bool,
111    hints: Vec<Hint>,
112    last_hint_at: Option<Timestamp>,
113    reask: Option<Reask>,
114}
115impl State {
116    /// Birth with no chain claim or stored evidence, as in `Tracker.State.init`.
117    pub fn new(txid: impl Into<TxId>) -> Self {
118        Self {
119            txid: txid.into().to_ascii_lowercase(),
120            word: Word::Unknown,
121            evidence: None,
122            suspect: false,
123            hints: vec![],
124            last_hint_at: None,
125            reask: None,
126        }
127    }
128    /// The host's tracked transaction.
129    pub fn txid(&self) -> &str {
130        &self.txid
131    }
132    /// The current word. It is immutable outside the transition function.
133    pub fn word(&self) -> &Word {
134        &self.word
135    }
136    /// The retained record, including when the word has become stale.
137    pub fn evidence(&self) -> Option<&CheckedProof> {
138        self.evidence.as_deref()
139    }
140    /// Whether an unresolved fork requires a header recheck before acting.
141    pub fn suspect(&self) -> bool {
142        self.suspect
143    }
144    /// Observations in newest received order, matching the Lean list.
145    pub fn hints(&self) -> &[Hint] {
146        &self.hints
147    }
148    /// Time of the most recently received hint, even for out of order clocks.
149    pub fn last_hint_at(&self) -> Option<Timestamp> {
150        self.last_hint_at
151    }
152    /// The next re-ask, which the host must execute and feed back.
153    pub fn reask(&self) -> Option<&Reask> {
154        self.reask.as_ref()
155    }
156    /// Execute one Lean transition. Proof and header failures retain the word
157    /// and record the re-ask before returning an error to the host.
158    pub fn step<H: Headers + ?Sized>(
159        &mut self,
160        params: &Params,
161        headers: &H,
162        input: Input,
163    ) -> Result<(), CheckError> {
164        match input {
165            Input::Hint(hint) => self.on_hint(hint),
166            Input::Host(action) => self.on_host(params, action),
167            Input::Evidence(Evidence::Proof(proof)) => self.on_proof(headers, proof)?,
168            Input::Evidence(Evidence::Competitor(proof)) => self.on_competitor(headers, proof)?,
169            Input::Evidence(Evidence::Chain(event)) => self.on_chain(headers, &event)?,
170            Input::Evidence(Evidence::Verdict(verdict)) => self.on_verdict(verdict),
171            Input::Evidence(Evidence::Recheck) => self.recheck(headers)?,
172        }
173        Ok(())
174    }
175
176    fn on_hint(&mut self, hint: Hint) {
177        match (&self.word, &hint.status) {
178            (Word::Built, HintStatus::Accepted) => self.word = Word::Announced,
179            (Word::Built | Word::Announced, HintStatus::Seen) => self.word = Word::Seen,
180            _ => {}
181        }
182        if !agrees(&self.word, &hint.status) {
183            self.reask = Some(Reask::Hint(hint.clone()));
184        }
185        self.last_hint_at = Some(hint.observed);
186        self.hints.insert(0, hint);
187    }
188
189    fn on_host(&mut self, params: &Params, action: HostAction) {
190        match action {
191            HostAction::Build if self.word == Word::Unknown => self.word = Word::Built,
192            HostAction::Abandon if matches!(self.word, Word::Unknown | Word::Built) => {
193                self.word = Word::Abandoned
194            }
195            HostAction::SpendAttempt => self.reask = Some(Reask::Spend),
196            // Lean adds unbounded naturals. Checked addition keeps an
197            // overflowing deadline from becoming prematurely due.
198            HostAction::Tick(now)
199                if matches!(self.word, Word::Announced | Word::Seen)
200                    && self
201                        .last_hint_at
202                        .and_then(|t| t.checked_add(params.age_threshold))
203                        .is_some_and(|deadline| deadline <= now) =>
204            {
205                self.reask = Some(Reask::Age);
206            }
207            _ => {}
208        }
209    }
210
211    fn on_proof<H: Headers + ?Sized>(
212        &mut self,
213        headers: &H,
214        proof: Proof,
215    ) -> Result<(), CheckError> {
216        let height = proof.height();
217        if proof.txid() != self.txid {
218            self.reask = Some(Reask::ProofFailed { height });
219            return Err(CheckError::TxidMismatch);
220        }
221        match crate::evidence::check_snapshot(headers, proof) {
222            Ok(checked) => {
223                let checked = Arc::new(checked);
224                self.word = Word::Mined(Mined {
225                    checked: Arc::clone(&checked),
226                });
227                self.evidence = Some(checked);
228                self.suspect = false;
229                self.reask = None;
230                Ok(())
231            }
232            Err(error) => {
233                self.reask = Some(Reask::ProofFailed { height });
234                Err(error)
235            }
236        }
237    }
238
239    fn on_competitor<H: Headers + ?Sized>(
240        &mut self,
241        headers: &H,
242        proof: Proof,
243    ) -> Result<(), CheckError> {
244        let height = proof.height();
245        let competitor = proof.txid().to_owned();
246        match crate::evidence::check_snapshot(headers, proof) {
247            Ok(_) => {
248                match &mut self.word {
249                    Word::Mined(_) => self.reask = Some(Reask::Recheck),
250                    Word::Conflicted { competitors } => competitors.insert(0, competitor),
251                    _ => {
252                        self.word = Word::Conflicted {
253                            competitors: vec![competitor],
254                        }
255                    }
256                }
257                Ok(())
258            }
259            Err(error) => {
260                self.reask = Some(Reask::ProofFailed { height });
261                Err(error)
262            }
263        }
264    }
265
266    fn recheck<H: Headers + ?Sized>(&mut self, headers: &H) -> Result<(), CheckError> {
267        let Word::Mined(mined) = &self.word else {
268            return Ok(());
269        };
270        let height = mined.height();
271        let answer = headers.header_at(height);
272        match answer {
273            Ok(Some(header)) if header.merkle_root == mined.checked().root() => {
274                self.suspect = false;
275                self.reask = None;
276            }
277            Ok(Some(_)) => self.stale(height),
278            Ok(None) => {
279                self.reask = Some(Reask::Recheck);
280                return Err(CheckError::Unavailable(height));
281            }
282            Err(error) => {
283                self.reask = Some(Reask::Recheck);
284                return Err(CheckError::Headers(error));
285            }
286        }
287        Ok(())
288    }
289
290    fn stale(&mut self, fork_height: Height) {
291        self.word = Word::Stale;
292        self.suspect = false;
293        self.reask = Some(Reask::Reorg { fork_height });
294    }
295
296    pub(crate) fn on_chain<H: Headers + ?Sized>(
297        &mut self,
298        headers: &H,
299        event: &ChainEvent,
300    ) -> Result<(), CheckError> {
301        match event {
302            ChainEvent::Tip { .. } if self.suspect => self.recheck(headers)?,
303            ChainEvent::Fork { height, .. } if self.word.height().is_some_and(|h| *height <= h) => {
304                self.suspect = true;
305                self.reask = Some(Reask::Fork { height: *height });
306            }
307            ChainEvent::Reorg { fork_height, .. }
308                if self.word.height().is_some_and(|h| *fork_height <= h) =>
309            {
310                self.stale(*fork_height)
311            }
312            ChainEvent::Invalidated { block_hash } => {
313                if let Word::Mined(mined) = &self.word {
314                    if &mined.checked().header().hash == block_hash {
315                        self.stale(mined.height());
316                    }
317                }
318            }
319            _ => {}
320        }
321        Ok(())
322    }
323
324    fn on_verdict(&mut self, verdict: NodeVerdict) {
325        if matches!(self.word, Word::Mined(_)) {
326            self.reask = Some(Reask::Recheck);
327            return;
328        }
329        self.word = match verdict {
330            NodeVerdict::Rejected { reason } => Word::Rejected { reason },
331            NodeVerdict::Conflicted { mut competitors } => {
332                if let Word::Conflicted {
333                    competitors: previous,
334                } = &self.word
335                {
336                    competitors.extend(previous.iter().cloned());
337                }
338                Word::Conflicted { competitors }
339            }
340        };
341    }
342}
343
344fn agrees(word: &Word, status: &HintStatus) -> bool {
345    match (word, status) {
346        (Word::Announced | Word::Seen, HintStatus::Accepted)
347        | (Word::Seen, HintStatus::Seen)
348        | (Word::Stale, HintStatus::StaleBlock)
349        | (Word::Rejected { .. }, HintStatus::Rejected { .. })
350        | (Word::Conflicted { .. }, HintStatus::DoubleSpend { .. }) => true,
351        (Word::Mined(mined), HintStatus::Mined { height }) => mined.height() == *height,
352        _ => false,
353    }
354}