bsv-tracker 0.7.0

A transaction evidence state machine with host supplied headers, proofs, hints and time
Documentation
//! Four randomized statements named for the Lean theorems they exercise.
//! 256 reproducible traces per theorem, using an explicit ChaCha seed and no
//! failure persistence, network, clock or storage.
// The lint bar binds the library; a test may unwrap, and its fixtures fail loudly
// (bsv-stack-lean #134).
#![allow(
    clippy::pedantic,
    clippy::unwrap_used,
    clippy::expect_used,
    clippy::panic
)]
mod common;
use bsv_tracker::*;
use common::*;
use proptest::prelude::*;
use proptest::test_runner::{Config, RngAlgorithm, TestRng, TestRunner};

fn runner() -> TestRunner {
    TestRunner::new_with_rng(
        Config {
            cases: 256,
            failure_persistence: None,
            ..Config::default()
        },
        TestRng::deterministic_rng(RngAlgorithm::ChaCha),
    )
}

fn trace() -> impl Strategy<Value = Vec<(u8, u32, u64, u8, u8)>> {
    prop::collection::vec(
        (0u8..16, 1u32..12, 20u64..40, any::<u8>(), any::<u8>()),
        1..80,
    )
}

fn status(code: u8, height: u32, observed: u64) -> Input {
    hint(
        match code % 9 {
            0 => HintStatus::Accepted,
            1 => HintStatus::Seen,
            2 => HintStatus::Mined { height },
            3 => HintStatus::StaleBlock,
            4 => HintStatus::Rejected {
                reason: format!("reason-{code}"),
            },
            5 => HintStatus::DoubleSpend {
                competitors: vec![hash(2)],
            },
            6 => HintStatus::OrphanMempool,
            7 => HintStatus::MempoolPresent {
                source: format!("courier-{code}"),
            },
            _ => HintStatus::Unknown,
        },
        observed,
    )
}

fn host(code: u8, observed: u64) -> Input {
    Input::Host(match code % 4 {
        0 => HostAction::Build,
        1 => HostAction::Abandon,
        2 => HostAction::SpendAttempt,
        _ => HostAction::Tick(observed),
    })
}

/// Each step supplies exactly one immutable snapshot, as in Tracker.Trace.
fn input(h: &mut TestHeaders, code: u8, height: u32, sibling: u64) -> Input {
    h.fault = false;
    let own = proof(&hash(1), height, sibling);
    let other = proof(&hash(2), height, sibling);
    Input::Evidence(match code {
        0 => {
            h.insert(&own, u64::from(height));
            Evidence::Proof(own)
        }
        1 => {
            h.insert(&other, u64::from(height));
            Evidence::Proof(own)
        }
        2 => {
            h.fault = true;
            Evidence::Proof(own)
        }
        3 => {
            h.insert(&own, u64::from(height));
            Evidence::Recheck
        }
        4 => {
            h.insert(&other, u64::from(height));
            Evidence::Competitor(other)
        }
        5 => {
            h.insert(&own, u64::from(height));
            Evidence::Competitor(other)
        }
        6 => Evidence::Chain(reorg(height)),
        7 => Evidence::Chain(fork(height)),
        8 => Evidence::Chain(tip(height, 90)),
        9 => Evidence::Chain(ChainEvent::Invalidated {
            block_hash: hash(u64::from(height)),
        }),
        10 => Evidence::Chain(ChainEvent::Frozen {
            outpoint: Outpoint {
                txid: hash(1),
                vout: 0,
            },
        }),
        11 => Evidence::Chain(ChainEvent::TipAge {
            seconds: sibling,
            tip: event_header(height, 90),
        }),
        12 => Evidence::Verdict(NodeVerdict::Rejected {
            reason: format!("node-{height}"),
        }),
        13 => Evidence::Verdict(NodeVerdict::Conflicted {
            competitors: vec![hash(sibling)],
        }),
        14 => {
            h.headers.clear();
            Evidence::Recheck
        }
        _ => {
            h.fault = true;
            Evidence::Recheck
        }
    })
}

#[test]
fn mined_implies_checked_proof() {
    runner()
        .run(&trace(), |trace| {
            let mut s = State::new(hash(1));
            let mut h = TestHeaders::default();
            for (code, height, sibling, noise, time) in trace {
                let i = input(&mut h, code, height, sibling);
                let _ = s.step(&params(), &h, i);
                step(&mut s, &h, status(noise, height, u64::from(time)));
                step(&mut s, &h, host(noise, u64::from(time)));
                if let Word::Mined(mined) = s.word() {
                    let checked = s.evidence().unwrap();
                    prop_assert_eq!(mined.height(), checked.proof().height());
                    prop_assert_eq!(mined.checked(), checked);
                    prop_assert_eq!(checked.proof().txid(), s.txid());
                    prop_assert_eq!(
                        checked.proof().path().compute_root(Some(s.txid())).unwrap(),
                        checked.header().merkle_root.clone()
                    );
                    prop_assert!(checked.depth() >= 1);
                }
            }
            Ok(())
        })
        .unwrap();
}

