Skip to main content

dig_did/
resolve.rs

1//! The chain-reading seam and the singleton lineage-authentication core (SPEC §5 & §10).
2//!
3//! dig-did performs NO network or chain I/O (INV-1). Yet authenticating that a coin is a genuine
4//! singleton — and rooting it in a DID's identity — requires *reading* chain state (a coin's creating
5//! spend, a DID singleton's lineage). [`ChainSource`] is the seam that squares that circle: the caller
6//! supplies an honest READER of chain state (a full node / coinset client / `chia-query`), and dig-did
7//! supplies ALL the trust logic on top. Reads are not broadcasts — this keeps the crate no-network and
8//! wasm-buildable while still proving lineage.
9//!
10//! ## Why the walk, and why puzzle-hash equality is NOT enough (the soundness crux)
11//!
12//! A Chia coin's `puzzle_hash` is attacker-chosen: anyone can pay-to a coin whose puzzle hash equals a
13//! singleton's outer puzzle hash for a victim launcher. Such a coin is NOT a singleton — it has no
14//! genuine recreation history. To authenticate a coin as a real singleton this module WALKS the
15//! parent-spend chain: for each step it parses the parent's puzzle with the SDK's [`SingletonLayer`]
16//! (proving the parent is itself a singleton and reading its *curried* `launcher_id`), RUNS the parent's
17//! inner puzzle to derive the exact singleton successor it creates, and requires that successor to be
18//! the child under authentication (binding amount parity + the singleton curry). The walk terminates at
19//! the singleton LAUNCHER coin, yielding an AUTHENTICATED `launcher_id`. A coin whose parent chain does
20//! not resolve this way is [`DidError::NotASingleton`] — never trusted on a bare puzzle hash or a bare
21//! `parent_coin_info`.
22//!
23//! ## Trust model
24//!
25//! The [`ChainSource`] MUST be the caller's OWN honest view of the chain, not an attacker-controlled
26//! channel. dig-did assumes the source reports real chain state; it cannot defend against a source that
27//! fabricates the chain itself. Its job is to ensure that, given honest chain data, no coin can launder
28//! itself into a DID's authority (see the adversarial tests). Every read failure or gap fails CLOSED —
29//! an error, never an "assume owned" default.
30
31use std::collections::BTreeSet;
32
33use chia_protocol::{Bytes32, Coin, CoinSpend, Program};
34use chia_puzzle_types::singleton::SingletonArgs;
35use chia_puzzle_types::Proof;
36use chia_puzzles::SINGLETON_LAUNCHER_HASH;
37use chia_wallet_sdk::driver::{Did, DidInfo, Layer, Puzzle, SingletonLayer};
38use chia_wallet_sdk::prelude::{Allocator, NodePtr};
39use chia_wallet_sdk::types::{run_puzzle, Condition};
40use clvm_traits::{FromClvm, ToClvm};
41use clvm_utils::TreeHash;
42
43use crate::error::{DidError, DidResult};
44
45/// The maximum number of parent-spend hops the singleton walk will follow before failing closed with
46/// [`DidError::LineageTooDeep`].
47///
48/// A genuine singleton's lineage grows by one coin per spend; a DID under active use might accumulate
49/// thousands of states over its lifetime, so the bound is generous. Its purpose is purely a DoS guard:
50/// a malicious [`ChainSource`] must not be able to make the walk loop unboundedly.
51pub const MAX_LINEAGE_DEPTH: usize = 100_000;
52
53/// A caller-supplied, honest READER of Chia chain state — the seam that keeps dig-did network-free
54/// (INV-1) while still authenticating on-chain lineage.
55///
56/// A consumer (dig-node, dig-chat, the extension, hub) implements this over its own chain backend
57/// (coinset.org, a local full node, `chia-query`). dig-did supplies all the trust logic on top; the
58/// source only fetches. See the module trust model — the source MUST be honest chain data and is never
59/// treated as a source of authority claims.
60pub trait ChainSource {
61    /// The source's own fetch/transport error, surfaced verbatim through [`DidError::Chain`].
62    type Error: core::fmt::Display;
63
64    /// Walks the singleton lineage from `launcher_id` to its current unspent tip, returning EVERY coin
65    /// id on that walk as a [`SingletonLineage`].
66    ///
67    /// Returns `None` when the launcher never existed or the singleton has been fully spent (melted).
68    /// The returned lineage is trusted as the DID singleton's authentic lineage — so this MUST be a
69    /// genuine forward walk from the DID launcher to its tip (each coin the singleton recreation of the
70    /// previous), NEVER an echo of a caller-supplied coin. The caller implements the walk against its
71    /// own chain backend.
72    fn resolve_singleton_lineage(
73        &self,
74        launcher_id: Bytes32,
75    ) -> Result<Option<SingletonLineage>, Self::Error>;
76
77    /// Returns the coin spend that CREATED `coin_id` — i.e. the spend of `coin_id`'s parent coin —
78    /// or `None` when no such spend is known (an unspent-parent / coinbase / genesis edge).
79    ///
80    /// This is the single primitive the singleton-authentication walk consumes: given a coin, it reads
81    /// the parent's puzzle reveal + solution, proves the parent is a singleton (or the launcher), and
82    /// derives the successor the parent created. The source only fetches; it performs NO authentication.
83    fn parent_spend(&self, coin_id: Bytes32) -> Result<Option<CoinSpend>, Self::Error>;
84}
85
86/// The lineage of a DID identity singleton: every coin id from the launcher spend forward to the
87/// current unspent tip.
88///
89/// Authority is MEMBERSHIP in this lineage, not equality with the tip: a coin launched from ANY genuine
90/// DID coin — the launch-time coin `Cn`, later spent to `Cn+1` — is rooted in the DID, while an
91/// attacker's coin (never a member, since minting any lineage coin requires the DID's key) is not. This
92/// is byte-coherent with dig-identity's `SingletonLineage` so the two crates can later de-duplicate.
93#[derive(Debug, Clone, PartialEq, Eq)]
94pub struct SingletonLineage {
95    /// The current unspent singleton tip coin id (the DID's current on-chain state handle).
96    tip: Bytes32,
97    /// Every coin id in the lineage (launcher -> tip inclusive). Always contains `tip`.
98    members: BTreeSet<Bytes32>,
99}
100
101impl SingletonLineage {
102    /// Builds a lineage from its full member set and current `tip`. `tip` is always treated as a
103    /// member, so a caller need not include it in `members` explicitly.
104    pub fn new(tip: Bytes32, members: impl IntoIterator<Item = Bytes32>) -> Self {
105        let mut members: BTreeSet<Bytes32> = members.into_iter().collect();
106        members.insert(tip);
107        Self { tip, members }
108    }
109
110    /// A degenerate single-coin lineage (the tip is the only member) — a DID never spent since launch.
111    pub fn single(tip: Bytes32) -> Self {
112        Self::new(tip, [tip])
113    }
114
115    /// The current unspent singleton tip coin id.
116    pub fn tip(&self) -> Bytes32 {
117        self.tip
118    }
119
120    /// Whether `coin_id` is a genuine coin in this singleton's lineage — the authority membership test.
121    pub fn contains(&self, coin_id: Bytes32) -> bool {
122        self.members.contains(&coin_id)
123    }
124
125    /// The number of coins in the lineage (launcher -> tip inclusive).
126    pub fn len(&self) -> usize {
127        self.members.len()
128    }
129
130    /// Whether the lineage has no members. Always `false` for a well-formed lineage (the tip is a
131    /// member), but provided so `len`/`is_empty` are consistent for lints and callers.
132    pub fn is_empty(&self) -> bool {
133        self.members.is_empty()
134    }
135}
136
137/// The current unspent tip of a DID singleton, reconstructed from chain reads: the tip coin, its
138/// [`DidInfo`], and the lineage [`Proof`] needed to spend it.
139///
140/// This is the output of [`walk_did_lineage_to_tip`] — a ready-to-inspect (and, with an inner spend,
141/// ready-to-spend) [`Did`]-shaped view of a DID's current on-chain state.
142#[derive(Debug, Clone)]
143pub struct DidTip {
144    /// The DID singleton's current unspent tip coin.
145    pub coin: Coin,
146    /// The DID's outer-puzzle fields at the tip (launcher id, recovery config, metadata, owner p2 hash).
147    pub info: DidInfo,
148    /// The lineage proof binding the tip to its parent — required in the tip's spend solution.
149    pub proof: Proof,
150}
151
152impl DidTip {
153    /// Projects this tip into the SDK's spendable [`Did`] (`Singleton<DidInfo>`).
154    pub fn did(&self) -> Did {
155        Did::new(self.coin, self.proof, self.info)
156    }
157}
158
159/// The authenticated result of the singleton walk: the launcher a coin genuinely descends from, plus
160/// the launcher coin itself (whose `parent_coin_info` is the coin that CREATED the launcher — the
161/// launch-from-DID link for [`LineageModel::LaunchedFrom`]).
162#[derive(Debug)]
163pub(crate) struct AuthenticatedLineage {
164    /// The launcher id the walked coin provably descends from (the curry commitment == the launcher).
165    pub(crate) launcher_id: Bytes32,
166    /// The launcher coin the walk terminated at. `launcher_coin.parent_coin_info` is the coin that
167    /// created the launcher.
168    pub(crate) launcher_coin: Coin,
169    /// The coin ids walked, from the coin under proof up to (and including) the launcher — an audit
170    /// trail carried into [`crate::lineage::AncestryProof`].
171    pub(crate) trail: Vec<Bytes32>,
172}
173
174/// Authenticates `coin_id` as a genuine singleton by walking its parent-spend chain to the launcher.
175///
176/// See the module docs for WHY this walk (not a puzzle-hash check) is the only sound authentication.
177/// Fails closed with [`DidError::NotASingleton`] on any break in the singleton structure, and
178/// [`DidError::LineageTooDeep`] past [`MAX_LINEAGE_DEPTH`]. Read failures propagate as
179/// [`DidError::Chain`].
180pub(crate) fn authenticate_singleton<S: ChainSource>(
181    coin_id: Bytes32,
182    source: &S,
183) -> DidResult<AuthenticatedLineage> {
184    authenticate_singleton_bounded(coin_id, source, MAX_LINEAGE_DEPTH)
185}
186
187/// [`authenticate_singleton`] with an explicit depth bound — the DoS guard, factored out so the
188/// [`DidError::LineageTooDeep`] behaviour can be exercised over a real (short) chain with a tiny bound.
189pub(crate) fn authenticate_singleton_bounded<S: ChainSource>(
190    coin_id: Bytes32,
191    source: &S,
192    max_depth: usize,
193) -> DidResult<AuthenticatedLineage> {
194    let mut allocator = Allocator::new();
195    let mut trail = vec![coin_id];
196    let mut current = coin_id;
197    // The launcher id every singleton parent must agree on — captured from the first singleton parent
198    // and re-checked at every subsequent hop and at the terminal launcher.
199    let mut expected_launcher: Option<Bytes32> = None;
200
201    for _hop in 0..max_depth {
202        let spend = source
203            .parent_spend(current)
204            .map_err(chain_error)?
205            .ok_or(DidError::NotASingleton)?;
206        let parent = spend.coin;
207        let (parent_puzzle, parent_solution) = parse_spend(&mut allocator, &spend)?;
208
209        // Terminal: the parent is the singleton launcher, so `current` is the eve singleton.
210        if parent.puzzle_hash == SINGLETON_LAUNCHER_HASH.into() {
211            let launcher_id = parent.coin_id();
212            if let Some(expected) = expected_launcher {
213                require(expected == launcher_id)?;
214            }
215            require(launcher_creates(
216                &mut allocator,
217                parent,
218                parent_puzzle,
219                parent_solution,
220                current,
221            )?)?;
222            return Ok(AuthenticatedLineage {
223                launcher_id,
224                launcher_coin: parent,
225                trail,
226            });
227        }
228
229        // Otherwise the parent must itself be a genuine singleton that recreates `current`.
230        let layer = SingletonLayer::<Puzzle>::parse_puzzle(&allocator, parent_puzzle)
231            .map_err(DidError::Driver)?
232            .ok_or(DidError::NotASingleton)?;
233        if let Some(expected) = expected_launcher {
234            require(expected == layer.launcher_id)?;
235        }
236        expected_launcher = Some(layer.launcher_id);
237
238        let successor = singleton_successor(&mut allocator, parent, &layer, parent_solution)?
239            .ok_or(DidError::NotASingleton)?;
240        require(successor.coin_id() == current)?;
241
242        trail.push(parent.coin_id());
243        current = parent.coin_id();
244    }
245
246    Err(DidError::LineageTooDeep)
247}
248
249/// Reconstructs the exact singleton successor coin that `parent` (a singleton for `layer.launcher_id`)
250/// creates, by running its inner puzzle and re-wrapping the odd-amount successor in the singleton curry.
251///
252/// Returns `None` when the spend emits no odd-amount successor (a melt / no child). The returned coin's
253/// puzzle hash is COMPUTED from the launcher id and the successor's inner puzzle hash — it is never read
254/// from an untrusted field, which is what makes the authentication sound.
255fn singleton_successor(
256    allocator: &mut Allocator,
257    parent: Coin,
258    layer: &SingletonLayer<Puzzle>,
259    parent_solution: NodePtr,
260) -> DidResult<Option<Coin>> {
261    let solution = SingletonLayer::<Puzzle>::parse_solution(allocator, parent_solution)
262        .map_err(DidError::Driver)?;
263    let output = run_puzzle(allocator, layer.inner_puzzle.ptr(), solution.inner_solution)
264        .map_err(|error| DidError::Parse(error.to_string()))?;
265    let conditions = Vec::<Condition>::from_clvm(allocator, output)
266        .map_err(|e| DidError::Parse(e.to_string()))?;
267
268    let Some(create_coin) = conditions
269        .into_iter()
270        .filter_map(Condition::into_create_coin)
271        .find(|create_coin| create_coin.amount % 2 == 1)
272    else {
273        return Ok(None);
274    };
275
276    let inner_hash: TreeHash = create_coin.puzzle_hash.into();
277    let full_puzzle_hash = SingletonArgs::curry_tree_hash(layer.launcher_id, inner_hash);
278    Ok(Some(Coin::new(
279        parent.coin_id(),
280        full_puzzle_hash.into(),
281        create_coin.amount,
282    )))
283}
284
285/// Whether the launcher coin's spend creates exactly the eve coin `eve_id`.
286///
287/// The launcher's `CREATE_COIN` puzzle hash is already the eve's full (singleton-wrapped) puzzle hash,
288/// so the eve coin is reconstructed directly and its id compared. This binds the eve to a genuine
289/// launcher spend rather than a claimed parent.
290fn launcher_creates(
291    allocator: &mut Allocator,
292    launcher: Coin,
293    launcher_puzzle: Puzzle,
294    launcher_solution: NodePtr,
295    eve_id: Bytes32,
296) -> DidResult<bool> {
297    let output = run_puzzle(allocator, launcher_puzzle.ptr(), launcher_solution)
298        .map_err(|error| DidError::Parse(error.to_string()))?;
299    let conditions = Vec::<Condition>::from_clvm(allocator, output)
300        .map_err(|e| DidError::Parse(e.to_string()))?;
301
302    Ok(conditions
303        .into_iter()
304        .filter_map(Condition::into_create_coin)
305        .any(|create_coin| {
306            Coin::new(
307                launcher.coin_id(),
308                create_coin.puzzle_hash,
309                create_coin.amount,
310            )
311            .coin_id()
312                == eve_id
313        }))
314}
315
316/// Walks a DID singleton forward to its current unspent tip, reconstructing it as a [`DidTip`].
317///
318/// Consolidates dig-identity's lineage half against this crate's [`ChainSource`]: it resolves the DID's
319/// lineage tip via [`ChainSource::resolve_singleton_lineage`], reads the spend that created the tip, and
320/// parses the tip DID with the SDK ([`Did::parse_child`], INV-4). Returns `None` when the DID has no
321/// current on-chain coin (unlaunched or melted). Fails closed with [`DidError::NotDid`] when the tip's
322/// creating spend does not parse as a DID (e.g. the tip is a bare eve whose parent is the launcher).
323pub fn walk_did_lineage_to_tip<S: ChainSource>(
324    source: &S,
325    launcher_id: Bytes32,
326) -> DidResult<Option<DidTip>> {
327    let Some(lineage) = source
328        .resolve_singleton_lineage(launcher_id)
329        .map_err(chain_error)?
330    else {
331        return Ok(None);
332    };
333    let tip_id = lineage.tip();
334
335    let spend = source
336        .parent_spend(tip_id)
337        .map_err(chain_error)?
338        .ok_or(DidError::NoIdentitySingleton)?;
339    let parent = spend.coin;
340
341    let mut allocator = Allocator::new();
342    let (parent_puzzle, parent_solution) = parse_spend(&mut allocator, &spend)?;
343
344    // Reconstruct the tip coin from the parent's genuine singleton successor.
345    let layer = SingletonLayer::<Puzzle>::parse_puzzle(&allocator, parent_puzzle)
346        .map_err(DidError::Driver)?
347        .ok_or(DidError::NotDid)?;
348    let tip_coin = singleton_successor(&mut allocator, parent, &layer, parent_solution)?
349        .filter(|coin| coin.coin_id() == tip_id)
350        .ok_or(DidError::NotDid)?;
351
352    let did = Did::parse_child(
353        &mut allocator,
354        parent,
355        parent_puzzle,
356        parent_solution,
357        tip_coin,
358    )
359    .map_err(DidError::Driver)?
360    .ok_or(DidError::NotDid)?;
361
362    Ok(Some(DidTip {
363        coin: did.coin,
364        info: did.info,
365        proof: did.proof,
366    }))
367}
368
369/// Deserializes a [`CoinSpend`]'s puzzle reveal and solution into the allocator, returning the parsed
370/// [`Puzzle`] and the solution [`NodePtr`].
371fn parse_spend(allocator: &mut Allocator, spend: &CoinSpend) -> DidResult<(Puzzle, NodePtr)> {
372    let puzzle_ptr = alloc_program(allocator, &spend.puzzle_reveal)?;
373    let solution_ptr = alloc_program(allocator, &spend.solution)?;
374    Ok((Puzzle::parse(allocator, puzzle_ptr), solution_ptr))
375}
376
377/// Deserializes a [`Program`] (a puzzle reveal or solution) into an allocated [`NodePtr`].
378fn alloc_program(allocator: &mut Allocator, program: &Program) -> DidResult<NodePtr> {
379    program
380        .to_clvm(allocator)
381        .map_err(|error| DidError::Parse(error.to_string()))
382}
383
384/// Fails closed with [`DidError::NotASingleton`] unless `condition` holds — the single-line guard the
385/// singleton walk uses so every structural break maps to the same "not a singleton" verdict.
386fn require(condition: bool) -> DidResult<()> {
387    condition.then_some(()).ok_or(DidError::NotASingleton)
388}
389
390/// Wraps a source-specific error into [`DidError::Chain`] without requiring `S::Error: 'static`.
391fn chain_error<E: core::fmt::Display>(error: E) -> DidError {
392    DidError::Chain(error.to_string())
393}
394
395#[cfg(test)]
396mod tests {
397    use super::*;
398
399    #[test]
400    fn lineage_membership_includes_tip_and_ancestors() {
401        let launcher = Bytes32::new([1u8; 32]);
402        let cn = Bytes32::new([2u8; 32]);
403        let tip = Bytes32::new([3u8; 32]);
404        let lineage = SingletonLineage::new(tip, [launcher, cn]);
405
406        assert!(lineage.contains(launcher));
407        assert!(lineage.contains(cn));
408        assert!(lineage.contains(tip));
409        assert!(!lineage.contains(Bytes32::new([9u8; 32])));
410        assert_eq!(lineage.tip(), tip);
411        assert_eq!(lineage.len(), 3);
412        assert!(!lineage.is_empty());
413    }
414
415    #[test]
416    fn single_lineage_is_tip_only() {
417        let tip = Bytes32::new([7u8; 32]);
418        let lineage = SingletonLineage::single(tip);
419        assert_eq!(lineage.len(), 1);
420        assert!(lineage.contains(tip));
421    }
422}