Skip to main content

dig_evidence/
root_anchor.rs

1//! [`RootAnchorEvidence`] (#1473) — ACTIVE proof that a claimed generation root is genuinely committed
2//! on-chain by the store's own DataStore singleton.
3//!
4//! This is the crate's anti-rollback / anti-impostor anchor, and it enforces the ecosystem's
5//! load-bearing security invariant (#1473, `canonical`):
6//!
7//! > A singleton's CURRIED `launcher_id` is FORGEABLE. Anchor identity on the launcher COIN, never the
8//! > curry.
9//!
10//! Chia's singleton top-layer never binds the curried `SingletonStruct.launcher_id` to the coin that
11//! actually launched the singleton — an attacker can launch from their OWN launcher coin
12//! (`coin_id != store_id`) while currying `launcher_id = store_id`, set any state root, and hint the
13//! tip to `store_id`. So a check of the curried `launcher_id == store_id` (or a hint discovery) is
14//! spoofable. The ONLY unforgeable anchor is the launcher **coin_id == store_id** (a 256-bit hash
15//! preimage an attacker cannot grind): trust chains to `coin_record(store_id)` — it exists, and its
16//! puzzle hash IS the singleton launcher puzzle hash — and to the store's authenticated singleton
17//! lineage (a genuine forward walk from that launcher, supplied by
18//! [`ChainSource::resolve_singleton_lineage`]). The curried-`launcher_id == store_id` check is kept
19//! ONLY as per-hop defence-in-depth, never as the anchor.
20//!
21//! [`gather`] therefore: (1) confirms `store_id` is a real launcher coin, (2) resolves the
22//! authenticated lineage, then (3) walks that lineage backward from the tip — each hop confirmed a
23//! genuine member — hydrating each store coin ([`dig_merkle::hydrate`]) until it finds the coin that
24//! commits the claimed root. Anything missing fails closed.
25
26use chia_protocol::Bytes32;
27use chia_puzzles::SINGLETON_LAUNCHER_HASH;
28use dig_chainsource_interface::ChainSource;
29use dig_merkle::hydrate;
30
31use crate::error::{EvidenceError, EvidenceResult};
32use crate::evidence::{chain_err, Evidence};
33
34/// The maximum number of lineage coins the backward walk will visit before failing closed with
35/// [`EvidenceError::LineageTooDeep`]. Bounds the work an adversarial (deep) lineage can force.
36pub const MAX_LINEAGE_DEPTH: usize = 100_000;
37
38/// What a root-anchor proof claims: that `generation_root` is committed on-chain by the store whose
39/// launcher coin id is `store_id`.
40///
41/// `store_id` MUST be the store's launcher COIN id (the unforgeable identity anchor). `generation_root`
42/// is the `.dig` merkle root the caller claims the store committed at some generation.
43#[derive(Debug, Clone, Copy, PartialEq, Eq)]
44pub struct RootAnchorClaim {
45    /// The store's launcher coin id (`launcher coin_id == store_id`, the unforgeable anchor).
46    pub store_id: Bytes32,
47    /// The generation root claimed to be committed by a coin in the store's lineage.
48    pub generation_root: Bytes32,
49}
50
51/// Authenticated evidence that a generation root is committed by the store's on-chain lineage.
52///
53/// Its fields are PRIVATE and exposed only through accessors: the only way to obtain a value is
54/// [`gather`](Evidence::gather), which authenticates the launcher-coin anchor, the lineage, and the
55/// committing coin against the injected chain. A value witnesses that the root is genuinely anchored.
56#[derive(Debug, Clone, Copy, PartialEq, Eq)]
57pub struct RootAnchorEvidence {
58    store_id: Bytes32,
59    generation_root: Bytes32,
60    committing_coin: Bytes32,
61    lineage_tip: Bytes32,
62}
63
64impl RootAnchorEvidence {
65    /// The store's launcher coin id (the unforgeable identity anchor this evidence chained to).
66    pub fn store_id(&self) -> Bytes32 {
67        self.store_id
68    }
69
70    /// The generation root proven to be committed on-chain by the store.
71    pub fn generation_root(&self) -> Bytes32 {
72        self.generation_root
73    }
74
75    /// The lineage coin whose DataStore state committed `generation_root`.
76    pub fn committing_coin(&self) -> Bytes32 {
77        self.committing_coin
78    }
79
80    /// The store singleton's current unspent tip at gather time.
81    pub fn lineage_tip(&self) -> Bytes32 {
82        self.lineage_tip
83    }
84}
85
86impl Evidence for RootAnchorEvidence {
87    type Claim = RootAnchorClaim;
88
89    /// Gathers + authenticates the on-chain anchor for `claim` (see the module docs for the
90    /// launcher-coin invariant). Fails closed on a missing/forged launcher, an absent lineage, an
91    /// unreadable chain, or a root committed by no coin in the lineage.
92    fn gather<S: ChainSource>(claim: &Self::Claim, chain: &S) -> EvidenceResult<Self> {
93        // (1) The unforgeable identity anchor: a coin genuinely exists AT `store_id`, and it is a
94        // singleton LAUNCHER coin. Because it was looked up BY `store_id`, its coin id IS `store_id`
95        // (coin_id == store_id) — the 256-bit preimage an attacker cannot grind. A curried
96        // `launcher_id == store_id` is NOT trusted here; only the launcher coin is.
97        let launcher = chain
98            .coin_record(claim.store_id)
99            .map_err(chain_err)?
100            .ok_or(EvidenceError::LauncherNotFound)?;
101        if launcher.coin.puzzle_hash != Bytes32::from(SINGLETON_LAUNCHER_HASH) {
102            return Err(EvidenceError::NotALauncher);
103        }
104
105        // (2) The authenticated lineage — a genuine forward walk from the launcher to its tip (the
106        // ChainSource contract). Membership in THIS set is the authority test.
107        let lineage = chain
108            .resolve_singleton_lineage(claim.store_id)
109            .map_err(chain_err)?
110            .ok_or(EvidenceError::NoLineage)?;
111
112        // (3) Walk the lineage backward from the tip, hydrating each store coin, until one commits the
113        // claimed root. Each visited coin is confirmed a genuine lineage member; the curried
114        // `launcher_id == store_id` is a per-hop defence-in-depth check, never the anchor.
115        let mut current = lineage.tip();
116        for _ in 0..MAX_LINEAGE_DEPTH {
117            if !lineage.contains(current) {
118                return Err(EvidenceError::RootNotCommitted);
119            }
120
121            let record = chain
122                .coin_record(current)
123                .map_err(chain_err)?
124                .ok_or(EvidenceError::RootNotCommitted)?;
125
126            // The spend that CREATED `current` is the spend of its parent; hydrating it reconstructs
127            // `current`'s DataStore state (and the root it committed).
128            if let Some(creating_spend) = chain
129                .coin_spend(record.coin.parent_coin_info)
130                .map_err(chain_err)?
131            {
132                if let Ok(store) = hydrate(&creating_spend) {
133                    if store.info.launcher_id == claim.store_id
134                        && store.info.metadata.root_hash == claim.generation_root
135                    {
136                        return Ok(Self {
137                            store_id: claim.store_id,
138                            generation_root: claim.generation_root,
139                            committing_coin: current,
140                            lineage_tip: lineage.tip(),
141                        });
142                    }
143                }
144            }
145
146            // Step to the parent; stop once we reach the launcher (which commits no store root).
147            let parent = record.coin.parent_coin_info;
148            if parent == claim.store_id {
149                return Err(EvidenceError::RootNotCommitted);
150            }
151            current = parent;
152        }
153
154        Err(EvidenceError::LineageTooDeep)
155    }
156
157    /// Re-asserts the anchor's internal invariant OFFLINE: the evidence names a committing coin and its
158    /// identity is anchored on the launcher coin (`store_id`). The chain-authenticated facts were
159    /// established at [`gather`](Evidence::gather) time (the value cannot be forged), so an offline
160    /// re-check without the chain confirms only self-coherence.
161    fn verify(&self) -> EvidenceResult<()> {
162        if self.committing_coin == Bytes32::default() || self.store_id == Bytes32::default() {
163            return Err(EvidenceError::RootNotCommitted);
164        }
165        Ok(())
166    }
167}
168
169#[cfg(test)]
170mod tests {
171    use super::*;
172    use chia_protocol::Coin;
173    use chia_puzzle_types::standard::StandardArgs;
174    use chia_wallet_sdk::test::Simulator;
175    use dig_chainsource_interface::MockChainSource;
176    use dig_chainsource_interface::{CoinRecord, SingletonLineage};
177    use dig_merkle::{mint_datastore, Owner};
178
179    /// A real minted store: returns the launcher coin, the eve store coin, the launcher spend, and the
180    /// committed root — enough to load an authentic [`MockChainSource`] fixture.
181    struct MintedStore {
182        launcher: Coin,
183        eve: Coin,
184        launcher_spend: chia_protocol::CoinSpend,
185        store_id: Bytes32,
186        root: Bytes32,
187    }
188
189    fn mint(root: Bytes32) -> anyhow::Result<MintedStore> {
190        let mut sim = Simulator::new();
191        let owner = sim.bls(1_000_000);
192        let owner_ph: Bytes32 = StandardArgs::curry_tree_hash(owner.pk).into();
193        let built = mint_datastore(
194            owner.coin,
195            Owner::Standard(owner.pk),
196            root,
197            None,
198            None,
199            None,
200            None,
201            None,
202            owner_ph,
203            vec![],
204            0,
205        )?;
206        sim.spend_coins(built.coin_spends.clone(), std::slice::from_ref(&owner.sk))?;
207        let minted = built.child.expect("mint yields a child");
208        let store_id = minted.info.launcher_id;
209        let launcher_spend = built
210            .coin_spends
211            .iter()
212            .find(|s| s.coin.coin_id() == store_id)
213            .expect("launcher spend present")
214            .clone();
215        Ok(MintedStore {
216            launcher: launcher_spend.coin,
217            eve: minted.coin,
218            launcher_spend,
219            store_id,
220            root,
221        })
222    }
223
224    fn record(coin: Coin, spent_height: Option<u32>) -> CoinRecord {
225        CoinRecord {
226            coin,
227            confirmed_height: Some(1),
228            spent_height,
229            timestamp: None,
230            coinbase: false,
231        }
232    }
233
234    /// An authentic chain fixture for a minted store: the launcher coin (spent), the eve coin
235    /// (unspent tip), the launcher spend, and the lineage launcher -> eve.
236    fn authentic_source(m: &MintedStore) -> MockChainSource {
237        MockChainSource::new()
238            .with_coin(m.store_id, record(m.launcher, Some(2)))
239            .with_coin(m.eve.coin_id(), record(m.eve, None))
240            .with_spend(m.store_id, m.launcher_spend.clone())
241            .with_lineage(
242                m.store_id,
243                SingletonLineage::new(m.eve.coin_id(), [m.store_id, m.eve.coin_id()]),
244            )
245    }
246
247    #[test]
248    fn a_genuine_root_anchor_gathers_and_verifies() -> anyhow::Result<()> {
249        let m = mint(Bytes32::new([0x5a; 32]))?;
250        let source = authentic_source(&m);
251        let claim = RootAnchorClaim {
252            store_id: m.store_id,
253            generation_root: m.root,
254        };
255        let evidence = RootAnchorEvidence::gather(&claim, &source).expect("genuine anchor");
256        assert_eq!(evidence.store_id(), m.store_id);
257        assert_eq!(evidence.generation_root(), m.root);
258        assert_eq!(evidence.committing_coin(), m.eve.coin_id());
259        assert!(evidence.verify().is_ok());
260        Ok(())
261    }
262
263    #[test]
264    fn a_root_never_committed_is_rejected() -> anyhow::Result<()> {
265        let m = mint(Bytes32::new([0x5a; 32]))?;
266        let source = authentic_source(&m);
267        let claim = RootAnchorClaim {
268            store_id: m.store_id,
269            generation_root: Bytes32::new([0xAA; 32]), // never committed by this store
270        };
271        assert_eq!(
272            RootAnchorEvidence::gather(&claim, &source).unwrap_err(),
273            EvidenceError::RootNotCommitted
274        );
275        Ok(())
276    }
277
278    /// The launcher-anchor defence (#1473): a coin exists at `store_id` but its puzzle hash is NOT the
279    /// singleton launcher puzzle hash — so it is not a genuine launcher coin. An attacker who curries
280    /// `launcher_id = store_id` on their own coin cannot satisfy this, so the impostor is rejected
281    /// BEFORE any lineage/root is trusted.
282    #[test]
283    fn an_impostor_whose_store_id_coin_is_not_a_launcher_is_rejected() {
284        let store_id = Bytes32::new([0x11; 32]);
285        let impostor = Coin::new(Bytes32::new([0x99; 32]), Bytes32::new([0x22; 32]), 1);
286        let source = MockChainSource::new()
287            .with_coin(store_id, record(impostor, Some(2)))
288            // Even a fabricated lineage claiming the root must not be trusted — the launcher gate
289            // rejects first.
290            .with_lineage(store_id, SingletonLineage::single(store_id));
291        let claim = RootAnchorClaim {
292            store_id,
293            generation_root: Bytes32::new([0x33; 32]),
294        };
295        assert_eq!(
296            RootAnchorEvidence::gather(&claim, &source).unwrap_err(),
297            EvidenceError::NotALauncher
298        );
299    }
300
301    #[test]
302    fn an_absent_launcher_is_rejected() {
303        let claim = RootAnchorClaim {
304            store_id: Bytes32::new([0x44; 32]),
305            generation_root: Bytes32::new([0x55; 32]),
306        };
307        assert_eq!(
308            RootAnchorEvidence::gather(&claim, &MockChainSource::new()).unwrap_err(),
309            EvidenceError::LauncherNotFound
310        );
311    }
312
313    #[test]
314    fn an_unreadable_chain_fails_closed() {
315        use dig_chainsource_interface::ChainSourceError;
316        let source = MockChainSource::new().fail_with(ChainSourceError::Timeout);
317        let claim = RootAnchorClaim {
318            store_id: Bytes32::new([0x44; 32]),
319            generation_root: Bytes32::new([0x55; 32]),
320        };
321        assert!(matches!(
322            RootAnchorEvidence::gather(&claim, &source).unwrap_err(),
323            EvidenceError::Chain(_)
324        ));
325    }
326
327    #[test]
328    fn a_launcher_with_no_lineage_is_rejected() -> anyhow::Result<()> {
329        let m = mint(Bytes32::new([0x5a; 32]))?;
330        // Launcher coin present + valid, but no lineage resolves (fully melted / unlaunched view).
331        let source = MockChainSource::new().with_coin(m.store_id, record(m.launcher, Some(2)));
332        let claim = RootAnchorClaim {
333            store_id: m.store_id,
334            generation_root: m.root,
335        };
336        assert_eq!(
337            RootAnchorEvidence::gather(&claim, &source).unwrap_err(),
338            EvidenceError::NoLineage
339        );
340        Ok(())
341    }
342}