Skip to main content

dig_did/
lineage.rs

1//! Lineage proof: authenticating that a coin belongs to a DID's identity (SPEC §5).
2//!
3//! [`prove_lineage`] answers ONE question — "is this coin a coin the DID identity owns / is rooted
4//! in?" — and answers it soundly, via exactly two accepted models, both reduced to DID-singleton
5//! lineage membership:
6//!
7//! - **[`LineageModel::Direct`]** — the coin authenticates as a state of the DID singleton ITSELF
8//!   (its authenticated launcher id equals the DID's launcher id).
9//! - **[`LineageModel::LaunchedFrom`]** — the coin is a DISTINCT singleton whose launcher coin's parent
10//!   is a MEMBER of the DID singleton's lineage (the DID coin created that launcher). Membership, NOT
11//!   tip-equality: launching from a DID recreates the DID coin in the same spend, so the launcher's
12//!   parent is a PAST DID coin `Cn` while the DID tip is already `Cn+1`.
13//!
14//! Everything else fails closed. In particular an ordinary payment/change coin whose `parent_coin_info`
15//! merely happens to be a DID coin is REJECTED — a DID spend can pay anyone, so a pay-to coin is not
16//! owned by the DID. The discriminator is SINGLETON STRUCTURE (authenticated by the walk in
17//! [`crate::resolve`]): a non-singleton coin has no launcher and fails with [`DidError::NotASingleton`].
18
19use chia_protocol::Bytes32;
20use chia_wallet_sdk::driver::{Did, SingletonInfo};
21
22use crate::error::{DidError, DidResult};
23use crate::resolve::{authenticate_singleton, ChainSource};
24
25/// How a coin is rooted in a DID's identity — the two (and only two) accepted models.
26#[derive(Debug, Clone, Copy, PartialEq, Eq)]
27pub enum LineageModel {
28    /// The coin is a state of the DID singleton itself (authenticated launcher id == the DID's).
29    Direct,
30
31    /// The coin is a distinct singleton launched from the DID: its launcher's parent is a member of the
32    /// DID singleton's lineage.
33    LaunchedFrom {
34        /// The authenticated launcher id of the distinct (launched) singleton.
35        launcher: Bytes32,
36        /// The DID coin (a member of the DID lineage) that created the distinct singleton's launcher.
37        did_parent: Bytes32,
38    },
39}
40
41/// A chain-authenticated proof that a coin is rooted in a DID's identity.
42///
43/// Its fields are PRIVATE and exposed only through accessors: an `AncestryProof` cannot be forged by a
44/// struct literal — the only way to obtain one is [`prove_lineage`], which authenticates every field
45/// against the chain. Treat a value of this type as evidence the proof genuinely holds.
46#[derive(Debug, Clone, PartialEq, Eq)]
47pub struct AncestryProof {
48    coin_id: Bytes32,
49    did_launcher_id: Bytes32,
50    model: LineageModel,
51    did_lineage_tip: Bytes32,
52    authenticated_launcher: Bytes32,
53    chain: Vec<Bytes32>,
54}
55
56impl AncestryProof {
57    /// The coin this proof authenticates.
58    pub fn coin_id(&self) -> Bytes32 {
59        self.coin_id
60    }
61
62    /// The launcher id of the DID the coin is rooted in.
63    pub fn did_launcher_id(&self) -> Bytes32 {
64        self.did_launcher_id
65    }
66
67    /// Which of the two accepted models roots the coin in the DID.
68    pub fn model(&self) -> LineageModel {
69        self.model
70    }
71
72    /// The DID singleton's current unspent tip at proof time.
73    pub fn did_lineage_tip(&self) -> Bytes32 {
74        self.did_lineage_tip
75    }
76
77    /// The launcher id the coin itself authenticated to (equal to the DID's launcher id for
78    /// [`LineageModel::Direct`]; the distinct singleton's launcher for [`LineageModel::LaunchedFrom`]).
79    pub fn authenticated_launcher(&self) -> Bytes32 {
80        self.authenticated_launcher
81    }
82
83    /// The audit trail of coin ids walked, from the proven coin up to (and including) its launcher.
84    pub fn chain(&self) -> &[Bytes32] {
85        &self.chain
86    }
87}
88
89/// Proves that `coin_id` is rooted in `did`'s identity, reading chain state through `chain`.
90///
91/// This is the crate's lineage trust anchor. It authenticates `coin_id` as a genuine singleton (walking
92/// its parent-spend chain to a launcher — see [`crate::resolve`]) and then roots it in the DID by one of
93/// the two [`LineageModel`]s. Pure over the injected reads: NO keys, NO signing, NO network (INV-1/2).
94///
95/// # Errors
96///
97/// Fails closed on every gap or mismatch (SPEC §5):
98///
99/// - [`DidError::NoIdentitySingleton`] — the DID has no current on-chain coin (unlaunched or melted).
100/// - [`DidError::NotASingleton`] — `coin_id` is not a genuine singleton (a payment/change coin, or a
101///   pay-to coin wearing a singleton puzzle hash with no genuine recreation parent spend).
102/// - [`DidError::NotDidRooted`] — `coin_id` authenticates as a singleton but is neither the DID nor
103///   launched from a coin in the DID's lineage.
104/// - [`DidError::LineageTooDeep`] — the walk exceeded [`crate::resolve::MAX_LINEAGE_DEPTH`].
105/// - [`DidError::Chain`] — a `chain` read failed (never degraded to "assume owned").
106pub fn prove_lineage<S: ChainSource>(
107    coin_id: Bytes32,
108    did: &Did,
109    chain: &S,
110) -> DidResult<AncestryProof> {
111    let did_launcher_id = did.info.launcher_id();
112
113    // The DID's own authentic lineage — its existence anchors the proof, and its membership set decides
114    // the LaunchedFrom model. A missing lineage is fail-closed, never "assume owned".
115    let did_lineage = chain
116        .resolve_singleton_lineage(did_launcher_id)
117        .map_err(|error| DidError::Chain(error.to_string()))?
118        .ok_or(DidError::NoIdentitySingleton)?;
119
120    // Authenticate the coin as a genuine singleton and derive the launcher it descends from.
121    let authenticated = authenticate_singleton(coin_id, chain)?;
122
123    // Model (a) Direct: the coin IS a state of the DID singleton.
124    let model = if authenticated.launcher_id == did_launcher_id {
125        LineageModel::Direct
126    } else {
127        // Model (b) LaunchedFrom: the distinct singleton's launcher must have been created by a coin in
128        // the DID's lineage (membership, not tip-equality).
129        let did_parent = authenticated.launcher_coin.parent_coin_info;
130        if !did_lineage.contains(did_parent) {
131            return Err(DidError::NotDidRooted);
132        }
133        LineageModel::LaunchedFrom {
134            launcher: authenticated.launcher_id,
135            did_parent,
136        }
137    };
138
139    Ok(AncestryProof {
140        coin_id,
141        did_launcher_id,
142        model,
143        did_lineage_tip: did_lineage.tip(),
144        authenticated_launcher: authenticated.launcher_id,
145        chain: authenticated.trail,
146    })
147}
148
149#[cfg(test)]
150// Tests build launchers directly, on purpose: a fixture needs an arbitrary parent id, and some
151// fixtures need an amount the production chokepoint would (rightly) refuse. The lint guards
152// PRODUCTION launch sites; see the note on `singleton_launcher` in create.rs.
153#[allow(clippy::disallowed_methods)]
154mod tests {
155    use super::*;
156    use std::collections::HashMap;
157
158    use chia_protocol::{Coin, CoinSpend};
159    use chia_puzzle_types::singleton::SingletonArgs;
160    use chia_puzzle_types::Memos;
161    use chia_wallet_sdk::driver::{Launcher, SpendContext, StandardLayer};
162    use chia_wallet_sdk::test::Simulator;
163    use chia_wallet_sdk::types::Conditions;
164    use dig_chainsource_interface::CoinRecord;
165
166    use crate::create::create_simple_did;
167    use crate::resolve::{authenticate_singleton_bounded, SingletonLineage};
168    use crate::types::Owner;
169
170    /// An honest chain view for tests: the real in-process Simulator answers `parent_spend` (the
171    /// creating spend of any coin), and a per-launcher lineage map answers `resolve_singleton_lineage`.
172    struct SimSource<'a> {
173        sim: &'a Simulator,
174        lineages: HashMap<Bytes32, SingletonLineage>,
175    }
176
177    impl ChainSource for SimSource<'_> {
178        type Error = String;
179
180        fn coin_record(&self, coin_id: Bytes32) -> Result<Option<CoinRecord>, Self::Error> {
181            Ok(self.sim.coin_state(coin_id).map(CoinRecord::from))
182        }
183
184        fn coin_records_by_puzzle_hash(
185            &self,
186            _puzzle_hash: Bytes32,
187            _include_spent: bool,
188        ) -> Result<Vec<CoinRecord>, Self::Error> {
189            // dig-did's lineage logic never queries by puzzle hash; the honest simulator source only
190            // needs the parent-walk + lineage reads below.
191            Ok(Vec::new())
192        }
193
194        fn coin_records_by_parent(
195            &self,
196            _parent_coin_id: Bytes32,
197        ) -> Result<Vec<CoinRecord>, Self::Error> {
198            Ok(Vec::new())
199        }
200
201        fn coin_spend(&self, coin_id: Bytes32) -> Result<Option<CoinSpend>, Self::Error> {
202            // The spend that SPENT `coin_id` — the simulator holds its reveal + solution once spent.
203            let Some(state) = self.sim.coin_state(coin_id) else {
204                return Ok(None);
205            };
206            let (Some(reveal), Some(solution)) =
207                (self.sim.puzzle_reveal(coin_id), self.sim.solution(coin_id))
208            else {
209                return Ok(None);
210            };
211            Ok(Some(CoinSpend::new(state.coin, reveal, solution)))
212        }
213
214        fn resolve_singleton_lineage(
215            &self,
216            launcher_id: Bytes32,
217        ) -> Result<Option<SingletonLineage>, Self::Error> {
218            Ok(self.lineages.get(&launcher_id).cloned())
219        }
220
221        fn peak_height(&self) -> Result<Option<u32>, Self::Error> {
222            Ok(None)
223        }
224
225        fn block_timestamp(&self, _height: u32) -> Result<Option<u64>, Self::Error> {
226            Ok(None)
227        }
228    }
229
230    /// A single-coin lineage source for `launcher_id` with a chosen member set.
231    fn source_with<'a>(
232        sim: &'a Simulator,
233        launcher_id: Bytes32,
234        lineage: SingletonLineage,
235    ) -> SimSource<'a> {
236        SimSource {
237            sim,
238            lineages: HashMap::from([(launcher_id, lineage)]),
239        }
240    }
241
242    /// The full lineage of a freshly-created DID (launcher -> eve -> settled tip), derived from the
243    /// settled `Did` alone (`did.coin.parent_coin_info` is the eve coin id).
244    fn did_lineage(did: &Did) -> SingletonLineage {
245        SingletonLineage::new(
246            did.coin.coin_id(),
247            [
248                did.info.launcher_id(),
249                did.coin.parent_coin_info,
250                did.coin.coin_id(),
251            ],
252        )
253    }
254
255    #[test]
256    fn model_a_direct_proves_a_did_state() -> anyhow::Result<()> {
257        let mut sim = Simulator::new();
258        let ctx = &mut SpendContext::new();
259        let owner = sim.bls(1);
260
261        let spend = create_simple_did(ctx, owner.coin, Owner::Standard(owner.pk))?;
262        let did = spend.child.expect("create returns a child DID");
263        sim.spend_coins(spend.coin_spends, std::slice::from_ref(&owner.sk))?;
264
265        let launcher_id = did.info.launcher_id();
266        let source = source_with(&sim, launcher_id, did_lineage(&did));
267
268        let proof = prove_lineage(did.coin.coin_id(), &did, &source)?;
269        assert_eq!(proof.model(), LineageModel::Direct);
270        assert_eq!(proof.authenticated_launcher(), launcher_id);
271        assert_eq!(proof.did_launcher_id(), launcher_id);
272        assert_eq!(proof.coin_id(), did.coin.coin_id());
273        assert!(!proof.chain().is_empty());
274        Ok(())
275    }
276
277    #[test]
278    fn model_b_launched_from_proves_a_singleton_launched_by_the_did() -> anyhow::Result<()> {
279        let mut sim = Simulator::new();
280        let ctx = &mut SpendContext::new();
281        // Fund the DID with 3 mojos so its spend can create a launcher (even amount 2) AND recreate the
282        // DID (odd amount 1) — a singleton spend may emit only ONE odd child.
283        let owner = sim.bls(3);
284        let owner_p2 = StandardLayer::new(owner.pk);
285
286        let create = create_simple_did(ctx, owner.coin, Owner::Standard(owner.pk))?;
287        let did = create.child.expect("create returns a child DID");
288        sim.spend_coins(create.coin_spends, std::slice::from_ref(&owner.sk))?;
289
290        // The DID spend creates a launcher parented to the DID coin, and mints an eve singleton from it.
291        let launcher = Launcher::new(did.coin.coin_id(), 2).with_singleton_amount(1);
292        let launcher_id = launcher.coin().coin_id();
293        let (launch_conditions, eve_coin) = launcher.spend(ctx, owner.puzzle_hash, ())?;
294
295        let memos = ctx.hint(did.info.p2_puzzle_hash)?;
296        let did_spend_conditions =
297            launch_conditions.create_coin(did.info.inner_puzzle_hash().into(), 1, memos);
298        did.spend_with(ctx, &owner_p2, did_spend_conditions)?;
299        sim.spend_coins(ctx.take(), std::slice::from_ref(&owner.sk))?;
300
301        let source = source_with(&sim, did.info.launcher_id(), did_lineage(&did));
302
303        let proof = prove_lineage(eve_coin.coin_id(), &did, &source)?;
304        assert_eq!(
305            proof.model(),
306            LineageModel::LaunchedFrom {
307                launcher: launcher_id,
308                did_parent: did.coin.coin_id(),
309            }
310        );
311        assert_eq!(proof.authenticated_launcher(), launcher_id);
312        assert_eq!(proof.did_launcher_id(), did.info.launcher_id());
313        Ok(())
314    }
315
316    #[test]
317    fn payment_coin_parented_to_a_did_is_not_a_singleton() -> anyhow::Result<()> {
318        let mut sim = Simulator::new();
319        let ctx = &mut SpendContext::new();
320        let owner = sim.bls(3);
321        let owner_p2 = StandardLayer::new(owner.pk);
322
323        let create = create_simple_did(ctx, owner.coin, Owner::Standard(owner.pk))?;
324        let did = create.child.expect("create returns a child DID");
325        sim.spend_coins(create.coin_spends, std::slice::from_ref(&owner.sk))?;
326
327        // Spend the DID(3): recreate it (odd 1) and pay a PLAIN coin (even 2) to a standard puzzle. The
328        // payment's parent is a DID coin, but it is not a singleton — it must NOT prove as owned.
329        let memos = ctx.hint(did.info.p2_puzzle_hash)?;
330        let payment_puzzle_hash = owner.puzzle_hash;
331        let conditions = Conditions::new()
332            .create_coin(did.info.inner_puzzle_hash().into(), 1, memos)
333            .create_coin(payment_puzzle_hash, 2, Memos::None);
334        did.spend_with(ctx, &owner_p2, conditions)?;
335        sim.spend_coins(ctx.take(), std::slice::from_ref(&owner.sk))?;
336
337        let payment_coin = Coin::new(did.coin.coin_id(), payment_puzzle_hash, 2);
338        let source = source_with(&sim, did.info.launcher_id(), did_lineage(&did));
339
340        let error = prove_lineage(payment_coin.coin_id(), &did, &source).unwrap_err();
341        assert!(matches!(error, DidError::NotASingleton));
342        Ok(())
343    }
344
345    #[test]
346    fn attacker_singleton_from_attacker_coin_is_not_did_rooted() -> anyhow::Result<()> {
347        let mut sim = Simulator::new();
348        let ctx = &mut SpendContext::new();
349
350        let victim = sim.bls(1);
351        let victim_spend = create_simple_did(ctx, victim.coin, Owner::Standard(victim.pk))?;
352        let victim_did = victim_spend.child.expect("child DID");
353        sim.spend_coins(victim_spend.coin_spends, std::slice::from_ref(&victim.sk))?;
354
355        let attacker = sim.bls(1);
356        let attacker_spend = create_simple_did(ctx, attacker.coin, Owner::Standard(attacker.pk))?;
357        let attacker_did = attacker_spend.child.expect("child DID");
358        sim.spend_coins(
359            attacker_spend.coin_spends,
360            std::slice::from_ref(&attacker.sk),
361        )?;
362
363        // The victim's honest lineage — it does NOT contain any attacker coin.
364        let source = source_with(
365            &sim,
366            victim_did.info.launcher_id(),
367            did_lineage(&victim_did),
368        );
369
370        let error = prove_lineage(attacker_did.coin.coin_id(), &victim_did, &source).unwrap_err();
371        assert!(matches!(error, DidError::NotDidRooted));
372        Ok(())
373    }
374
375    #[test]
376    fn pay_to_coin_wearing_a_singleton_puzzle_hash_is_not_a_singleton() -> anyhow::Result<()> {
377        let mut sim = Simulator::new();
378        let ctx = &mut SpendContext::new();
379
380        let victim = sim.bls(1);
381        let victim_spend = create_simple_did(ctx, victim.coin, Owner::Standard(victim.pk))?;
382        let victim_did = victim_spend.child.expect("child DID");
383        sim.spend_coins(victim_spend.coin_spends, std::slice::from_ref(&victim.sk))?;
384
385        // A plain coin pays TO a puzzle hash equal to a singleton outer puzzle for the victim launcher,
386        // but its parent is an ordinary coin — there is NO genuine singleton recreation. This is WHY a
387        // bare puzzle-hash equality is forbidden: it must fail closed.
388        let alice = sim.bls(1);
389        let alice_p2 = StandardLayer::new(alice.pk);
390        let fake_singleton_puzzle_hash: Bytes32 =
391            SingletonArgs::curry_tree_hash(victim_did.info.launcher_id(), alice.puzzle_hash.into())
392                .into();
393        alice_p2.spend(
394            ctx,
395            alice.coin,
396            Conditions::new().create_coin(fake_singleton_puzzle_hash, 1, Memos::None),
397        )?;
398        sim.spend_coins(ctx.take(), std::slice::from_ref(&alice.sk))?;
399
400        let fake_coin = Coin::new(alice.coin.coin_id(), fake_singleton_puzzle_hash, 1);
401        let source = source_with(
402            &sim,
403            victim_did.info.launcher_id(),
404            did_lineage(&victim_did),
405        );
406
407        let error = prove_lineage(fake_coin.coin_id(), &victim_did, &source).unwrap_err();
408        assert!(matches!(error, DidError::NotASingleton));
409        Ok(())
410    }
411
412    #[test]
413    fn melted_did_has_no_identity_singleton() -> anyhow::Result<()> {
414        let mut sim = Simulator::new();
415        let ctx = &mut SpendContext::new();
416        let owner = sim.bls(1);
417
418        let spend = create_simple_did(ctx, owner.coin, Owner::Standard(owner.pk))?;
419        let did = spend.child.expect("child DID");
420        sim.spend_coins(spend.coin_spends, std::slice::from_ref(&owner.sk))?;
421
422        // The source reports NO lineage for the DID launcher (unlaunched or melted).
423        let source = SimSource {
424            sim: &sim,
425            lineages: HashMap::new(),
426        };
427
428        let error = prove_lineage(did.coin.coin_id(), &did, &source).unwrap_err();
429        assert!(matches!(error, DidError::NoIdentitySingleton));
430        Ok(())
431    }
432
433    #[test]
434    fn an_over_deep_lineage_fails_closed() -> anyhow::Result<()> {
435        let mut sim = Simulator::new();
436        let ctx = &mut SpendContext::new();
437        let owner = sim.bls(1);
438
439        let spend = create_simple_did(ctx, owner.coin, Owner::Standard(owner.pk))?;
440        let did = spend.child.expect("child DID");
441        sim.spend_coins(spend.coin_spends, std::slice::from_ref(&owner.sk))?;
442
443        let source = source_with(&sim, did.info.launcher_id(), did_lineage(&did));
444
445        // The settled DID needs two hops to reach its launcher (settled -> eve -> launcher). A depth
446        // bound of 1 must fail closed rather than walk further.
447        let error = authenticate_singleton_bounded(did.coin.coin_id(), &source, 1).unwrap_err();
448        assert!(matches!(error, DidError::LineageTooDeep));
449        Ok(())
450    }
451
452    #[test]
453    fn walk_did_lineage_to_tip_reconstructs_the_current_did() -> anyhow::Result<()> {
454        let mut sim = Simulator::new();
455        let ctx = &mut SpendContext::new();
456        let owner = sim.bls(1);
457
458        let spend = create_simple_did(ctx, owner.coin, Owner::Standard(owner.pk))?;
459        let did = spend.child.expect("child DID");
460        sim.spend_coins(spend.coin_spends, std::slice::from_ref(&owner.sk))?;
461
462        let source = source_with(&sim, did.info.launcher_id(), did_lineage(&did));
463
464        let tip = crate::resolve::walk_did_lineage_to_tip(&source, did.info.launcher_id())?
465            .expect("a launched DID has a tip");
466        assert_eq!(tip.coin.coin_id(), did.coin.coin_id());
467        assert_eq!(tip.info.launcher_id(), did.info.launcher_id());
468        assert_eq!(tip.did(), did);
469        Ok(())
470    }
471
472    #[test]
473    fn walk_did_lineage_to_tip_returns_none_when_absent() -> anyhow::Result<()> {
474        let sim = Simulator::new();
475        let source = SimSource {
476            sim: &sim,
477            lineages: HashMap::new(),
478        };
479        assert!(
480            crate::resolve::walk_did_lineage_to_tip(&source, Bytes32::new([1u8; 32]))?.is_none()
481        );
482        Ok(())
483    }
484}