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    pub fn new(txid: impl Into<TxId>, path: MerklePath) -> Result<Self, CheckError> {
66        let txid = txid.into().to_ascii_lowercase();
67        if txid.len() != 64 || !txid.bytes().all(|c| c.is_ascii_hexdigit()) {
68            return Err(CheckError::InvalidProof(
69                "txid must be 32 bytes of hex".into(),
70            ));
71        }
72        let path = MerklePath::new_unchecked(path.block_height, path.path)
73            .map_err(|e| CheckError::InvalidProof(e.to_string()))?;
74        path.compute_root(Some(&txid))
75            .map_err(|e| CheckError::InvalidProof(e.to_string()))?;
76        Ok(Self { txid, path })
77    }
78    /// The transaction whose inclusion is being checked.
79    pub fn txid(&self) -> &str {
80        &self.txid
81    }
82    /// The height named by the path.
83    pub fn height(&self) -> Height {
84        self.path.block_height
85    }
86    /// The SDK path, borrowed without allowing mutation.
87    pub fn path(&self) -> &MerklePath {
88        &self.path
89    }
90    /// Compute the root for the bound txid through bsv-rs.
91    pub fn root(&self) -> Result<Hash, CheckError> {
92        self.path
93            .compute_root(Some(&self.txid))
94            .map_err(|e| CheckError::InvalidProof(e.to_string()))
95    }
96}
97
98/// A capability produced only by [`Headers::check`]. There is no unchecked
99/// constructor or deserializer; the retained proof and header are immutable.
100///
101/// ```compile_fail
102/// use bsv_tracker::{CheckedProof, Header, Proof};
103/// use bsv_rs::transaction::MerklePath;
104/// let txid = "01".repeat(32);
105/// let proof = Proof::new(&txid, MerklePath::from_coinbase_txid(&txid, 10)).unwrap();
106/// let header = Header { hash: "02".repeat(32), merkle_root: txid.clone() };
107/// let _ = CheckedProof { proof, header, root: txid, depth: 1 };
108/// ```
109///
110/// ```compile_fail
111/// use bsv_tracker::CheckedProof;
112/// let _ = serde_json::from_str::<CheckedProof>("{}");
113/// ```
114#[derive(Clone, Debug, PartialEq, Eq)]
115pub struct CheckedProof {
116    proof: Proof,
117    header: Header,
118    root: Hash,
119    depth: u64,
120}
121impl CheckedProof {
122    /// The path whose root was checked.
123    pub fn proof(&self) -> &Proof {
124        &self.proof
125    }
126    /// The active header used at the moment of the check.
127    pub fn header(&self) -> &Header {
128        &self.header
129    }
130    /// The computed root, equal to the retained header's root.
131    pub fn root(&self) -> &str {
132        &self.root
133    }
134    /// The proof's height.
135    pub fn height(&self) -> Height {
136        self.proof.height()
137    }
138    /// Confirmations at the snapshot that checked this proof.
139    pub fn depth(&self) -> u64 {
140        self.depth
141    }
142}
143
144/// A host supplied snapshot of fresh, verified active headers. Both methods
145/// must answer from the same snapshot and fail closed when it is unavailable.
146pub trait Headers {
147    /// Look up an active header, or no answer while behind or unverified.
148    fn header_at(&self, height: Height) -> Result<Option<Header>, HeaderError>;
149    /// The active height from the same snapshot.
150    fn tip_height(&self) -> Result<Height, HeaderError>;
151    /// Reduce a bound SDK proof and check it against the active header.
152    /// Tracker transitions always invoke this default implementation through
153    /// a snapshot wrapper, so host overrides cannot bypass current lookups.
154    fn check(&self, proof: Proof) -> Result<CheckedProof, CheckError> {
155        let root = proof.root()?;
156        let height = proof.height();
157        let header = self
158            .header_at(height)
159            .map_err(CheckError::Headers)?
160            .ok_or(CheckError::Unavailable(height))?;
161        let tip = self.tip_height().map_err(CheckError::Headers)?;
162        if tip < height {
163            return Err(CheckError::Unavailable(height));
164        }
165        if header.merkle_root != root {
166            return Err(CheckError::RootMismatch(height));
167        }
168        Ok(CheckedProof {
169            proof,
170            header,
171            root,
172            depth: u64::from(tip) - u64::from(height) + 1,
173        })
174    }
175}
176
177pub(crate) fn check_snapshot<H: Headers + ?Sized>(
178    headers: &H,
179    proof: Proof,
180) -> Result<CheckedProof, CheckError> {
181    struct Snapshot<'a, H: ?Sized>(&'a H);
182
183    impl<H: Headers + ?Sized> Headers for Snapshot<'_, H> {
184        fn header_at(&self, height: Height) -> Result<Option<Header>, HeaderError> {
185            self.0.header_at(height)
186        }
187
188        fn tip_height(&self) -> Result<Height, HeaderError> {
189            self.0.tip_height()
190        }
191    }
192
193    Headers::check(&Snapshot(headers), proof)
194}