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 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}