Skip to main content

dig_evidence/
read_integrity.rs

1//! [`ReadIntegrityEvidence`] — the composite proof a reader gathers before it caches + reshares content
2//! (the MVP flywheel's read→verify→cache→reshare integrity gate).
3//!
4//! It is the conjunction of the two proofs a safe read needs:
5//!
6//! > `RangeInclusion(leaf ⇒ root)  ∧  RootAnchor(root ⇐ launcher)`
7//!
8//! - [`RangeInclusionEvidence`] proves the served range's leaf is included under a generation root.
9//! - [`RootAnchorEvidence`] proves that SAME root is genuinely committed on-chain by the store's
10//!   launcher-anchored lineage (#1473).
11//!
12//! Neither alone is sufficient: a range proof under an unanchored root proves nothing about the store,
13//! and an anchored root without an inclusion proof says nothing about the bytes served. Binding them —
14//! the range proof MUST fold to the exact root the anchor proves — is what lets a reader trust served
15//! bytes enough to cache and re-serve them. `dig-download` / `dig-store-cache` invoke this single call.
16
17use chia_protocol::Bytes32;
18use dig_capsule::format::Bytes32 as CapsuleBytes32;
19use dig_capsule::merkle::ProofStep;
20use dig_chainsource_interface::ChainSource;
21
22use crate::error::{EvidenceError, EvidenceResult};
23use crate::evidence::Evidence;
24use crate::range_inclusion::{RangeInclusionClaim, RangeInclusionEvidence};
25use crate::root_anchor::{RootAnchorClaim, RootAnchorEvidence};
26
27/// Re-expresses a chia [`Bytes32`] as a `dig-capsule` [`CapsuleBytes32`] — the SAME 32 bytes, viewed
28/// through the merkle crate's newtype. This is the ONE place the two 32-byte views meet, so the
29/// range-fold root and the chain-anchored root are provably the same bytes.
30fn to_capsule_bytes(bytes: Bytes32) -> CapsuleBytes32 {
31    let mut raw = [0u8; 32];
32    raw.copy_from_slice(bytes.as_ref());
33    CapsuleBytes32(raw)
34}
35
36/// What a read-integrity proof claims: that `range_leaf` (via `range_path`) is included under
37/// `generation_root`, AND that `generation_root` is committed on-chain by the store `store_id`.
38#[derive(Debug, Clone, PartialEq, Eq)]
39pub struct ReadIntegrityClaim {
40    /// The store's launcher coin id (`launcher coin_id == store_id`, the unforgeable anchor).
41    pub store_id: Bytes32,
42    /// The generation root both proofs bind to.
43    pub generation_root: Bytes32,
44    /// The served range's merkle leaf digest.
45    pub range_leaf: CapsuleBytes32,
46    /// The served range's bottom-up inclusion path.
47    pub range_path: Vec<ProofStep>,
48}
49
50/// Authenticated composite evidence that a served range is both included under a generation root and
51/// that the root is genuinely anchored on-chain — the single assurance a reader needs to cache +
52/// reshare.
53///
54/// Its fields are PRIVATE: the only way to obtain a value is [`gather`](Evidence::gather), which
55/// authenticates both legs and binds them to the same root.
56#[derive(Debug, Clone, PartialEq, Eq)]
57pub struct ReadIntegrityEvidence {
58    range: RangeInclusionEvidence,
59    anchor: RootAnchorEvidence,
60}
61
62impl ReadIntegrityEvidence {
63    /// The range-inclusion leg (leaf ⇒ root).
64    pub fn range(&self) -> &RangeInclusionEvidence {
65        &self.range
66    }
67
68    /// The root-anchor leg (root ⇐ launcher).
69    pub fn anchor(&self) -> &RootAnchorEvidence {
70        &self.anchor
71    }
72
73    /// The store's launcher coin id this evidence is anchored to.
74    pub fn store_id(&self) -> Bytes32 {
75        self.anchor.store_id()
76    }
77
78    /// The generation root both legs bind to.
79    pub fn generation_root(&self) -> Bytes32 {
80        self.anchor.generation_root()
81    }
82}
83
84impl Evidence for ReadIntegrityEvidence {
85    type Claim = ReadIntegrityClaim;
86
87    /// Gathers both legs against `chain` and binds them: the range proof is required to fold to the
88    /// EXACT root the anchor proves, so the two describe the same content. Fails closed if either leg
89    /// fails.
90    fn gather<S: ChainSource>(claim: &Self::Claim, chain: &S) -> EvidenceResult<Self> {
91        // Anchor first: prove the root is genuinely committed on-chain (the expensive, chain-reading
92        // leg). Its authenticated root is then the ONLY root the range proof is allowed to fold to.
93        let anchor = RootAnchorEvidence::gather(
94            &RootAnchorClaim {
95                store_id: claim.store_id,
96                generation_root: claim.generation_root,
97            },
98            chain,
99        )?;
100
101        // Bind the range proof to the anchored root by passing that exact root as the claimed root: a
102        // range that folds to any other root is rejected as not-folding.
103        let range = RangeInclusionEvidence::gather(
104            &RangeInclusionClaim {
105                leaf: claim.range_leaf,
106                path: claim.range_path.clone(),
107                generation_root: to_capsule_bytes(anchor.generation_root()),
108            },
109            chain,
110        )?;
111
112        Ok(Self { range, anchor })
113    }
114
115    /// Re-verifies both legs offline and re-asserts they bind to the same root.
116    fn verify(&self) -> EvidenceResult<()> {
117        self.range.verify()?;
118        self.anchor.verify()?;
119        if self.range.generation_root() != to_capsule_bytes(self.anchor.generation_root()) {
120            return Err(EvidenceError::RootMismatch);
121        }
122        Ok(())
123    }
124}
125
126#[cfg(test)]
127mod tests {
128    use super::*;
129    use chia_puzzle_types::standard::StandardArgs;
130    use chia_wallet_sdk::test::Simulator;
131    use dig_capsule::merkle::MerkleTree;
132    use dig_chainsource_interface::MockChainSource;
133    use dig_chainsource_interface::{CoinRecord, SingletonLineage};
134    use dig_merkle::{mint_datastore, Owner};
135
136    /// Builds a store whose committed root IS a real merkle-tree root, plus an inclusion proof for one
137    /// leaf under it — so both legs can be satisfied by the same root.
138    struct Fixture {
139        source: MockChainSource,
140        store_id: Bytes32,
141        root: Bytes32,
142        leaf: CapsuleBytes32,
143        path: Vec<ProofStep>,
144    }
145
146    fn record(coin: chia_protocol::Coin, spent: Option<u32>) -> CoinRecord {
147        CoinRecord {
148            coin,
149            confirmed_height: Some(1),
150            spent_height: spent,
151            timestamp: None,
152            coinbase: false,
153        }
154    }
155
156    fn fixture() -> anyhow::Result<Fixture> {
157        // A real merkle tree; its root is what the store will commit on-chain.
158        let chunks: Vec<Vec<u8>> = (0..6u8).map(|i| vec![i; 16]).collect();
159        let tree = MerkleTree::build(&chunks);
160        let root_capsule = tree.root();
161        let proof = tree.prove(3).expect("index in range");
162        let root = Bytes32::new(root_capsule.0);
163
164        // Mint a store committing exactly that root.
165        let mut sim = Simulator::new();
166        let owner = sim.bls(1_000_000);
167        let owner_ph: Bytes32 = StandardArgs::curry_tree_hash(owner.pk).into();
168        let built = mint_datastore(
169            owner.coin,
170            Owner::Standard(owner.pk),
171            root,
172            None,
173            None,
174            None,
175            None,
176            None,
177            owner_ph,
178            vec![],
179            0,
180        )?;
181        sim.spend_coins(built.coin_spends.clone(), std::slice::from_ref(&owner.sk))?;
182        let minted = built.child.expect("child");
183        let store_id = minted.info.launcher_id;
184        let launcher_spend = built
185            .coin_spends
186            .iter()
187            .find(|s| s.coin.coin_id() == store_id)
188            .expect("launcher spend")
189            .clone();
190        let launcher = launcher_spend.coin;
191        let eve = minted.coin;
192
193        let source = MockChainSource::new()
194            .with_coin(store_id, record(launcher, Some(2)))
195            .with_coin(eve.coin_id(), record(eve, None))
196            .with_spend(store_id, launcher_spend)
197            .with_lineage(
198                store_id,
199                SingletonLineage::new(eve.coin_id(), [store_id, eve.coin_id()]),
200            );
201
202        Ok(Fixture {
203            source,
204            store_id,
205            root,
206            leaf: proof.leaf,
207            path: proof.path,
208        })
209    }
210
211    #[test]
212    fn a_genuine_read_gathers_and_verifies() -> anyhow::Result<()> {
213        let f = fixture()?;
214        let claim = ReadIntegrityClaim {
215            store_id: f.store_id,
216            generation_root: f.root,
217            range_leaf: f.leaf,
218            range_path: f.path.clone(),
219        };
220        let evidence = ReadIntegrityEvidence::gather(&claim, &f.source).expect("genuine read");
221        assert_eq!(evidence.store_id(), f.store_id);
222        assert_eq!(evidence.generation_root(), f.root);
223        assert!(evidence.verify().is_ok());
224        Ok(())
225    }
226
227    #[test]
228    fn a_range_under_an_unanchored_root_is_rejected() -> anyhow::Result<()> {
229        let f = fixture()?;
230        // The range leaf/path are for the real tree, but the claimed root is not the anchored one.
231        let claim = ReadIntegrityClaim {
232            store_id: f.store_id,
233            generation_root: Bytes32::new([0xAB; 32]), // not committed on-chain
234            range_leaf: f.leaf,
235            range_path: f.path.clone(),
236        };
237        assert_eq!(
238            ReadIntegrityEvidence::gather(&claim, &f.source).unwrap_err(),
239            EvidenceError::RootNotCommitted
240        );
241        Ok(())
242    }
243
244    #[test]
245    fn a_tampered_range_leaf_is_rejected_even_with_a_genuine_anchor() -> anyhow::Result<()> {
246        let f = fixture()?;
247        let claim = ReadIntegrityClaim {
248            store_id: f.store_id,
249            generation_root: f.root,                // genuinely anchored
250            range_leaf: CapsuleBytes32([0xff; 32]), // but the served leaf is forged
251            range_path: f.path.clone(),
252        };
253        assert_eq!(
254            ReadIntegrityEvidence::gather(&claim, &f.source).unwrap_err(),
255            EvidenceError::ProofDoesNotFold
256        );
257        Ok(())
258    }
259}