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 chia_protocol::{Bytes32, Coin, CoinSpend, Program};
32use chia_puzzle_types::singleton::SingletonArgs;
33use chia_puzzle_types::Proof;
34use chia_puzzles::SINGLETON_LAUNCHER_HASH;
35use chia_sdk_utils::Address;
36use chia_wallet_sdk::driver::{Did, DidInfo, Layer, Puzzle, SingletonLayer};
37use chia_wallet_sdk::prelude::{Allocator, NodePtr};
38use chia_wallet_sdk::types::{run_puzzle, Condition};
39use clvm_traits::{FromClvm, ToClvm};
40use clvm_utils::TreeHash;
41
42use crate::error::{DidError, DidResult};
43
44// The chain-reading seam is the ONE canonical `dig-chainsource-interface` contract (#1240), not a
45// per-crate copy that could byte-drift. Re-exported here so the historical `dig_did::ChainSource` and
46// `dig_did::SingletonLineage` paths are preserved for downstream consumers.
47pub use dig_chainsource_interface::{ChainSource, SingletonLineage};
48
49/// The maximum number of parent-spend hops the singleton walk will follow before failing closed with
50/// [`DidError::LineageTooDeep`].
51///
52/// A genuine singleton's lineage grows by one coin per spend; a DID under active use might accumulate
53/// thousands of states over its lifetime, so the bound is generous. Its purpose is purely a DoS guard:
54/// a malicious [`ChainSource`] must not be able to make the walk loop unboundedly.
55pub const MAX_LINEAGE_DEPTH: usize = 100_000;
56
57/// The current unspent tip of a DID singleton, reconstructed from chain reads: the tip coin, its
58/// [`DidInfo`], and the lineage [`Proof`] needed to spend it.
59///
60/// This is the output of [`walk_did_lineage_to_tip`] — a ready-to-inspect (and, with an inner spend,
61/// ready-to-spend) [`Did`]-shaped view of a DID's current on-chain state.
62#[derive(Debug, Clone)]
63pub struct DidTip {
64 /// The DID singleton's current unspent tip coin.
65 pub coin: Coin,
66 /// The DID's outer-puzzle fields at the tip (launcher id, recovery config, metadata, owner p2 hash).
67 pub info: DidInfo,
68 /// The lineage proof binding the tip to its parent — required in the tip's spend solution.
69 pub proof: Proof,
70}
71
72impl DidTip {
73 /// Projects this tip into the SDK's spendable [`Did`] (`Singleton<DidInfo>`).
74 pub fn did(&self) -> Did {
75 Did::new(self.coin, self.proof, self.info)
76 }
77}
78
79/// The authenticated result of the singleton walk: the launcher a coin genuinely descends from, plus
80/// the launcher coin itself (whose `parent_coin_info` is the coin that CREATED the launcher — the
81/// launch-from-DID link for [`LineageModel::LaunchedFrom`]).
82#[derive(Debug)]
83pub(crate) struct AuthenticatedLineage {
84 /// The launcher id the walked coin provably descends from (the curry commitment == the launcher).
85 pub(crate) launcher_id: Bytes32,
86 /// The launcher coin the walk terminated at. `launcher_coin.parent_coin_info` is the coin that
87 /// created the launcher.
88 pub(crate) launcher_coin: Coin,
89 /// The coin ids walked, from the coin under proof up to (and including) the launcher — an audit
90 /// trail carried into [`crate::lineage::AncestryProof`].
91 pub(crate) trail: Vec<Bytes32>,
92}
93
94/// Authenticates `coin_id` as a genuine singleton by walking its parent-spend chain to the launcher.
95///
96/// See the module docs for WHY this walk (not a puzzle-hash check) is the only sound authentication.
97/// Fails closed with [`DidError::NotASingleton`] on any break in the singleton structure, and
98/// [`DidError::LineageTooDeep`] past [`MAX_LINEAGE_DEPTH`]. Read failures propagate as
99/// [`DidError::Chain`].
100pub(crate) fn authenticate_singleton<S: ChainSource>(
101 coin_id: Bytes32,
102 source: &S,
103) -> DidResult<AuthenticatedLineage> {
104 authenticate_singleton_bounded(coin_id, source, MAX_LINEAGE_DEPTH)
105}
106
107/// [`authenticate_singleton`] with an explicit depth bound — the DoS guard, factored out so the
108/// [`DidError::LineageTooDeep`] behaviour can be exercised over a real (short) chain with a tiny bound.
109pub(crate) fn authenticate_singleton_bounded<S: ChainSource>(
110 coin_id: Bytes32,
111 source: &S,
112 max_depth: usize,
113) -> DidResult<AuthenticatedLineage> {
114 let mut allocator = Allocator::new();
115 let mut trail = vec![coin_id];
116 let mut current = coin_id;
117 // The launcher id every singleton parent must agree on — captured from the first singleton parent
118 // and re-checked at every subsequent hop and at the terminal launcher.
119 let mut expected_launcher: Option<Bytes32> = None;
120
121 for _hop in 0..max_depth {
122 let spend = source
123 .parent_spend(current)
124 .map_err(chain_error)?
125 .ok_or(DidError::NotASingleton)?;
126 let parent = spend.coin;
127 let (parent_puzzle, parent_solution) = parse_spend(&mut allocator, &spend)?;
128
129 // Terminal: the parent is the singleton launcher, so `current` is the eve singleton.
130 if parent.puzzle_hash == SINGLETON_LAUNCHER_HASH.into() {
131 let launcher_id = parent.coin_id();
132 if let Some(expected) = expected_launcher {
133 require(expected == launcher_id)?;
134 }
135 require(launcher_creates(
136 &mut allocator,
137 parent,
138 parent_puzzle,
139 parent_solution,
140 current,
141 )?)?;
142 return Ok(AuthenticatedLineage {
143 launcher_id,
144 launcher_coin: parent,
145 trail,
146 });
147 }
148
149 // Otherwise the parent must itself be a genuine singleton that recreates `current`.
150 let layer = SingletonLayer::<Puzzle>::parse_puzzle(&allocator, parent_puzzle)
151 .map_err(DidError::Driver)?
152 .ok_or(DidError::NotASingleton)?;
153 if let Some(expected) = expected_launcher {
154 require(expected == layer.launcher_id)?;
155 }
156 expected_launcher = Some(layer.launcher_id);
157
158 let successor = singleton_successor(&mut allocator, parent, &layer, parent_solution)?
159 .ok_or(DidError::NotASingleton)?;
160 require(successor.coin_id() == current)?;
161
162 trail.push(parent.coin_id());
163 current = parent.coin_id();
164 }
165
166 Err(DidError::LineageTooDeep)
167}
168
169/// Reconstructs the exact singleton successor coin that `parent` (a singleton for `layer.launcher_id`)
170/// creates, by running its inner puzzle and re-wrapping the odd-amount successor in the singleton curry.
171///
172/// Returns `None` when the spend emits no odd-amount successor (a melt / no child). The returned coin's
173/// puzzle hash is COMPUTED from the launcher id and the successor's inner puzzle hash — it is never read
174/// from an untrusted field, which is what makes the authentication sound.
175fn singleton_successor(
176 allocator: &mut Allocator,
177 parent: Coin,
178 layer: &SingletonLayer<Puzzle>,
179 parent_solution: NodePtr,
180) -> DidResult<Option<Coin>> {
181 let solution = SingletonLayer::<Puzzle>::parse_solution(allocator, parent_solution)
182 .map_err(DidError::Driver)?;
183 let output = run_puzzle(allocator, layer.inner_puzzle.ptr(), solution.inner_solution)
184 .map_err(|error| DidError::Parse(error.to_string()))?;
185 let conditions = Vec::<Condition>::from_clvm(allocator, output)
186 .map_err(|e| DidError::Parse(e.to_string()))?;
187
188 let Some(create_coin) = conditions
189 .into_iter()
190 .filter_map(Condition::into_create_coin)
191 .find(|create_coin| create_coin.amount % 2 == 1)
192 else {
193 return Ok(None);
194 };
195
196 let inner_hash: TreeHash = create_coin.puzzle_hash.into();
197 let full_puzzle_hash = SingletonArgs::curry_tree_hash(layer.launcher_id, inner_hash);
198 Ok(Some(Coin::new(
199 parent.coin_id(),
200 full_puzzle_hash.into(),
201 create_coin.amount,
202 )))
203}
204
205/// Whether the launcher coin's spend creates exactly the eve coin `eve_id`.
206///
207/// The launcher's `CREATE_COIN` puzzle hash is already the eve's full (singleton-wrapped) puzzle hash,
208/// so the eve coin is reconstructed directly and its id compared. This binds the eve to a genuine
209/// launcher spend rather than a claimed parent.
210fn launcher_creates(
211 allocator: &mut Allocator,
212 launcher: Coin,
213 launcher_puzzle: Puzzle,
214 launcher_solution: NodePtr,
215 eve_id: Bytes32,
216) -> DidResult<bool> {
217 let output = run_puzzle(allocator, launcher_puzzle.ptr(), launcher_solution)
218 .map_err(|error| DidError::Parse(error.to_string()))?;
219 let conditions = Vec::<Condition>::from_clvm(allocator, output)
220 .map_err(|e| DidError::Parse(e.to_string()))?;
221
222 Ok(conditions
223 .into_iter()
224 .filter_map(Condition::into_create_coin)
225 .any(|create_coin| {
226 Coin::new(
227 launcher.coin_id(),
228 create_coin.puzzle_hash,
229 create_coin.amount,
230 )
231 .coin_id()
232 == eve_id
233 }))
234}
235
236/// Walks a DID singleton forward to its current unspent tip, reconstructing it as a [`DidTip`].
237///
238/// Consolidates dig-identity's lineage half against this crate's [`ChainSource`]: it resolves the DID's
239/// lineage tip via [`ChainSource::resolve_singleton_lineage`], reads the spend that created the tip, and
240/// parses the tip DID with the SDK ([`Did::parse_child`], INV-4). Returns `None` when the DID has no
241/// current on-chain coin (unlaunched or melted). Fails closed with [`DidError::NotDid`] when the tip's
242/// creating spend does not parse as a DID (e.g. the tip is a bare eve whose parent is the launcher).
243pub fn walk_did_lineage_to_tip<S: ChainSource>(
244 source: &S,
245 launcher_id: Bytes32,
246) -> DidResult<Option<DidTip>> {
247 let Some(lineage) = source
248 .resolve_singleton_lineage(launcher_id)
249 .map_err(chain_error)?
250 else {
251 return Ok(None);
252 };
253 let tip_id = lineage.tip();
254
255 let spend = source
256 .parent_spend(tip_id)
257 .map_err(chain_error)?
258 .ok_or(DidError::NoIdentitySingleton)?;
259 let parent = spend.coin;
260
261 let mut allocator = Allocator::new();
262 let (parent_puzzle, parent_solution) = parse_spend(&mut allocator, &spend)?;
263
264 // Reconstruct the tip coin from the parent's genuine singleton successor.
265 let layer = SingletonLayer::<Puzzle>::parse_puzzle(&allocator, parent_puzzle)
266 .map_err(DidError::Driver)?
267 .ok_or(DidError::NotDid)?;
268 let tip_coin = singleton_successor(&mut allocator, parent, &layer, parent_solution)?
269 .filter(|coin| coin.coin_id() == tip_id)
270 .ok_or(DidError::NotDid)?;
271
272 let did = Did::parse_child(
273 &mut allocator,
274 parent,
275 parent_puzzle,
276 parent_solution,
277 tip_coin,
278 )
279 .map_err(DidError::Driver)?
280 .ok_or(DidError::NotDid)?;
281
282 Ok(Some(DidTip {
283 coin: did.coin,
284 info: did.info,
285 proof: did.proof,
286 }))
287}
288
289/// Resolves a DID's CURRENT owner XCH payment address from chain — the DID→address primitive
290/// (SPEC §3, U10).
291///
292/// Walks the DID singleton identified by `launcher_id` to its current authenticated tip and returns
293/// the tip owner's payment address: the tip's `p2_puzzle_hash` (SPEC §2.1, "the current owner's
294/// address") bech32m-encoded under `prefix`. `prefix` is the network HRP (`"xch"` mainnet, `"txch"`
295/// testnet) — dig-did stays network-agnostic and never hard-codes an HRP. This is a ChainSource-READ
296/// only: it never signs and never broadcasts (INV-1/INV-2).
297///
298/// # Money-critical soundness (why the extra walk)
299///
300/// This primitive routes payments, so a wrong answer silently pays the wrong recipient. The tip's
301/// curried `launcher_id` is attacker-chosen (the `pay_to_coin_wearing_a_singleton_puzzle_hash`
302/// attack class), so `tip.info.launcher_id() == launcher_id` is INSUFFICIENT. After walking to the
303/// tip this function runs the `authenticate_singleton` parent-spend walk — which walks the
304/// parent-spend chain to the GENUINE launcher — and requires that authenticated launcher to equal
305/// `launcher_id`. Only then is
306/// the address built. A dishonest [`ChainSource`] that echoes a DIFFERENT DID's tip for `launcher_id`
307/// is caught here as [`DidError::LauncherMismatch`], never resolved to the attacker's address.
308///
309/// # Returns
310///
311/// `Ok(Some(address))` for a launched, authenticated DID; `Ok(None)` when the DID has no current
312/// on-chain coin (unlaunched or melted). Every other failure is a typed, fail-closed [`DidError`]:
313///
314/// | Case | Result |
315/// |---|---|
316/// | unlaunched / melted | `Ok(None)` |
317/// | tip creating-spend absent | `Err(NoIdentitySingleton)` |
318/// | tip not a DID | `Err(NotDid)` |
319/// | tip not a genuine singleton (spoofed curry) | `Err(NotASingleton)` |
320/// | genuine launcher ≠ requested | `Err(LauncherMismatch)` |
321/// | chain read fails | `Err(Chain)` |
322/// | lineage over-deep | `Err(LineageTooDeep)` |
323/// | `Address::encode` fails (bad `prefix`) | `Err(Parse)` |
324pub fn resolve_xch_address<S: ChainSource>(
325 launcher_id: Bytes32,
326 prefix: &str,
327 source: &S,
328) -> DidResult<Option<Address>> {
329 let Some(tip) = walk_did_lineage_to_tip(source, launcher_id)? else {
330 return Ok(None);
331 };
332
333 // The money-critical guard: authenticate the tip's GENUINE launcher via the parent-spend walk,
334 // never the attacker-chosen curried launcher id on the tip itself.
335 let authenticated = authenticate_singleton(tip.coin.coin_id(), source)?;
336 if authenticated.launcher_id != launcher_id {
337 return Err(DidError::LauncherMismatch);
338 }
339
340 let address = Address::new(tip.info.p2_puzzle_hash, prefix.to_string());
341 // Validate the address encodes under `prefix` before returning it — a bad HRP fails closed here
342 // rather than handing back an address that cannot be rendered.
343 address
344 .encode()
345 .map_err(|error| DidError::Parse(error.to_string()))?;
346 Ok(Some(address))
347}
348
349/// Resolves a DID's current owner XCH payment address from its `did:chia:1…` string form — a
350/// convenience wrapper over [`resolve_xch_address`] (SPEC §3, U10).
351///
352/// Decodes `did` to its launcher id (see [`crate::launcher_id_from_did_string`]) and delegates. A
353/// malformed `did:chia:` string fails closed with [`DidError::InvalidDidString`] before any chain
354/// read. All other semantics — including the money-critical launcher authentication — match
355/// [`resolve_xch_address`].
356pub fn resolve_xch_address_from_did_string<S: ChainSource>(
357 did: &str,
358 prefix: &str,
359 source: &S,
360) -> DidResult<Option<Address>> {
361 let launcher_id = crate::launcher_id_from_did_string(did)?;
362 resolve_xch_address(launcher_id, prefix, source)
363}
364
365/// Deserializes a [`CoinSpend`]'s puzzle reveal and solution into the allocator, returning the parsed
366/// [`Puzzle`] and the solution [`NodePtr`].
367fn parse_spend(allocator: &mut Allocator, spend: &CoinSpend) -> DidResult<(Puzzle, NodePtr)> {
368 let puzzle_ptr = alloc_program(allocator, &spend.puzzle_reveal)?;
369 let solution_ptr = alloc_program(allocator, &spend.solution)?;
370 Ok((Puzzle::parse(allocator, puzzle_ptr), solution_ptr))
371}
372
373/// Deserializes a [`Program`] (a puzzle reveal or solution) into an allocated [`NodePtr`].
374fn alloc_program(allocator: &mut Allocator, program: &Program) -> DidResult<NodePtr> {
375 program
376 .to_clvm(allocator)
377 .map_err(|error| DidError::Parse(error.to_string()))
378}
379
380/// Fails closed with [`DidError::NotASingleton`] unless `condition` holds — the single-line guard the
381/// singleton walk uses so every structural break maps to the same "not a singleton" verdict.
382fn require(condition: bool) -> DidResult<()> {
383 condition.then_some(()).ok_or(DidError::NotASingleton)
384}
385
386/// Wraps a source-specific error into [`DidError::Chain`] without requiring `S::Error: 'static`.
387fn chain_error<E: core::fmt::Display>(error: E) -> DidError {
388 DidError::Chain(error.to_string())
389}
390
391#[cfg(test)]
392mod tests {
393 use super::*;
394 use std::collections::HashMap;
395
396 use chia_puzzle_types::Memos;
397 use chia_wallet_sdk::driver::{SingletonInfo, SpendContext, StandardLayer};
398 use chia_wallet_sdk::test::Simulator;
399 use chia_wallet_sdk::types::Conditions;
400 use dig_chainsource_interface::{CoinRecord, MockChainSource};
401
402 use crate::create::create_simple_did;
403 use crate::did_string::did_string_from_launcher_id;
404 use crate::types::Owner;
405
406 /// The mainnet payment-address HRP used across the resolve tests (dig-did itself is
407 /// network-agnostic — the caller passes the prefix).
408 const XCH: &str = "xch";
409
410 /// A DID freshly created and settled in the simulator, kept together with its owner so tests can
411 /// spend it further.
412 struct SettledDid {
413 did: Did,
414 launcher_id: Bytes32,
415 }
416
417 /// A chain view backed by the real in-process simulator, with a per-launcher lineage map that a
418 /// test can populate HONESTLY (the DID's own lineage) or DISHONESTLY (echoing another DID's tip
419 /// for a victim launcher — the money-critical attack). Everything except the lineage map is read
420 /// straight from the genuine simulator, so the parent-spend authentication walk always sees real
421 /// on-chain spends.
422 struct SimSource<'a> {
423 sim: &'a Simulator,
424 lineages: HashMap<Bytes32, SingletonLineage>,
425 }
426
427 impl ChainSource for SimSource<'_> {
428 type Error = String;
429
430 fn coin_record(&self, coin_id: Bytes32) -> Result<Option<CoinRecord>, Self::Error> {
431 Ok(self.sim.coin_state(coin_id).map(CoinRecord::from))
432 }
433
434 fn coin_records_by_puzzle_hash(
435 &self,
436 _puzzle_hash: Bytes32,
437 _include_spent: bool,
438 ) -> Result<Vec<CoinRecord>, Self::Error> {
439 Ok(Vec::new())
440 }
441
442 fn coin_records_by_parent(
443 &self,
444 _parent_coin_id: Bytes32,
445 ) -> Result<Vec<CoinRecord>, Self::Error> {
446 Ok(Vec::new())
447 }
448
449 fn coin_spend(&self, coin_id: Bytes32) -> Result<Option<CoinSpend>, Self::Error> {
450 let Some(state) = self.sim.coin_state(coin_id) else {
451 return Ok(None);
452 };
453 let (Some(reveal), Some(solution)) =
454 (self.sim.puzzle_reveal(coin_id), self.sim.solution(coin_id))
455 else {
456 return Ok(None);
457 };
458 Ok(Some(CoinSpend::new(state.coin, reveal, solution)))
459 }
460
461 fn resolve_singleton_lineage(
462 &self,
463 launcher_id: Bytes32,
464 ) -> Result<Option<SingletonLineage>, Self::Error> {
465 Ok(self.lineages.get(&launcher_id).cloned())
466 }
467
468 fn peak_height(&self) -> Result<Option<u32>, Self::Error> {
469 Ok(None)
470 }
471
472 fn block_timestamp(&self, _height: u32) -> Result<Option<u64>, Self::Error> {
473 Ok(None)
474 }
475 }
476
477 /// Creates and settles a fresh single-owner DID in `sim`, returning it plus its launcher id.
478 fn settle_did(sim: &mut Simulator, ctx: &mut SpendContext) -> anyhow::Result<SettledDid> {
479 let owner = sim.bls(1);
480 let spend = create_simple_did(ctx, owner.coin, Owner::Standard(owner.pk))?;
481 let did = spend.child.expect("create returns a child DID");
482 sim.spend_coins(spend.coin_spends, std::slice::from_ref(&owner.sk))?;
483 let launcher_id = did.info.launcher_id();
484 Ok(SettledDid { did, launcher_id })
485 }
486
487 /// The full lineage of a freshly-settled DID (launcher -> eve -> settled tip).
488 fn did_lineage(did: &Did) -> SingletonLineage {
489 SingletonLineage::new(
490 did.coin.coin_id(),
491 [
492 did.info.launcher_id(),
493 did.coin.parent_coin_info,
494 did.coin.coin_id(),
495 ],
496 )
497 }
498
499 /// An honest source reporting `did`'s own lineage for its launcher.
500 fn honest_source<'a>(sim: &'a Simulator, did: &Did) -> SimSource<'a> {
501 SimSource {
502 sim,
503 lineages: HashMap::from([(did.info.launcher_id(), did_lineage(did))]),
504 }
505 }
506
507 #[test]
508 fn resolve_happy_path_matches_the_owner_address() -> anyhow::Result<()> {
509 let mut sim = Simulator::new();
510 let ctx = &mut SpendContext::new();
511 let SettledDid { did, launcher_id } = settle_did(&mut sim, ctx)?;
512 let source = honest_source(&sim, &did);
513
514 let address = resolve_xch_address(launcher_id, XCH, &source)?
515 .expect("a launched, authenticated DID resolves to an address");
516
517 assert_eq!(address.puzzle_hash, did.info.p2_puzzle_hash);
518 assert_eq!(address.prefix, XCH);
519 let expected = Address::new(did.info.p2_puzzle_hash, XCH.to_string()).encode()?;
520 assert_eq!(address.encode()?, expected);
521 Ok(())
522 }
523
524 #[test]
525 fn resolved_address_roundtrips_through_decode() -> anyhow::Result<()> {
526 let mut sim = Simulator::new();
527 let ctx = &mut SpendContext::new();
528 let SettledDid { did, launcher_id } = settle_did(&mut sim, ctx)?;
529 let source = honest_source(&sim, &did);
530
531 let address = resolve_xch_address(launcher_id, XCH, &source)?.expect("resolves");
532 let decoded = Address::decode(&address.encode()?)?;
533
534 assert_eq!(decoded.puzzle_hash, did.info.p2_puzzle_hash);
535 assert_eq!(decoded.prefix, XCH);
536 Ok(())
537 }
538
539 #[test]
540 fn resolve_rejects_an_echoed_different_dids_tip() -> anyhow::Result<()> {
541 // THE money test. A dishonest source echoes an ATTACKER DID's real tip for the VICTIM's
542 // launcher. A naive resolver would walk to the attacker's tip, trust the tip's curried
543 // launcher id, and hand back the ATTACKER's payment address for the victim's DID — silently
544 // routing funds to the attacker. The parent-walk `authenticate_singleton` guard must catch it.
545 let mut sim = Simulator::new();
546 let ctx = &mut SpendContext::new();
547 let victim = settle_did(&mut sim, ctx)?;
548 let attacker = settle_did(&mut sim, ctx)?;
549
550 // The victim launcher resolves (dishonestly) to the ATTACKER's lineage tip.
551 let source = SimSource {
552 sim: &sim,
553 lineages: HashMap::from([(victim.launcher_id, did_lineage(&attacker.did))]),
554 };
555
556 let result = resolve_xch_address(victim.launcher_id, XCH, &source);
557 assert!(matches!(result, Err(DidError::LauncherMismatch)));
558
559 // Explicitly prove the guard prevented the wrong-recipient payment: the attacker's address is
560 // what a naive resolver would have returned, and resolve did NOT return it.
561 let attacker_address =
562 Address::new(attacker.did.info.p2_puzzle_hash, XCH.to_string()).encode()?;
563 assert!(
564 !matches!(result, Ok(Some(address)) if address.encode().ok() == Some(attacker_address))
565 );
566 Ok(())
567 }
568
569 #[test]
570 fn resolve_rejects_a_spoofed_curry_singleton() -> anyhow::Result<()> {
571 // A pay-to coin that merely WEARS a singleton outer puzzle hash for the launcher, minted from
572 // an ordinary coin (no genuine singleton recreation parent-spend). Fed as the echoed lineage
573 // tip, it must fail closed — never resolved to an address.
574 let mut sim = Simulator::new();
575 let ctx = &mut SpendContext::new();
576 let victim = settle_did(&mut sim, ctx)?;
577
578 let alice = sim.bls(1);
579 let alice_p2 = StandardLayer::new(alice.pk);
580 let fake_singleton_puzzle_hash: Bytes32 =
581 SingletonArgs::curry_tree_hash(victim.launcher_id, alice.puzzle_hash.into()).into();
582 alice_p2.spend(
583 ctx,
584 alice.coin,
585 Conditions::new().create_coin(fake_singleton_puzzle_hash, 1, Memos::None),
586 )?;
587 sim.spend_coins(ctx.take(), std::slice::from_ref(&alice.sk))?;
588
589 let fake_coin = Coin::new(alice.coin.coin_id(), fake_singleton_puzzle_hash, 1);
590 let source = SimSource {
591 sim: &sim,
592 lineages: HashMap::from([(
593 victim.launcher_id,
594 SingletonLineage::single(fake_coin.coin_id()),
595 )]),
596 };
597
598 let result = resolve_xch_address(victim.launcher_id, XCH, &source);
599 // Fail-closed: the tip is not a genuine singleton state of the DID, so it never parses into a
600 // resolvable owner. (The spoof is rejected at the DID/singleton gate, never as an address.)
601 assert!(matches!(
602 result,
603 Err(DidError::NotDid | DidError::NotASingleton)
604 ));
605 Ok(())
606 }
607
608 #[test]
609 fn resolve_returns_none_for_unlaunched_or_melted() -> anyhow::Result<()> {
610 // An empty mock source reports no lineage for any launcher — the DID was never launched or has
611 // been fully melted. Absence is `Ok(None)`, never an error and never an address.
612 let source = MockChainSource::new();
613 let launcher_id: Bytes32 =
614 clvm_utils::tree_hash_atom(b"dig-did::resolve::unlaunched-launcher").into();
615
616 let resolved = resolve_xch_address(launcher_id, XCH, &source)?;
617 assert!(resolved.is_none());
618 Ok(())
619 }
620
621 #[test]
622 fn resolve_from_did_string_rejects_malformed() {
623 // A malformed did:chia string fails closed BEFORE any chain read.
624 let source = MockChainSource::new();
625 let error = resolve_xch_address_from_did_string("not-a-valid-did", XCH, &source)
626 .expect_err("a malformed did:chia string must fail closed");
627 assert!(matches!(error, DidError::InvalidDidString(_)));
628 }
629
630 #[test]
631 fn resolve_from_did_string_happy_path_matches_direct_resolution() -> anyhow::Result<()> {
632 // The string convenience fn agrees with the launcher-id form for a genuine DID.
633 let mut sim = Simulator::new();
634 let ctx = &mut SpendContext::new();
635 let SettledDid { did, launcher_id } = settle_did(&mut sim, ctx)?;
636 let source = honest_source(&sim, &did);
637
638 let did_string = did_string_from_launcher_id(launcher_id);
639 let via_string = resolve_xch_address_from_did_string(&did_string, XCH, &source)?
640 .expect("resolves via the did:chia string");
641 let via_launcher = resolve_xch_address(launcher_id, XCH, &source)?.expect("resolves");
642
643 assert_eq!(via_string.encode()?, via_launcher.encode()?);
644 Ok(())
645 }
646}