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    /// A stored record, held together but not checked against any headers:
160    /// only [`State::from_compact`](crate::State::from_compact) calls this,
161    /// and it marks the record's mined word suspect with a recheck due.
162    pub(crate) fn restored(
163        proof: Proof,
164        header: Header,
165        root: Hash,
166        depth: u64,
167    ) -> Result<Self, CheckError> {
168        let height = proof.height();
169        if proof.root()? != root || header.merkle_root != root {
170            return Err(CheckError::RootMismatch(height));
171        }
172        if depth == 0 {
173            return Err(CheckError::InvalidProof("depth must be at least 1".into()));
174        }
175        Ok(Self {
176            proof,
177            header,
178            root,
179            depth,
180        })
181    }
182}
183
184/// A host supplied snapshot of fresh, verified active headers. Both methods
185/// must answer from the same snapshot and fail closed when it is unavailable.
186pub trait Headers {
187    /// Look up an active header, or no answer while behind or unverified.
188    ///
189    /// # Errors
190    ///
191    /// A [`HeaderError`] when the host cannot answer from a fresh verified
192    /// active chain.
193    fn header_at(&self, height: Height) -> Result<Option<Header>, HeaderError>;
194    /// The active height from the same snapshot.
195    ///
196    /// # Errors
197    ///
198    /// A [`HeaderError`] when the snapshot is unavailable.
199    fn tip_height(&self) -> Result<Height, HeaderError>;
200    /// Reduce a bound SDK proof and check it against the active header.
201    /// Tracker transitions always invoke this default implementation through
202    /// a snapshot wrapper, so host overrides cannot bypass current lookups.
203    ///
204    /// # Errors
205    ///
206    /// [`CheckError::InvalidProof`] when the path does not reduce,
207    /// [`CheckError::Headers`] when a lookup fails, [`CheckError::Unavailable`]
208    /// when the snapshot has no active header at the height or its tip is
209    /// below it, and [`CheckError::RootMismatch`] when the roots differ.
210    fn check(&self, proof: Proof) -> Result<CheckedProof, CheckError> {
211        let root = proof.root()?;
212        let height = proof.height();
213        let header = self
214            .header_at(height)
215            .map_err(CheckError::Headers)?
216            .ok_or(CheckError::Unavailable(height))?;
217        let tip = self.tip_height().map_err(CheckError::Headers)?;
218        if tip < height {
219            return Err(CheckError::Unavailable(height));
220        }
221        if header.merkle_root != root {
222            return Err(CheckError::RootMismatch(height));
223        }
224        Ok(CheckedProof {
225            proof,
226            header,
227            root,
228            depth: u64::from(tip) - u64::from(height) + 1,
229        })
230    }
231}
232
233pub(crate) fn check_snapshot<H: Headers + ?Sized>(
234    headers: &H,
235    proof: Proof,
236) -> Result<CheckedProof, CheckError> {
237    struct Snapshot<'a, H: ?Sized>(&'a H);
238
239    impl<H: Headers + ?Sized> Headers for Snapshot<'_, H> {
240        fn header_at(&self, height: Height) -> Result<Option<Header>, HeaderError> {
241            self.0.header_at(height)
242        }
243
244        fn tip_height(&self) -> Result<Height, HeaderError> {
245            self.0.tip_height()
246        }
247    }
248
249    Headers::check(&Snapshot(headers), proof)
250}