Skip to main content

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}