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}