bsv_tracker/evidence.rs
1use std::fmt;
2
3use bsv_rs::transaction::MerklePath;
4
5use crate::{Hash, Height, TxId};
6
7/// The active header projection retained with a proof, as in `Tracker.Header`.
8#[derive(Clone, Debug, PartialEq, Eq)]
9pub struct Header {
10 /// The block's display order hash.
11 pub hash: Hash,
12 /// The header's display order merkle root.
13 pub merkle_root: Hash,
14}
15
16/// A fault reported by the host's header snapshot.
17#[derive(Clone, Debug, PartialEq, Eq)]
18pub struct HeaderError(pub String);
19
20impl fmt::Display for HeaderError {
21 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
22 f.write_str(&self.0)
23 }
24}
25impl std::error::Error for HeaderError {}
26
27/// A failed evidence check. An error is never a transaction word.
28#[derive(Clone, Debug, PartialEq, Eq)]
29pub enum CheckError {
30 /// An invalid txid or BRC-74 path.
31 InvalidProof(String),
32 /// The host could not answer from a fresh verified active chain.
33 Headers(HeaderError),
34 /// The snapshot has no active header at this height.
35 Unavailable(Height),
36 /// The computed root differs from the active header's root.
37 RootMismatch(Height),
38 /// A proof belongs to a different transaction.
39 TxidMismatch,
40}
41impl fmt::Display for CheckError {
42 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
43 write!(f, "{self:?}")
44 }
45}
46impl std::error::Error for CheckError {}
47
48/// An owned, structurally validated SDK path bound to the transaction it proves.
49#[derive(Clone, Debug)]
50pub struct Proof {
51 txid: TxId,
52 path: MerklePath,
53}
54impl PartialEq for Proof {
55 fn eq(&self, other: &Self) -> bool {
56 self.txid == other.txid
57 && self.path.block_height == other.path.block_height
58 && self.path.path == other.path.path
59 }
60}
61impl Eq for Proof {}
62
63impl Proof {
64 /// Bind the path to its txid, revalidating public SDK fields before reduction.
65 ///
66 /// # Errors
67 ///
68 /// [`CheckError::InvalidProof`] when the txid is not 32 bytes of hex, the
69 /// path fails bsv-rs's validation, or the path does not reduce for the txid.
70 pub fn new(txid: impl Into<TxId>, path: MerklePath) -> Result<Self, CheckError> {
71 let txid = txid.into().to_ascii_lowercase();
72 if txid.len() != 64 || !txid.bytes().all(|c| c.is_ascii_hexdigit()) {
73 return Err(CheckError::InvalidProof(
74 "txid must be 32 bytes of hex".into(),
75 ));
76 }
77 let path = MerklePath::new_unchecked(path.block_height, path.path)
78 .map_err(|e| CheckError::InvalidProof(e.to_string()))?;
79 path.compute_root(Some(&txid))
80 .map_err(|e| CheckError::InvalidProof(e.to_string()))?;
81 Ok(Self { txid, path })
82 }
83 /// The transaction whose inclusion is being checked.
84 #[must_use]
85 pub fn txid(&self) -> &str {
86 &self.txid
87 }
88 /// The height named by the path.
89 #[must_use]
90 pub fn height(&self) -> Height {
91 self.path.block_height
92 }
93 /// The SDK path, borrowed without allowing mutation.
94 #[must_use]
95 pub fn path(&self) -> &MerklePath {
96 &self.path
97 }
98 /// Compute the root for the bound txid through bsv-rs.
99 ///
100 /// # Errors
101 ///
102 /// [`CheckError::InvalidProof`] when bsv-rs cannot reduce the path.
103 pub fn root(&self) -> Result<Hash, CheckError> {
104 self.path
105 .compute_root(Some(&self.txid))
106 .map_err(|e| CheckError::InvalidProof(e.to_string()))
107 }
108}
109
110/// A capability produced only by [`Headers::check`]. There is no unchecked
111/// constructor or deserializer; the retained proof and header are immutable.
112///
113/// ```compile_fail
114/// use bsv_tracker::{CheckedProof, Header, Proof};
115/// use bsv_rs::transaction::MerklePath;
116/// let txid = "01".repeat(32);
117/// let proof = Proof::new(&txid, MerklePath::from_coinbase_txid(&txid, 10)).unwrap();
118/// let header = Header { hash: "02".repeat(32), merkle_root: txid.clone() };
119/// let _ = CheckedProof { proof, header, root: txid, depth: 1 };
120/// ```
121///
122/// ```compile_fail
123/// use bsv_tracker::CheckedProof;
124/// let _ = serde_json::from_str::<CheckedProof>("{}");
125/// ```
126#[derive(Clone, Debug, PartialEq, Eq)]
127pub struct CheckedProof {
128 proof: Proof,
129 header: Header,
130 root: Hash,
131 depth: u64,
132}
133impl CheckedProof {
134 /// The path whose root was checked.
135 #[must_use]
136 pub fn proof(&self) -> &Proof {
137 &self.proof
138 }
139 /// The active header used at the moment of the check.
140 #[must_use]
141 pub fn header(&self) -> &Header {
142 &self.header
143 }
144 /// The computed root, equal to the retained header's root.
145 #[must_use]
146 pub fn root(&self) -> &str {
147 &self.root
148 }
149 /// The proof's height.
150 #[must_use]
151 pub fn height(&self) -> Height {
152 self.proof.height()
153 }
154 /// Confirmations at the snapshot that checked this proof.
155 #[must_use]
156 pub fn depth(&self) -> u64 {
157 self.depth
158 }
159}
160
161/// A host supplied snapshot of fresh, verified active headers. Both methods
162/// must answer from the same snapshot and fail closed when it is unavailable.
163pub trait Headers {
164 /// Look up an active header, or no answer while behind or unverified.
165 ///
166 /// # Errors
167 ///
168 /// A [`HeaderError`] when the host cannot answer from a fresh verified
169 /// active chain.
170 fn header_at(&self, height: Height) -> Result<Option<Header>, HeaderError>;
171 /// The active height from the same snapshot.
172 ///
173 /// # Errors
174 ///
175 /// A [`HeaderError`] when the snapshot is unavailable.
176 fn tip_height(&self) -> Result<Height, HeaderError>;
177 /// Reduce a bound SDK proof and check it against the active header.
178 /// Tracker transitions always invoke this default implementation through
179 /// a snapshot wrapper, so host overrides cannot bypass current lookups.
180 ///
181 /// # Errors
182 ///
183 /// [`CheckError::InvalidProof`] when the path does not reduce,
184 /// [`CheckError::Headers`] when a lookup fails, [`CheckError::Unavailable`]
185 /// when the snapshot has no active header at the height or its tip is
186 /// below it, and [`CheckError::RootMismatch`] when the roots differ.
187 fn check(&self, proof: Proof) -> Result<CheckedProof, CheckError> {
188 let root = proof.root()?;
189 let height = proof.height();
190 let header = self
191 .header_at(height)
192 .map_err(CheckError::Headers)?
193 .ok_or(CheckError::Unavailable(height))?;
194 let tip = self.tip_height().map_err(CheckError::Headers)?;
195 if tip < height {
196 return Err(CheckError::Unavailable(height));
197 }
198 if header.merkle_root != root {
199 return Err(CheckError::RootMismatch(height));
200 }
201 Ok(CheckedProof {
202 proof,
203 header,
204 root,
205 depth: u64::from(tip) - u64::from(height) + 1,
206 })
207 }
208}
209
210pub(crate) fn check_snapshot<H: Headers + ?Sized>(
211 headers: &H,
212 proof: Proof,
213) -> Result<CheckedProof, CheckError> {
214 struct Snapshot<'a, H: ?Sized>(&'a H);
215
216 impl<H: Headers + ?Sized> Headers for Snapshot<'_, H> {
217 fn header_at(&self, height: Height) -> Result<Option<Header>, HeaderError> {
218 self.0.header_at(height)
219 }
220
221 fn tip_height(&self) -> Result<Height, HeaderError> {
222 self.0.tip_height()
223 }
224 }
225
226 Headers::check(&Snapshot(headers), proof)
227}