#![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};
use std::cell::Cell;
fn runner() -> TestRunner {
TestRunner::new_with_rng(
Config {
cases: 256,
failure_persistence: None,
..Config::default()
},
TestRng::deterministic_rng(RngAlgorithm::ChaCha),
)
}
fn status(code: u8, height: u32, n: u64) -> Hint {
let status = match code % 11 {
0 => HintStatus::Accepted,
1 => HintStatus::Seen,
2 => HintStatus::Mined { height },
3 => HintStatus::StaleBlock,
4 => HintStatus::Rejected {
reason: format!("reason \u{e9} {n}"),
},
5 => HintStatus::DoubleSpend {
competitors: vec![hash(n), hash(n).to_ascii_uppercase(), "short".into()],
},
6 => HintStatus::OrphanMempool,
7 => HintStatus::Unknown,
8 => HintStatus::MempoolPresent {
source: format!("courier-{n}"),
},
9 => {
let body =
format!(r#"{{"txStatus":"MINED","blockHeight":{height},"extraInfo":"x{n}"}}"#);
return Verdict::from_arc_body(&hash(1), body.as_bytes())
.unwrap()
.into_hint("arc", n);
}
_ => {
let body = format!(
r#"{{"txStatus":"SEEN_ON_NETWORK","competingTxs":["{}"]}}"#,
hash(n)
);
return Verdict::from_arc_body(&hash(1), body.as_bytes())
.unwrap()
.into_hint("arc", n);
}
};
Hint::new(format!("source-{code}"), status, n)
}
fn input(h: &mut TestHeaders, code: u8, height: u32, n: u64) -> Input {
h.fault = false;
let own = proof(&hash(1), height, n);
let other = proof(&hash(2), height, n);
match code % 14 {
0 | 1 => {
h.insert(&own, u64::from(height));
Input::Evidence(Evidence::Proof(own))
}
2 => Input::Evidence(Evidence::Proof(own)),
3 => {
h.insert(&other, u64::from(height));
Input::Evidence(Evidence::Competitor(other))
}
4 => Input::Evidence(Evidence::Chain(reorg(height))),
5 => Input::Evidence(Evidence::Chain(fork(height))),
6 => Input::Evidence(Evidence::Chain(split(height))),
7 => Input::Evidence(Evidence::Verdict(NodeVerdict::Rejected {
reason: format!("node-{n}"),
})),
8 => Input::Evidence(Evidence::Verdict(NodeVerdict::Conflicted {
competitors: vec![hash(n), format!("Not-An-Id-{n}")],
})),
9 => Input::Host(HostAction::Build),
10 => Input::Host(HostAction::SpendAttempt),
11 => Input::Host(HostAction::Tick(n + 1_000)),
12 => Input::Host(HostAction::Abandon),
_ => Input::Hint(status(n as u8, height, n)),
}
}
fn states() -> impl Strategy<Value = Vec<(u8, u32, u64, u8)>> {
prop::collection::vec(
(any::<u8>(), 1u32..300_000, 0u64..1 << 40, any::<u8>()),
0..40,
)
}
fn build(trace: Vec<(u8, u32, u64, u8)>) -> State {
let mut s = State::new(hash(1));
let mut h = TestHeaders::default();
for (code, height, n, hint) in trace {
let i = input(&mut h, code, height, n);
let _ = s.step(¶ms(), &h, i);
if hint % 3 == 0 {
let _ = s.step(¶ms(), &h, Input::Hint(status(hint, height, n)));
}
}
s
}
#[test]
fn compact_round_trip_over_256_random_states() {
let mined = Cell::new(0);
runner()
.run(&states(), |trace| {
let s = build(trace);
let bytes = s.to_compact();
prop_assert_eq!(bytes[0], COMPACT_VERSION);
let restored = State::from_compact(&bytes).unwrap();
if let Word::Mined(m) = s.word() {
mined.set(mined.get() + 1);
prop_assert_eq!(restored.word(), s.word());
prop_assert_eq!(restored.evidence(), s.evidence());
prop_assert_eq!(restored.evidence(), Some(m.checked()));
prop_assert!(restored.suspect());
prop_assert_eq!(restored.reask(), s.reask().or(Some(&Reask::Recheck)));
prop_assert_eq!(restored.hints(), s.hints());
prop_assert_eq!(restored.last_hint_at(), s.last_hint_at());
prop_assert_eq!(restored.txid(), s.txid());
} else {
prop_assert_eq!(&restored, &s);
prop_assert_eq!(restored.to_compact(), bytes);
}
let again = restored.to_compact();
prop_assert_eq!(State::from_compact(&again).unwrap(), restored.clone());
prop_assert_eq!(State::from_compact(&again).unwrap().to_compact(), again);
Ok(())
})
.unwrap();
assert!((1..256).contains(&mined.get()), "mined: {}", mined.get());
}
#[test]
fn every_proper_prefix_is_truncated_and_a_suffix_is_trailing() {
let s = build(vec![
(9, 10, 5, 0),
(13, 10, 9, 3),
(0, 10, 7, 0),
(10, 10, 7, 1),
]);
assert!(matches!(s.word(), Word::Mined(_)));
let bytes = s.to_compact();
for n in 0..bytes.len() {
assert_eq!(
State::from_compact(&bytes[..n]),
Err(CompactError::Truncated),
"prefix {n}"
);
}
let mut long = bytes.clone();
long.push(0);
assert_eq!(State::from_compact(&long), Err(CompactError::TrailingBytes));
let mut v2 = bytes;
v2[0] = 2;
assert_eq!(
State::from_compact(&v2),
Err(CompactError::UnknownVersion(2))
);
}
#[test]
fn bytes_that_forge_a_mined_word_are_refused() {
let txid = hash(1);
let mut s = State::new(&txid);
let mut h = TestHeaders::default();
let p = proof(&txid, 10, 2);
h.insert(&p, 10);
step(&mut s, &h, Input::Evidence(Evidence::Proof(p)));
let bytes = s.to_compact();
assert_eq!(bytes[34], 4);
let mut no_proof = State::new(&txid).to_compact();
no_proof[34] = 4;
assert_eq!(
State::from_compact(&no_proof),
Err(CompactError::Malformed("mined word with no stored proof"))
);
let checked = s.evidence().unwrap();
let root = hex_bytes(&checked.header().merkle_root);
let at = find(&bytes, &root, 0);
let mut forged = bytes.clone();
forged[at] ^= 1;
assert_eq!(
State::from_compact(&forged),
Err(CompactError::Evidence(CheckError::RootMismatch(10)))
);
let other = hash(9);
let mut s9 = State::new(&other);
let p9 = proof(&other, 10, 2);
let mut h9 = TestHeaders::default();
h9.insert(&p9, 10);
step(&mut s9, &h9, Input::Evidence(Evidence::Proof(p9)));
let mut swapped = s9.to_compact();
swapped[2..34].copy_from_slice(&hex_bytes(&txid));
assert_eq!(
State::from_compact(&swapped),
Err(CompactError::Evidence(CheckError::TxidMismatch))
);
let mut tag = State::new(&txid).to_compact();
tag[34] = 9;
assert_eq!(
State::from_compact(&tag),
Err(CompactError::Malformed("word tag"))
);
let mut fresh = State::new(&txid).to_compact();
let hints_at = fresh.len() - 3;
assert_eq!(&fresh[hints_at..], &[0, 0, 0]);
fresh.splice(hints_at..hints_at + 1, [0x80, 0x00]);
assert_eq!(
State::from_compact(&fresh),
Err(CompactError::Malformed("varint not shortest"))
);
}
#[test]
fn a_restored_mined_word_is_rechecked_before_it_counts() {
let txid = hash(1);
let mut h = TestHeaders::default();
let p = proof(&txid, 10, 2);
h.insert(&p, 10);
let mut tracker = Tracker::new(params());
tracker
.apply(&txid, &h, Input::Evidence(Evidence::Proof(p)))
.unwrap();
let bytes = tracker.get(&txid).unwrap().to_compact();
let mut host = Tracker::new(params());
let restored = State::from_compact(&bytes).unwrap();
mined(&restored, 10);
assert!(restored.suspect());
assert_eq!(restored.reask(), Some(&Reask::Recheck));
assert_eq!(host.restore(restored.clone()), None);
assert_eq!(
host.restore(restored),
Some(State::from_compact(&bytes).unwrap())
);
let updates = host.on_chain(&h, tip(11, 90));
assert_eq!(updates.len(), 1);
assert_eq!(updates[0].result, Ok(()));
let after = host.get(&txid).unwrap();
mined(after, 10);
assert!(!after.suspect());
assert_eq!(after.reask(), None);
let mut moved = TestHeaders::default();
moved.insert(&proof(&txid, 10, 3), 77);
let mut s = State::from_compact(&bytes).unwrap();
step(&mut s, &moved, Input::Evidence(Evidence::Recheck));
assert_eq!(s.word(), &Word::Stale);
assert_eq!(s.reask(), Some(&Reask::Reorg { fork_height: 10 }));
assert_eq!(
s.evidence(),
State::from_compact(&bytes).unwrap().evidence()
);
}
fn hex_bytes(s: &str) -> Vec<u8> {
(0..s.len())
.step_by(2)
.map(|i| u8::from_str_radix(&s[i..i + 2], 16).unwrap())
.collect()
}
fn find(haystack: &[u8], needle: &[u8], from: usize) -> usize {
from + haystack[from..]
.windows(needle.len())
.position(|w| w == needle)
.unwrap()
}