// The ruling (5) of 2026-10-20 19:4xZ (bsv-stack-lean docs/RULINGS-2026-10-12.md),
// theorem `stale_iff_header_inactive` in place of (ii) `reorg_reasks_at_or_above`:
// a `mined` word is `stale` when, and only when, its stored proof's root is not
// the active header's root at its height. "At or above the fork point" stays as
// the bound of the recheck: the reorg selects exactly those words, and only the
// ones whose root the host's headers moved go stale with `Reask::Reorg`.
#[test]
fn stale_iff_header_inactive() {
    let states = prop::collection::vec((1u32..20, 0u8..5), 1..40);
    let moved = prop::collection::vec(any::<bool>(), 20);
    runner()
        .run(
            &(states, 0u32..22, moved),
            |(states, fork_height, moved)| {
                let mut tracker = Tracker::new(params());
                let mut h = TestHeaders::default();
                let mut before = vec![];
                for (n, (height, kind)) in states.into_iter().enumerate() {
                    let txid = hash(n as u64 + 100);
                    tracker.track(&txid);
                    match kind {
                        0 | 1 => {
                            let p = proof(&txid, height, 1_000);
                            h.insert(&p, u64::from(height));
                            tracker
                                .apply(&txid, &h, Input::Evidence(Evidence::Proof(p)))
                                .unwrap();
                        }
                        2 => {
                            tracker
                                .apply(&txid, &h, Input::Host(HostAction::Build))
                                .unwrap();
                        }
                        3 => {
                            tracker
                                .apply(
                                    &txid,
                                    &h,
                                    Input::Evidence(Evidence::Verdict(NodeVerdict::Rejected {
                                        reason: "node".into(),
                                    })),
                                )
                                .unwrap();
                        }
                        _ => {}
                    }
                    before.push(tracker.get(&txid).unwrap().clone());
                }
                // The new branch: some heights at or above the fork carry new roots.
                for height in fork_height.max(1)..20 {
                    if moved[height as usize] {
                        h.move_root(height, u64::from(height) + 500);
                    }
                }
                let affected = tracker.on_chain(&h, reorg(fork_height));
                let expected = before
                    .iter()
                    .filter(|s| s.word().height().is_some_and(|h| h >= fork_height))
                    .count();
                prop_assert_eq!(affected.len(), expected);
                prop_assert!(affected.len() <= before.len());
                for old in before {
                    let after = tracker.get(old.txid()).unwrap();
                    let Some(height) = old.word().height().filter(|h| *h >= fork_height) else {
                        prop_assert_eq!(after, &old);
                        continue;
                    };
                    prop_assert!(affected
                        .iter()
                        .any(|u| u.txid == old.txid() && u.result.is_ok()));
                    prop_assert_eq!(after.evidence(), old.evidence());
                    prop_assert!(!after.suspect());
                    let active = &h.headers[&height].merkle_root;
                    if active == old.evidence().unwrap().root() {
                        prop_assert_eq!(after, &old);
                    } else {
                        prop_assert_eq!(after.word(), &Word::Stale);
                        let reorg = Reask::Reorg { fork_height };
                        prop_assert_eq!(after.reask(), Some(&reorg));
                    }
                }
                Ok(())
            },
        )
        .unwrap();
}

#[test]
fn hint_changes_no_chain_word() {
    runner()
        .run(&trace(), |trace| {
            let mut s = State::new(hash(1));
            let mut h = TestHeaders::default();
            for (code, height, sibling, noise, time) in trace {
                let i = input(&mut h, code, height, sibling);
                let _ = s.step(&params(), &h, i);
                step(&mut s, &h, host(noise, u64::from(time)));
                let before = s.clone();
                step(&mut s, &h, status(noise, height, u64::from(time)));
                prop_assert_eq!(s.word().chain(), before.word().chain());
                prop_assert_eq!(s.evidence(), before.evidence());
                prop_assert_eq!(s.suspect(), before.suspect());
                if s.word() != before.word() {
                    prop_assert!(matches!(
                        (before.word(), s.word()),
                        (Word::Built, Word::Announced | Word::Seen) | (Word::Announced, Word::Seen)
                    ));
                }
            }
            Ok(())
        })
        .unwrap();
}

#[test]
fn same_evidence_same_word() {
    runner()
        .run(&trace(), |trace| {
            let mut a = State::new(hash(1));
            let mut b = a.clone();
            let mut h = TestHeaders::default();
            for (code, height, sibling, noise_a, noise_b) in trace {
                let i = input(&mut h, code, height, sibling);
                step(&mut a, &h, status(noise_a, height, u64::from(noise_a)));
                step(&mut b, &h, status(noise_b, height, u64::from(noise_b)));
                step(&mut a, &h, host(noise_a, u64::from(noise_a)));
                step(&mut b, &h, host(noise_b, u64::from(noise_b)));
                let result_a = a.step(&params(), &h, i.clone());
                let result_b = b.step(&params(), &h, i);
                prop_assert_eq!(result_a, result_b);
                prop_assert_eq!(a.word().chain(), b.word().chain());
                prop_assert_eq!(a.evidence(), b.evidence());
                prop_assert_eq!(a.suspect(), b.suspect());
            }
            Ok(())
        })
        .unwrap();
}