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}