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}