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