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)]
150mod tests {
151    use super::*;
152    use std::collections::HashMap;
153
154    use chia_protocol::{Coin, CoinSpend};
155    use chia_puzzle_types::singleton::SingletonArgs;
156    use chia_puzzle_types::Memos;
157    use chia_wallet_sdk::driver::{Launcher, SpendContext, StandardLayer};
158    use chia_wallet_sdk::test::Simulator;
159    use chia_wallet_sdk::types::Conditions;
160    use dig_chainsource_interface::CoinRecord;
161
162    use crate::create::create_simple_did;
163    use crate::resolve::{authenticate_singleton_bounded, SingletonLineage};
164    use crate::types::Owner;
165
166    /// An honest chain view for tests: the real in-process Simulator answers `parent_spend` (the
167    /// creating spend of any coin), and a per-launcher lineage map answers `resolve_singleton_lineage`.
168    struct SimSource<'a> {
169        sim: &'a Simulator,
170        lineages: HashMap<Bytes32, SingletonLineage>,
171    }
172
173    impl ChainSource for SimSource<'_> {
174        type Error = String;
175
176        fn coin_record(&self, coin_id: Bytes32) -> Result<Option<CoinRecord>, Self::Error> {
177            Ok(self.sim.coin_state(coin_id).map(CoinRecord::from))
178        }
179
180        fn coin_records_by_puzzle_hash(
181            &self,
182            _puzzle_hash: Bytes32,
183            _include_spent: bool,
184        ) -> Result<Vec<CoinRecord>, Self::Error> {
185            // dig-did's lineage logic never queries by puzzle hash; the honest simulator source only
186            // needs the parent-walk + lineage reads below.
187            Ok(Vec::new())
188        }
189
190        fn coin_records_by_parent(
191            &self,
192            _parent_coin_id: Bytes32,
193        ) -> Result<Vec<CoinRecord>, Self::Error> {
194            Ok(Vec::new())
195        }
196
197        fn coin_spend(&self, coin_id: Bytes32) -> Result<Option<CoinSpend>, Self::Error> {
198            // The spend that SPENT `coin_id` — the simulator holds its reveal + solution once spent.
199            let Some(state) = self.sim.coin_state(coin_id) else {
200                return Ok(None);
201            };
202            let (Some(reveal), Some(solution)) =
203                (self.sim.puzzle_reveal(coin_id), self.sim.solution(coin_id))
204            else {
205                return Ok(None);
206            };
207            Ok(Some(CoinSpend::new(state.coin, reveal, solution)))
208        }
209
210        fn resolve_singleton_lineage(
211            &self,
212            launcher_id: Bytes32,
213        ) -> Result<Option<SingletonLineage>, Self::Error> {
214            Ok(self.lineages.get(&launcher_id).cloned())
215        }
216
217        fn peak_height(&self) -> Result<Option<u32>, Self::Error> {
218            Ok(None)
219        }
220
221        fn block_timestamp(&self, _height: u32) -> Result<Option<u64>, Self::Error> {
222            Ok(None)
223        }
224    }
225
226    /// A single-coin lineage source for `launcher_id` with a chosen member set.
227    fn source_with<'a>(
228        sim: &'a Simulator,
229        launcher_id: Bytes32,
230        lineage: SingletonLineage,
231    ) -> SimSource<'a> {
232        SimSource {
233            sim,
234            lineages: HashMap::from([(launcher_id, lineage)]),
235        }
236    }
237
238    /// The full lineage of a freshly-created DID (launcher -> eve -> settled tip), derived from the
239    /// settled `Did` alone (`did.coin.parent_coin_info` is the eve coin id).
240    fn did_lineage(did: &Did) -> SingletonLineage {
241        SingletonLineage::new(
242            did.coin.coin_id(),
243            [
244                did.info.launcher_id(),
245                did.coin.parent_coin_info,
246                did.coin.coin_id(),
247            ],
248        )
249    }
250
251    #[test]
252    fn model_a_direct_proves_a_did_state() -> anyhow::Result<()> {
253        let mut sim = Simulator::new();
254        let ctx = &mut SpendContext::new();
255        let owner = sim.bls(1);
256
257        let spend = create_simple_did(ctx, owner.coin, Owner::Standard(owner.pk))?;
258        let did = spend.child.expect("create returns a child DID");
259        sim.spend_coins(spend.coin_spends, std::slice::from_ref(&owner.sk))?;
260
261        let launcher_id = did.info.launcher_id();
262        let source = source_with(&sim, launcher_id, did_lineage(&did));
263
264        let proof = prove_lineage(did.coin.coin_id(), &did, &source)?;
265        assert_eq!(proof.model(), LineageModel::Direct);
266        assert_eq!(proof.authenticated_launcher(), launcher_id);
267        assert_eq!(proof.did_launcher_id(), launcher_id);
268        assert_eq!(proof.coin_id(), did.coin.coin_id());
269        assert!(!proof.chain().is_empty());
270        Ok(())
271    }
272
273    #[test]
274    fn model_b_launched_from_proves_a_singleton_launched_by_the_did() -> anyhow::Result<()> {
275        let mut sim = Simulator::new();
276        let ctx = &mut SpendContext::new();
277        // Fund the DID with 3 mojos so its spend can create a launcher (even amount 2) AND recreate the
278        // DID (odd amount 1) — a singleton spend may emit only ONE odd child.
279        let owner = sim.bls(3);
280        let owner_p2 = StandardLayer::new(owner.pk);
281
282        let create = create_simple_did(ctx, owner.coin, Owner::Standard(owner.pk))?;
283        let did = create.child.expect("create returns a child DID");
284        sim.spend_coins(create.coin_spends, std::slice::from_ref(&owner.sk))?;
285
286        // The DID spend creates a launcher parented to the DID coin, and mints an eve singleton from it.
287        let launcher = Launcher::new(did.coin.coin_id(), 2).with_singleton_amount(1);
288        let launcher_id = launcher.coin().coin_id();
289        let (launch_conditions, eve_coin) = launcher.spend(ctx, owner.puzzle_hash, ())?;
290
291        let memos = ctx.hint(did.info.p2_puzzle_hash)?;
292        let did_spend_conditions =
293            launch_conditions.create_coin(did.info.inner_puzzle_hash().into(), 1, memos);
294        did.spend_with(ctx, &owner_p2, did_spend_conditions)?;
295        sim.spend_coins(ctx.take(), std::slice::from_ref(&owner.sk))?;
296
297        let source = source_with(&sim, did.info.launcher_id(), did_lineage(&did));
298
299        let proof = prove_lineage(eve_coin.coin_id(), &did, &source)?;
300        assert_eq!(
301            proof.model(),
302            LineageModel::LaunchedFrom {
303                launcher: launcher_id,
304                did_parent: did.coin.coin_id(),
305            }
306        );
307        assert_eq!(proof.authenticated_launcher(), launcher_id);
308        assert_eq!(proof.did_launcher_id(), did.info.launcher_id());
309        Ok(())
310    }
311
312    #[test]
313    fn payment_coin_parented_to_a_did_is_not_a_singleton() -> anyhow::Result<()> {
314        let mut sim = Simulator::new();
315        let ctx = &mut SpendContext::new();
316        let owner = sim.bls(3);
317        let owner_p2 = StandardLayer::new(owner.pk);
318
319        let create = create_simple_did(ctx, owner.coin, Owner::Standard(owner.pk))?;
320        let did = create.child.expect("create returns a child DID");
321        sim.spend_coins(create.coin_spends, std::slice::from_ref(&owner.sk))?;
322
323        // Spend the DID(3): recreate it (odd 1) and pay a PLAIN coin (even 2) to a standard puzzle. The
324        // payment's parent is a DID coin, but it is not a singleton — it must NOT prove as owned.
325        let memos = ctx.hint(did.info.p2_puzzle_hash)?;
326        let payment_puzzle_hash = owner.puzzle_hash;
327        let conditions = Conditions::new()
328            .create_coin(did.info.inner_puzzle_hash().into(), 1, memos)
329            .create_coin(payment_puzzle_hash, 2, Memos::None);
330        did.spend_with(ctx, &owner_p2, conditions)?;
331        sim.spend_coins(ctx.take(), std::slice::from_ref(&owner.sk))?;
332
333        let payment_coin = Coin::new(did.coin.coin_id(), payment_puzzle_hash, 2);
334        let source = source_with(&sim, did.info.launcher_id(), did_lineage(&did));
335
336        let error = prove_lineage(payment_coin.coin_id(), &did, &source).unwrap_err();
337        assert!(matches!(error, DidError::NotASingleton));
338        Ok(())
339    }
340
341    #[test]
342    fn attacker_singleton_from_attacker_coin_is_not_did_rooted() -> anyhow::Result<()> {
343        let mut sim = Simulator::new();
344        let ctx = &mut SpendContext::new();
345
346        let victim = sim.bls(1);
347        let victim_spend = create_simple_did(ctx, victim.coin, Owner::Standard(victim.pk))?;
348        let victim_did = victim_spend.child.expect("child DID");
349        sim.spend_coins(victim_spend.coin_spends, std::slice::from_ref(&victim.sk))?;
350
351        let attacker = sim.bls(1);
352        let attacker_spend = create_simple_did(ctx, attacker.coin, Owner::Standard(attacker.pk))?;
353        let attacker_did = attacker_spend.child.expect("child DID");
354        sim.spend_coins(
355            attacker_spend.coin_spends,
356            std::slice::from_ref(&attacker.sk),
357        )?;
358
359        // The victim's honest lineage — it does NOT contain any attacker coin.
360        let source = source_with(
361            &sim,
362            victim_did.info.launcher_id(),
363            did_lineage(&victim_did),
364        );
365
366        let error = prove_lineage(attacker_did.coin.coin_id(), &victim_did, &source).unwrap_err();
367        assert!(matches!(error, DidError::NotDidRooted));
368        Ok(())
369    }
370
371    #[test]
372    fn pay_to_coin_wearing_a_singleton_puzzle_hash_is_not_a_singleton() -> anyhow::Result<()> {
373        let mut sim = Simulator::new();
374        let ctx = &mut SpendContext::new();
375
376        let victim = sim.bls(1);
377        let victim_spend = create_simple_did(ctx, victim.coin, Owner::Standard(victim.pk))?;
378        let victim_did = victim_spend.child.expect("child DID");
379        sim.spend_coins(victim_spend.coin_spends, std::slice::from_ref(&victim.sk))?;
380
381        // A plain coin pays TO a puzzle hash equal to a singleton outer puzzle for the victim launcher,
382        // but its parent is an ordinary coin — there is NO genuine singleton recreation. This is WHY a
383        // bare puzzle-hash equality is forbidden: it must fail closed.
384        let alice = sim.bls(1);
385        let alice_p2 = StandardLayer::new(alice.pk);
386        let fake_singleton_puzzle_hash: Bytes32 =
387            SingletonArgs::curry_tree_hash(victim_did.info.launcher_id(), alice.puzzle_hash.into())
388                .into();
389        alice_p2.spend(
390            ctx,
391            alice.coin,
392            Conditions::new().create_coin(fake_singleton_puzzle_hash, 1, Memos::None),
393        )?;
394        sim.spend_coins(ctx.take(), std::slice::from_ref(&alice.sk))?;
395
396        let fake_coin = Coin::new(alice.coin.coin_id(), fake_singleton_puzzle_hash, 1);
397        let source = source_with(
398            &sim,
399            victim_did.info.launcher_id(),
400            did_lineage(&victim_did),
401        );
402
403        let error = prove_lineage(fake_coin.coin_id(), &victim_did, &source).unwrap_err();
404        assert!(matches!(error, DidError::NotASingleton));
405        Ok(())
406    }
407
408    #[test]
409    fn melted_did_has_no_identity_singleton() -> anyhow::Result<()> {
410        let mut sim = Simulator::new();
411        let ctx = &mut SpendContext::new();
412        let owner = sim.bls(1);
413
414        let spend = create_simple_did(ctx, owner.coin, Owner::Standard(owner.pk))?;
415        let did = spend.child.expect("child DID");
416        sim.spend_coins(spend.coin_spends, std::slice::from_ref(&owner.sk))?;
417
418        // The source reports NO lineage for the DID launcher (unlaunched or melted).
419        let source = SimSource {
420            sim: &sim,
421            lineages: HashMap::new(),
422        };
423
424        let error = prove_lineage(did.coin.coin_id(), &did, &source).unwrap_err();
425        assert!(matches!(error, DidError::NoIdentitySingleton));
426        Ok(())
427    }
428
429    #[test]
430    fn an_over_deep_lineage_fails_closed() -> anyhow::Result<()> {
431        let mut sim = Simulator::new();
432        let ctx = &mut SpendContext::new();
433        let owner = sim.bls(1);
434
435        let spend = create_simple_did(ctx, owner.coin, Owner::Standard(owner.pk))?;
436        let did = spend.child.expect("child DID");
437        sim.spend_coins(spend.coin_spends, std::slice::from_ref(&owner.sk))?;
438
439        let source = source_with(&sim, did.info.launcher_id(), did_lineage(&did));
440
441        // The settled DID needs two hops to reach its launcher (settled -> eve -> launcher). A depth
442        // bound of 1 must fail closed rather than walk further.
443        let error = authenticate_singleton_bounded(did.coin.coin_id(), &source, 1).unwrap_err();
444        assert!(matches!(error, DidError::LineageTooDeep));
445        Ok(())
446    }
447
448    #[test]
449    fn walk_did_lineage_to_tip_reconstructs_the_current_did() -> anyhow::Result<()> {
450        let mut sim = Simulator::new();
451        let ctx = &mut SpendContext::new();
452        let owner = sim.bls(1);
453
454        let spend = create_simple_did(ctx, owner.coin, Owner::Standard(owner.pk))?;
455        let did = spend.child.expect("child DID");
456        sim.spend_coins(spend.coin_spends, std::slice::from_ref(&owner.sk))?;
457
458        let source = source_with(&sim, did.info.launcher_id(), did_lineage(&did));
459
460        let tip = crate::resolve::walk_did_lineage_to_tip(&source, did.info.launcher_id())?
461            .expect("a launched DID has a tip");
462        assert_eq!(tip.coin.coin_id(), did.coin.coin_id());
463        assert_eq!(tip.info.launcher_id(), did.info.launcher_id());
464        assert_eq!(tip.did(), did);
465        Ok(())
466    }
467
468    #[test]
469    fn walk_did_lineage_to_tip_returns_none_when_absent() -> anyhow::Result<()> {
470        let sim = Simulator::new();
471        let source = SimSource {
472            sim: &sim,
473            lineages: HashMap::new(),
474        };
475        assert!(
476            crate::resolve::walk_did_lineage_to_tip(&source, Bytes32::new([1u8; 32]))?.is_none()
477        );
478        Ok(())
479    }
480}