Skip to main content

dig_merkle/
read.rs

1//! Reading on-chain DataLayer state without spending (SPEC §3.6/§3.7) — owner-DID discovery.
2//!
3//! A DIG store can be rooted in a DID: the store's launcher coin descends from a DID-authorized
4//! parent coin whose spend emits [`crate::DatastoreLaunch::parent_conditions`] (returned by
5//! [`crate::mint_datastore_launch_with_kind`]). A DID is a singleton, so it cannot parent the
6//! odd-amount launcher directly and interposes an even-amount intermediate coin (SPEC §3.1a). This
7//! module recovers the owning DID by walking the store's launcher lineage up — one hop, or two
8//! through that intermediate — and recognising a DID coin spend.
9//!
10//! ## Two layers
11//!
12//! [`did_ref_from_spend`] is the pure, network-free core — it recognises a DID from a single coin
13//! spend. [`resolve_owner_did`] is the launcher-lineage WALK on top: it fetches the coin spends
14//! (`store_id` → its creator, and at most one hop beyond) through the injected CANONICAL
15//! [`dig_chainsource_interface::ChainSource`] read interface (a reference-DOWN pure leaf) and passes
16//! the creator spend to `did_ref_from_spend`, fail-closed to `Ok(None)` at every missing hop — but
17//! to `Err(MerkleError::Chain)` when the source ANSWERS with something the coin did not commit to.
18//! dig-merkle itself opens no socket (INV-1) — the caller implements the chain read.
19
20use chia_puzzle_types::nft::NftIntermediateLauncherArgs;
21use chia_wallet_sdk::driver::{Did, Puzzle};
22use chia_wallet_sdk::prelude::{Allocator, TreeHash};
23use chia_wallet_sdk::puzzles::{NFT_INTERMEDIATE_LAUNCHER_HASH, SINGLETON_LAUNCHER_HASH};
24use clvm_traits::{FromClvm, ToClvm};
25use dig_chainsource_interface::ChainSource;
26
27use crate::types::{Bytes32, Coin, CoinSpend};
28use crate::{MerkleError, MerkleResult};
29
30/// A reference to a DID, identified by its immutable `launcher_id` (the DID's on-chain identity).
31///
32/// This is the successful result of owner-DID discovery: the launcher id uniquely names the DID that
33/// authorized a store's creation, and a caller resolves it to a full DID document via its own DID
34/// tooling (dig-merkle deliberately holds no `dig-did` dependency).
35#[derive(Debug, Clone, Copy, PartialEq, Eq)]
36pub struct DidRef {
37    /// The DID's launcher id — its permanent on-chain identity.
38    pub launcher_id: Bytes32,
39}
40
41/// Recognises whether a coin spend is a DID spend and, if so, returns its [`DidRef`].
42///
43/// Fail-closed, and the two failures are DISTINCT: a genuine non-DID puzzle is `Ok(None)`, while a
44/// spend the coin did not commit to — a `puzzle_reveal` that does not hash to `coin.puzzle_hash` — is
45/// [`MerkleError::Chain`]. "Not a DID" is an answer; "the source lied about the puzzle" is not.
46///
47/// This is the pure, network-free core of owner-DID discovery. Given the spend of a store launcher's
48/// PARENT coin, a `Some` result means that parent was a DID — i.e. the store is DID-owned — and names
49/// the owning DID. A `None` result means the parent was an ordinary coin (e.g. a plain standard mint,
50/// SPEC §3.7 fail-closed).
51///
52/// The parse runs in a private [`Allocator`], allocating the spend's puzzle and solution and handing
53/// them to the SDK's [`Did::parse`] (the byte-source-of-truth, INV-4). It performs NO network I/O and
54/// never signs or spends — it only inspects the given bytes.
55///
56/// # Authoritative ONLY for a CONFIRMED on-chain spend (NC-9)
57///
58/// This function trusts its input and only recognises STRUCTURE; it does NOT verify that the spend
59/// happened on chain. A caller feeding an unconfirmed, attacker-shaped, or otherwise unverified spend
60/// can be made to mis-attribute ownership — a crafted puzzle that parses as a DID yields a `Some`
61/// that proves nothing. Genuine chain-proven attribution MUST fetch the store launcher's parent spend
62/// from a TRUSTED chain source and verify it was actually spent on chain before trusting the result.
63/// Do NOT treat a `Some` result from an unverified spend as proof of ownership.
64///
65/// # Errors
66///
67/// Returns [`MerkleError::Parse`] if the spend's puzzle/solution CLVM cannot be allocated,
68/// [`MerkleError::Driver`] if the SDK's DID parser errors on a puzzle that structurally should have
69/// been a DID, and [`MerkleError::Chain`] if the `puzzle_reveal` does not hash to the spend's
70/// `coin.puzzle_hash` — a spend the coin never committed to, which is refused rather than parsed.
71///
72/// A puzzle that simply is not a DID is `Ok(None)`, not an error.
73pub fn did_ref_from_spend(spend: &CoinSpend) -> MerkleResult<Option<DidRef>> {
74    let mut allocator = Allocator::new();
75
76    let puzzle_ptr = spend
77        .puzzle_reveal
78        .to_clvm(&mut allocator)
79        .map_err(|error| MerkleError::Parse(format!("puzzle reveal: {error}")))?;
80    let solution_ptr = spend
81        .solution
82        .to_clvm(&mut allocator)
83        .map_err(|error| MerkleError::Parse(format!("solution: {error}")))?;
84
85    let puzzle = Puzzle::parse(&allocator, puzzle_ptr);
86
87    // The reveal must be the puzzle the coin COMMITS to. `Did::parse` never compares the two, and a
88    // coin id is computed from the coin's own fields, so a binding on the coin id alone lets a hostile
89    // chain source pair a genuine coin with a forged reveal and have a DID attributed to a store that
90    // has none. This is the same check `is_launcher_intermediate` makes on the intermediate hop.
91    if Bytes32::from(puzzle.curried_puzzle_hash()) != spend.coin.puzzle_hash {
92        return Err(MerkleError::Chain(format!(
93            "the puzzle reveal for coin {} does not hash to the coin's puzzle hash — the source \
94             returned a puzzle the coin never committed to",
95            spend.coin.coin_id()
96        )));
97    }
98
99    match Did::parse(&allocator, spend.coin, puzzle, solution_ptr)? {
100        Some((did, _p2_spend)) => Ok(Some(DidRef {
101            launcher_id: did.info.launcher_id,
102        })),
103        None => Ok(None),
104    }
105}
106
107/// Recovers the DID that OWNS the store launched at `store_id`, walking its launcher lineage up
108/// (SPEC §3.7).
109///
110/// A DID-owned store has its launcher coin created — directly or through one intermediate coin — by
111/// spending a DID-authorized coin. This walks that lineage via the injected [`ChainSource`] (INV-1 —
112/// dig-merkle opens no socket; the caller supplies the chain read):
113///
114/// 1. `chain.coin_spend(store_id)` — the launcher coin's spend (`store_id == launcher_id`).
115/// 2. `launcher_spend.coin.parent_coin_info` — the coin that CREATED the launcher.
116/// 3. `chain.coin_spend(parent_id)` — that creator's spend. A DID here is the answer.
117/// 4. Otherwise, IF that creator is an intermediate-launcher coin (see below), ONE further hop to
118///    its own creator, which is where a singleton-parent launch puts the DID.
119///
120/// # The walk is bounded at TWO creator hops, and the second is earned, not assumed
121///
122/// A singleton's inner puzzle may emit exactly one odd-amount `CREATE_COIN` — its own successor — so
123/// a DID cannot parent a 1-mojo launcher directly; it creates an even-amount intermediate coin that
124/// creates the launcher (SPEC §3.1a). The walk therefore tolerates exactly ONE such hop. It is not a
125/// general parent walk: an unbounded climb over coin records an untrusted source controls is a DoS,
126/// and it would also mis-attribute an ordinary store whose funding coin merely happened to come from
127/// a DID. The second hop is taken only when the creator IS the `nft_intermediate_launcher` puzzle,
128/// curried to the singleton launcher, and the launcher that puzzle necessarily creates is this
129/// store's. Anything else stops the walk at `Ok(None)`.
130///
131/// **The walk runs NO chain-supplied CLVM.** Every step parses or derives; none evaluates. An
132/// untrusted [`ChainSource`] therefore cannot spend the caller's CPU on a program of its choosing.
133///
134/// It is **fail-closed**, and READ-ONLY (never signs, spends, or broadcasts). Fail-closed splits two
135/// ways, and the split is the point:
136///
137/// - **`Ok(None)`** — the chain answered honestly and the answer is "no DID": a missing spend, a
138///   non-DID creator, or a creator the walk may not climb past. A store that is simply not DID-owned
139///   is never an error.
140/// - **[`MerkleError::Chain`]** — the source could not be consulted, or it ANSWERED with something the
141///   coin did not commit to: a read error, a spend whose `coin_id` is not the one requested, or a
142///   `puzzle_reveal` that does not hash to the coin's `puzzle_hash` (see [`did_ref_from_spend`]).
143///
144/// A hostile-source substitution is therefore distinguishable from a genuinely non-DID-owned store,
145/// rather than being flattened into the same `Ok(None)`.
146///
147/// # Authoritative ONLY for a CONFIRMED on-chain spend (NC-9)
148///
149/// The DID recognition in step 4 trusts STRUCTURE, not confirmation (see [`did_ref_from_spend`]).
150/// The result is chain-proven ownership ONLY when the injected [`ChainSource`] returns genuine,
151/// confirmed on-chain spends. A source that can be made to return unconfirmed or attacker-shaped
152/// spends can be made to mis-attribute ownership; do not treat a `Some` result as proof of ownership
153/// unless the `ChainSource` is trusted to return confirmed spends.
154///
155/// # Errors
156///
157/// Returns [`MerkleError::Chain`] if a [`ChainSource`] read fails, if a returned spend's `coin_id` is
158/// not the one requested, or if a returned `puzzle_reveal` does not hash to its coin's `puzzle_hash`;
159/// and [`MerkleError::Parse`] / [`MerkleError::Driver`] if the creator spend fails to parse (both
160/// propagated from [`did_ref_from_spend`]).
161///
162/// A store that is not DID-owned is `Ok(None)`, never an error.
163pub fn resolve_owner_did<C: ChainSource>(
164    store_id: Bytes32,
165    chain: &C,
166) -> MerkleResult<Option<DidRef>> {
167    let Some(launcher_spend) = read_coin_spend(chain, store_id)? else {
168        return Ok(None);
169    };
170
171    // Fail-closed identity binding (NC-9): a DIG store id IS its launcher coin id (read.rs docstring
172    // step 1). The injected ChainSource is only trusted to return CONFIRMED spends, never to return
173    // the RIGHT coin — a hostile/buggy source (e.g. the attacker-influenceable public gateway, §5.3)
174    // can answer this read with a DIFFERENT store's valid, DID-rooted launcher. Without this check the
175    // walk would attribute that other store's owning DID to `store_id`. Reject with an error (not
176    // Ok(None)) so a substituted answer is distinguishable from a genuinely non-DID-owned store.
177    if launcher_spend.coin.coin_id() != store_id {
178        return Err(MerkleError::Chain(format!(
179            "launcher spend for {store_id} is coin {}, not the requested store's launcher",
180            launcher_spend.coin.coin_id()
181        )));
182    }
183
184    let Some(creator_spend) = read_bound_spend(chain, launcher_spend.coin.parent_coin_info)? else {
185        return Ok(None);
186    };
187    if let Some(did_ref) = did_ref_from_spend(&creator_spend)? {
188        return Ok(Some(did_ref));
189    }
190
191    // The creator was not a DID. The ONE remaining shape a DID-rooted store can have is a singleton
192    // parent that interposed an intermediate launcher coin; take exactly one more hop, and only when
193    // this creator really is that intermediate.
194    if !is_launcher_intermediate(&creator_spend, launcher_spend.coin) {
195        return Ok(None);
196    }
197    let Some(singleton_spend) = read_bound_spend(chain, creator_spend.coin.parent_coin_info)?
198    else {
199        return Ok(None);
200    };
201
202    did_ref_from_spend(&singleton_spend)
203}
204
205/// Reads the spend of `coin_id` and binds the answer to the id that was asked for.
206///
207/// Fail-closed identity binding (NC-9): the injected [`ChainSource`] is trusted to return CONFIRMED
208/// spends, never to return the RIGHT coin — a hostile or buggy source (the attacker-influenceable
209/// public gateway, §5.3) can answer any read with an unrelated but valid DID spend, and without this
210/// check the walk would recognise a DID that never authorized this store. A substituted answer is an
211/// `Err`, not `Ok(None)`, so it stays distinguishable from a genuinely non-DID-owned store.
212fn read_bound_spend<C: ChainSource>(
213    chain: &C,
214    coin_id: Bytes32,
215) -> MerkleResult<Option<CoinSpend>> {
216    let Some(spend) = read_coin_spend(chain, coin_id)? else {
217        return Ok(None);
218    };
219    if spend.coin.coin_id() != coin_id {
220        return Err(MerkleError::Chain(format!(
221            "spend for {coin_id} is coin {}, not the coin that was requested",
222            spend.coin.coin_id()
223        )));
224    }
225    Ok(Some(spend))
226}
227
228/// Whether `spend` is the intermediate-launcher coin that created `launcher_coin` — the single hop
229/// the owner walk is allowed to traverse (SPEC §3.1a/§3.7).
230///
231/// Recognised by the coin's actual PUZZLE: the uncurried `nft_intermediate_launcher` mod hash, with
232/// its curried `launcher_puzzle_hash` argument bound to the singleton launcher. Because that puzzle
233/// is fixed, its one `CREATE_COIN` is fully determined by the coin it is spending — so the launcher
234/// it produces is DERIVED here rather than observed, and matched by full coin id.
235///
236/// Two reasons this is not recognised by shape instead:
237///
238/// - **It would run chain-supplied CLVM.** The spend comes from an untrusted [`ChainSource`], and
239///   evaluating it means executing an attacker's program: 28 bytes of non-terminating puzzle burns
240///   seconds of CPU at the block cost limit, and the walk still returns `Ok(None)`, so a caller sees
241///   only latency and never an error. Uncurrying parses; it does not execute.
242/// - **Shape is a looser bind than the puzzle.** "A 0-amount coin whose spend creates exactly this
243///   launcher" admits ANY puzzle that happens to emit that one condition, not just the intermediate
244///   launcher the walk means to traverse.
245///
246/// The `mint_number`/`mint_total` the caller chose are deliberately not constrained — they vary the
247/// curried puzzle hash but not the behaviour. Any parse failure is `false` — fail closed.
248fn is_launcher_intermediate(spend: &CoinSpend, launcher_coin: Coin) -> bool {
249    if spend.coin.amount != 0 {
250        return false;
251    }
252
253    let mut allocator = Allocator::new();
254    let Ok(puzzle_ptr) = spend.puzzle_reveal.to_clvm(&mut allocator) else {
255        return false;
256    };
257    let puzzle = Puzzle::parse(&allocator, puzzle_ptr);
258
259    // The reveal must be the coin it claims to be; a chain source that returns a different puzzle
260    // than the coin commits to cannot steer the walk.
261    if Bytes32::from(puzzle.curried_puzzle_hash()) != spend.coin.puzzle_hash {
262        return false;
263    }
264
265    let Some(curried) = puzzle.as_curried() else {
266        return false;
267    };
268    if curried.mod_hash != TreeHash::new(NFT_INTERMEDIATE_LAUNCHER_HASH) {
269        return false;
270    }
271
272    let Ok(args) = NftIntermediateLauncherArgs::from_clvm(&allocator, curried.args) else {
273        return false;
274    };
275    if args.launcher_puzzle_hash != Bytes32::from(SINGLETON_LAUNCHER_HASH) {
276        return false;
277    }
278
279    // The intermediate puzzle's only output is a 1-mojo coin at the curried launcher puzzle hash,
280    // parented by the coin being spent — so the launcher is derivable without running anything.
281    Coin::new(spend.coin.coin_id(), args.launcher_puzzle_hash, 1).coin_id()
282        == launcher_coin.coin_id()
283}
284
285/// Reads the spend that spent `coin_id`, mapping the source's own error into [`MerkleError::Chain`]
286/// so the crate's error surface never leaks a generic `ChainSource::Error` type parameter.
287fn read_coin_spend<C: ChainSource>(chain: &C, coin_id: Bytes32) -> MerkleResult<Option<CoinSpend>> {
288    chain
289        .coin_spend(coin_id)
290        .map_err(|error| MerkleError::Chain(format!("chain read for {coin_id}: {error}")))
291}
292
293#[cfg(test)]
294mod tests {
295    use super::*;
296    use chia_wallet_sdk::driver::{Launcher, SpendContext, StandardLayer};
297    use chia_wallet_sdk::test::Simulator;
298
299    /// A real DID coin spend is recognised, and the returned [`DidRef`] carries the DID's own
300    /// launcher id — the proof `did_ref_from_spend` drives the SDK's DID parser correctly.
301    #[test]
302    fn did_spend_is_recognised_with_its_launcher_id() -> anyhow::Result<()> {
303        let mut sim = Simulator::new();
304        let ctx = &mut SpendContext::new();
305
306        let alice = sim.bls(1);
307        let alice_p2 = StandardLayer::new(alice.pk);
308
309        // Create a DID, then settle it on chain so its coin exists to be spent again.
310        let (create_did, did) =
311            Launcher::new(alice.coin.coin_id(), 1).create_simple_did(ctx, &alice_p2)?;
312        alice_p2.spend(ctx, alice.coin, create_did)?;
313        sim.spend_coins(ctx.take(), std::slice::from_ref(&alice.sk))?;
314
315        // Spend the DID coin (an update spend recreates it) — this is the spend we recognise.
316        let did_coin = did.coin;
317        let _child = did.update(ctx, &alice_p2, chia_wallet_sdk::types::Conditions::new())?;
318        let coin_spends = ctx.take();
319        sim.spend_coins(coin_spends.clone(), std::slice::from_ref(&alice.sk))?;
320
321        let did_spend = coin_spends
322            .iter()
323            .find(|s| s.coin.coin_id() == did_coin.coin_id())
324            .expect("the DID coin spend is present");
325
326        let did_ref = did_ref_from_spend(did_spend)?.expect("a DID spend is recognised");
327        assert_eq!(
328            did_ref.launcher_id, did.info.launcher_id,
329            "the DidRef names the DID's own launcher id"
330        );
331        Ok(())
332    }
333
334    /// A plain standard-coin spend is NOT a DID — discovery fails closed to `None`, never an error
335    /// (SPEC §3.7).
336    #[test]
337    fn plain_standard_spend_is_not_a_did() -> anyhow::Result<()> {
338        let mut sim = Simulator::new();
339        let ctx = &mut SpendContext::new();
340
341        let alice = sim.bls(1);
342        let alice_p2 = StandardLayer::new(alice.pk);
343        let memos = ctx.hint(alice.puzzle_hash)?;
344        alice_p2.spend(
345            ctx,
346            alice.coin,
347            chia_wallet_sdk::types::Conditions::new().create_coin(alice.puzzle_hash, 1, memos),
348        )?;
349        let coin_spends = ctx.take();
350
351        let standard_spend = coin_spends
352            .iter()
353            .find(|s| s.coin.coin_id() == alice.coin.coin_id())
354            .expect("the standard coin spend is present");
355
356        assert_eq!(
357            did_ref_from_spend(standard_spend)?,
358            None,
359            "a plain standard spend is not a DID"
360        );
361        Ok(())
362    }
363
364    use crate::resolve_owner_did;
365    use crate::types::{Coin, CoinSpend};
366    use chia_wallet_sdk::types::Conditions;
367    use dig_chainsource_interface::{ChainSourceError, MockChainSource};
368
369    /// Builds a real, on-chain DID and returns (its coin spend, its launcher id). The DID coin is
370    /// created then update-spent so a genuine DID spend exists to be recognised.
371    fn did_coin_and_spend(sim: &mut Simulator) -> anyhow::Result<(CoinSpend, Bytes32)> {
372        let ctx = &mut SpendContext::new();
373        let alice = sim.bls(1);
374        let alice_p2 = StandardLayer::new(alice.pk);
375
376        let (create_did, did) =
377            Launcher::new(alice.coin.coin_id(), 1).create_simple_did(ctx, &alice_p2)?;
378        alice_p2.spend(ctx, alice.coin, create_did)?;
379        sim.spend_coins(ctx.take(), std::slice::from_ref(&alice.sk))?;
380
381        let did_coin = did.coin;
382        let _child = did.update(ctx, &alice_p2, Conditions::new())?;
383        let coin_spends = ctx.take();
384        sim.spend_coins(coin_spends.clone(), std::slice::from_ref(&alice.sk))?;
385
386        let did_spend = coin_spends
387            .into_iter()
388            .find(|s| s.coin.coin_id() == did_coin.coin_id())
389            .expect("the DID coin spend is present");
390        Ok((did_spend, did.info.launcher_id))
391    }
392
393    /// A DID-owned store resolves to the owning DID: the walk fetches the launcher spend, reads its
394    /// creator (the DID coin), and recognises the DID (SPEC §3.7).
395    #[test]
396    fn resolve_owner_did_returns_the_did_for_a_did_rooted_store() -> anyhow::Result<()> {
397        let mut sim = Simulator::new();
398        let (did_spend, did_launcher_id) = did_coin_and_spend(&mut sim)?;
399        let did_coin_id = did_spend.coin.coin_id();
400
401        // The store's launcher coin was created by spending the DID coin: its parent IS the DID coin.
402        // A DIG store id IS its launcher coin id, so derive it from the launcher coin (the binding
403        // the walk now enforces).
404        let launcher_coin = Coin::new(did_coin_id, Bytes32::new([0xb2; 32]), 1);
405        let store_id = launcher_coin.coin_id();
406        // Only `launcher_spend.coin.parent_coin_info` is read by the walk; reuse a real program pair.
407        let launcher_spend = CoinSpend::new(
408            launcher_coin,
409            did_spend.puzzle_reveal.clone(),
410            did_spend.solution.clone(),
411        );
412
413        let chain = MockChainSource::new()
414            .with_spend(store_id, launcher_spend)
415            .with_spend(did_coin_id, did_spend);
416
417        let did_ref = resolve_owner_did(store_id, &chain)?.expect("store is DID-owned");
418        assert_eq!(
419            did_ref.launcher_id, did_launcher_id,
420            "resolve names the owning DID's launcher id"
421        );
422        Ok(())
423    }
424
425    /// LOAD-BEARING (#2418): a store launched from a DID SINGLETON — which must interpose an
426    /// intermediate launcher coin to stay legal on chain — still resolves to its owning DID.
427    ///
428    /// Every spend here is real and was accepted by the simulator, so the walk is exercised against
429    /// the exact bytes the launch composition produces, not a synthetic stand-in. Without the second
430    /// hop this returns `Ok(None)` — a DID-rooted store reporting as not DID-owned.
431    #[test]
432    fn resolve_owner_did_traverses_the_intermediate_launcher_hop() -> anyhow::Result<()> {
433        use chia_wallet_sdk::driver::IntermediateLauncher;
434
435        let mut sim = Simulator::new();
436        let ctx = &mut SpendContext::new();
437        let alice = sim.bls(1_000_000);
438        let alice_p2 = StandardLayer::new(alice.pk);
439
440        let (create_did, did) =
441            Launcher::new(alice.coin.coin_id(), 1).create_simple_did(ctx, &alice_p2)?;
442        alice_p2.spend(ctx, alice.coin, create_did)?;
443        sim.spend_coins(ctx.take(), std::slice::from_ref(&alice.sk))?;
444
445        let did_coin_id = did.coin.coin_id();
446        let did_launcher_id = did.info.launcher_id;
447        let launcher = IntermediateLauncher::new(did_coin_id, 0, 1).create(ctx)?;
448        let launch = crate::mint_datastore_launch_with_kind(
449            ctx,
450            crate::StoreKind::DidProfile,
451            launcher,
452            Bytes32::new([0x6d; 32]),
453            None,
454            None,
455            None,
456            None,
457            None,
458            alice.puzzle_hash,
459            vec![],
460        )?;
461        let _child = did.update(ctx, &alice_p2, launch.parent_conditions.clone())?;
462
463        // The intermediate coin is created at 0 mojos, so the launcher's mojo comes from elsewhere.
464        let funder = sim.bls(1);
465        StandardLayer::new(funder.pk).spend(ctx, funder.coin, Conditions::new())?;
466
467        let coin_spends = ctx.take();
468        sim.spend_coins(coin_spends.clone(), &[alice.sk.clone(), funder.sk.clone()])?;
469
470        // Serve the REAL, on-chain-accepted spends back through the chain source.
471        let store_id = launch.datastore.info.launcher_id;
472        let chain = coin_spends
473            .iter()
474            .fold(MockChainSource::new(), |chain, spend| {
475                chain.with_spend(spend.coin.coin_id(), spend.clone())
476            });
477
478        let did_ref = resolve_owner_did(store_id, &chain)?
479            .expect("a singleton-rooted store resolves through the intermediate hop");
480        assert_eq!(
481            did_ref.launcher_id, did_launcher_id,
482            "the walk names the DID that authorized the launch"
483        );
484        Ok(())
485    }
486
487    /// The walk STOPS after the intermediate hop: a DID sitting one creator further up is NOT
488    /// reported. Chain: launcher ← a real intermediate ← an ordinary coin ← the DID.
489    ///
490    /// An unbounded parent walk returns `Some(did)` here, so this observes the bound rather than
491    /// restating it — and the bound is what keeps an untrusted source from driving an unbounded climb,
492    /// and keeps a store whose funding coin merely descends from a DID out of that DID's name.
493    #[test]
494    fn resolve_owner_did_does_not_walk_past_the_intermediate_hop() -> anyhow::Result<()> {
495        use chia_wallet_sdk::driver::IntermediateLauncher;
496
497        let mut sim = Simulator::new();
498        let ctx = &mut SpendContext::new();
499        let (did_spend, _did_launcher_id) = did_coin_and_spend(&mut sim)?;
500        let did_coin_id = did_spend.coin.coin_id();
501
502        // An ordinary coin whose PARENT is the DID coin — the extra hop the walk must not take.
503        let alice = sim.bls(1);
504        let alice_p2 = StandardLayer::new(alice.pk);
505        let memos = ctx.hint(alice.puzzle_hash)?;
506        alice_p2.spend(
507            ctx,
508            alice.coin,
509            Conditions::new().create_coin(alice.puzzle_hash, 1, memos),
510        )?;
511        let template = ctx
512            .take()
513            .into_iter()
514            .find(|spend| spend.coin.coin_id() == alice.coin.coin_id())
515            .expect("standard spend present");
516        let ordinary_coin = Coin::new(did_coin_id, alice.puzzle_hash, alice.coin.amount);
517        let ordinary_spend = CoinSpend::new(
518            ordinary_coin,
519            template.puzzle_reveal.clone(),
520            template.solution.clone(),
521        );
522
523        // A real intermediate launcher parented to that ordinary coin.
524        let intermediate = IntermediateLauncher::new(ordinary_coin.coin_id(), 0, 1);
525        let launcher_coin = intermediate.launcher_coin();
526        let _launcher = intermediate.create(ctx)?;
527        let intermediate_spend = ctx
528            .take()
529            .into_iter()
530            .next()
531            .expect("the intermediate coin spend is staged");
532        let launcher_spend = CoinSpend::new(
533            launcher_coin,
534            template.puzzle_reveal.clone(),
535            template.solution.clone(),
536        );
537
538        let store_id = launcher_coin.coin_id();
539        let chain = MockChainSource::new()
540            .with_spend(store_id, launcher_spend)
541            .with_spend(intermediate_spend.coin.coin_id(), intermediate_spend)
542            .with_spend(ordinary_coin.coin_id(), ordinary_spend)
543            .with_spend(did_coin_id, did_spend);
544
545        assert_eq!(
546            resolve_owner_did(store_id, &chain)?,
547            None,
548            "a DID two creators above the launcher is out of the walk's bound"
549        );
550        Ok(())
551    }
552
553    /// LOAD-BEARING: a hostile [`ChainSource`] cannot make the walk EXECUTE a program of its
554    /// choosing.
555    ///
556    /// The fixture is a 7-byte non-terminating puzzle — `(a 1 1)`, which applies its own solution to
557    /// itself forever — presented as the launcher's creator at amount 0, with the coin's puzzle hash
558    /// set to that puzzle's real tree hash so the spend is internally consistent. It therefore
559    /// satisfies every precondition the old shape-based recogniser checked before running the spend,
560    /// which is what makes it discriminating: the previous implementation evaluated it at the full
561    /// mainnet block cost limit (measured: ~7.1s of single-thread CPU in a release build, far worse
562    /// unoptimised) and still answered `Ok(None)`, so a caller saw only unexplained latency.
563    ///
564    /// The elapsed-time bound is the assertion, because the property IS about CPU. The margin is
565    /// enormous by construction — recognising the puzzle without running it is microseconds, and the
566    /// old path could not finish in seconds — so this cannot flake on a slow machine.
567    #[test]
568    fn the_walk_never_runs_a_chain_supplied_puzzle() -> anyhow::Result<()> {
569        use chia_wallet_sdk::clvm_utils::tree_hash;
570        use std::time::Instant;
571
572        // `(a 1 1)`: apply the environment as a program, in that same environment — non-terminating.
573        let hostile_bytes = hex_literal::hex!("ff02ff01ff0180").to_vec();
574        let hostile = crate::types::CoinSpend::new(
575            Coin::new(Bytes32::new([0x9e; 32]), Bytes32::new([0; 32]), 0),
576            hostile_bytes.clone().into(),
577            hostile_bytes.into(),
578        );
579
580        // Bind the coin to the puzzle it reveals, so the fixture passes every check that precedes
581        // the point at which the old implementation would have started executing.
582        let mut allocator = Allocator::new();
583        let puzzle_ptr = hostile.puzzle_reveal.to_clvm(&mut allocator)?;
584        let hostile = crate::types::CoinSpend::new(
585            Coin::new(
586                hostile.coin.parent_coin_info,
587                tree_hash(&allocator, puzzle_ptr).into(),
588                0,
589            ),
590            hostile.puzzle_reveal,
591            hostile.solution,
592        );
593
594        let launcher_coin = Coin::new(
595            hostile.coin.coin_id(),
596            Bytes32::from(SINGLETON_LAUNCHER_HASH),
597            1,
598        );
599        let store_id = launcher_coin.coin_id();
600        let launcher_spend = CoinSpend::new(
601            launcher_coin,
602            hostile.puzzle_reveal.clone(),
603            hostile.solution.clone(),
604        );
605        let chain = MockChainSource::new()
606            .with_spend(store_id, launcher_spend)
607            .with_spend(hostile.coin.coin_id(), hostile);
608
609        let started = Instant::now();
610        let resolved = resolve_owner_did(store_id, &chain)?;
611        let elapsed = started.elapsed();
612
613        assert_eq!(resolved, None, "a hostile creator is not a DID owner");
614        assert!(
615            elapsed.as_secs() < 2,
616            "the walk must recognise the hop without executing chain-supplied CLVM, but took \
617             {elapsed:?}"
618        );
619        Ok(())
620    }
621
622    /// A plain (non-DID) store resolves to `None`: the launcher's creator is an ordinary coin, not a
623    /// DID — fail-closed, never an error (SPEC §3.7).
624    #[test]
625    fn resolve_owner_did_returns_none_for_a_plain_store() -> anyhow::Result<()> {
626        let mut sim = Simulator::new();
627        let ctx = &mut SpendContext::new();
628        let alice = sim.bls(1);
629        let alice_p2 = StandardLayer::new(alice.pk);
630        let memos = ctx.hint(alice.puzzle_hash)?;
631        alice_p2.spend(
632            ctx,
633            alice.coin,
634            Conditions::new().create_coin(alice.puzzle_hash, 1, memos),
635        )?;
636        let creator_spend = ctx
637            .take()
638            .into_iter()
639            .find(|s| s.coin.coin_id() == alice.coin.coin_id())
640            .expect("standard creator spend present");
641
642        // A DIG store id IS its launcher coin id (the binding the walk enforces).
643        let launcher_coin = Coin::new(alice.coin.coin_id(), Bytes32::new([0xd4; 32]), 1);
644        let store_id = launcher_coin.coin_id();
645        let launcher_spend = CoinSpend::new(
646            launcher_coin,
647            creator_spend.puzzle_reveal.clone(),
648            creator_spend.solution.clone(),
649        );
650
651        let chain = MockChainSource::new()
652            .with_spend(store_id, launcher_spend)
653            .with_spend(alice.coin.coin_id(), creator_spend);
654
655        assert_eq!(
656            resolve_owner_did(store_id, &chain)?,
657            None,
658            "a plainly-minted store has no owning DID"
659        );
660        Ok(())
661    }
662
663    /// A missing launcher spend fails closed to `Ok(None)` — the store is unknown to the source.
664    #[test]
665    fn resolve_owner_did_none_when_launcher_spend_missing() -> anyhow::Result<()> {
666        let chain = MockChainSource::new();
667        assert_eq!(
668            resolve_owner_did(Bytes32::new([0xee; 32]), &chain)?,
669            None,
670            "an unknown store id resolves to None"
671        );
672        Ok(())
673    }
674
675    /// A missing CREATOR spend (launcher present, its parent unknown) also fails closed to `Ok(None)`.
676    #[test]
677    fn resolve_owner_did_none_when_creator_spend_missing() -> anyhow::Result<()> {
678        let parent_id = Bytes32::new([0x2b; 32]);
679        // A launcher spend whose creator (parent) is not in the source. A DIG store id IS its
680        // launcher coin id (the binding the walk enforces).
681        let mut sim = Simulator::new();
682        let (any_spend, _) = did_coin_and_spend(&mut sim)?;
683        let launcher_coin = Coin::new(parent_id, Bytes32::new([0x3c; 32]), 1);
684        let store_id = launcher_coin.coin_id();
685        let launcher_spend = CoinSpend::new(
686            launcher_coin,
687            any_spend.puzzle_reveal.clone(),
688            any_spend.solution.clone(),
689        );
690
691        let chain = MockChainSource::new().with_spend(store_id, launcher_spend);
692        assert_eq!(resolve_owner_did(store_id, &chain)?, None);
693        Ok(())
694    }
695
696    /// A SUBSTITUTED launcher — the source answers `store_id` with a DIFFERENT store's valid,
697    /// DID-rooted launcher — fails closed to `Err(MerkleError::Chain)`, NOT the wrong DID and NOT
698    /// `Ok(None)`. Without the `launcher_spend.coin.coin_id() == store_id` binding this returns
699    /// `Ok(Some(other_did))` and mis-attributes ownership (NC-9, §5.3).
700    #[test]
701    fn resolve_owner_did_rejects_a_substituted_launcher() -> anyhow::Result<()> {
702        let mut sim = Simulator::new();
703        let (did_spend, _did_launcher_id) = did_coin_and_spend(&mut sim)?;
704        let did_coin_id = did_spend.coin.coin_id();
705
706        // A genuine, DID-rooted launcher for store B (its coin_id is store B's real id).
707        let launcher_coin = Coin::new(did_coin_id, Bytes32::new([0xb2; 32]), 1);
708        let store_b_id = launcher_coin.coin_id();
709        let launcher_spend = CoinSpend::new(
710            launcher_coin,
711            did_spend.puzzle_reveal.clone(),
712            did_spend.solution.clone(),
713        );
714
715        // The caller asks for store A, but the source returns store B's launcher under A's id.
716        let store_a_id = Bytes32::new([0xa1; 32]);
717        assert_ne!(
718            store_a_id, store_b_id,
719            "the requested id differs from the answer's coin id"
720        );
721        let chain = MockChainSource::new()
722            .with_spend(store_a_id, launcher_spend)
723            .with_spend(did_coin_id, did_spend);
724
725        assert!(
726            matches!(
727                resolve_owner_did(store_a_id, &chain),
728                Err(MerkleError::Chain(_))
729            ),
730            "a substituted launcher is rejected, not attributed to the other store's DID"
731        );
732        Ok(())
733    }
734
735    /// A WRONG creator — the launcher is genuine, but the source answers `parent_id` with a spend of
736    /// a coin whose `coin_id() != parent_id` — fails closed to `Err(MerkleError::Chain)`. Without the
737    /// `creator_spend.coin.coin_id() == parent_id` binding this recognises a DID that never authorized
738    /// the store (NC-9).
739    #[test]
740    fn resolve_owner_did_rejects_a_wrong_creator() -> anyhow::Result<()> {
741        let mut sim = Simulator::new();
742        let (did_spend, _did_launcher_id) = did_coin_and_spend(&mut sim)?;
743        let did_coin_id = did_spend.coin.coin_id();
744
745        // The launcher's parent is a fabricated id that is NOT the DID coin's id.
746        let fake_parent_id = Bytes32::new([0x7f; 32]);
747        assert_ne!(
748            fake_parent_id, did_coin_id,
749            "the parent id is not the DID coin's id"
750        );
751        let launcher_coin = Coin::new(fake_parent_id, Bytes32::new([0xb2; 32]), 1);
752        let store_id = launcher_coin.coin_id();
753        let launcher_spend = CoinSpend::new(
754            launcher_coin,
755            did_spend.puzzle_reveal.clone(),
756            did_spend.solution.clone(),
757        );
758
759        // The source returns the real DID spend (coin_id == did_coin_id) under the fake parent id.
760        let chain = MockChainSource::new()
761            .with_spend(store_id, launcher_spend)
762            .with_spend(fake_parent_id, did_spend);
763
764        assert!(
765            matches!(
766                resolve_owner_did(store_id, &chain),
767                Err(MerkleError::Chain(_))
768            ),
769            "a creator spend not bound to the launcher's parent is rejected"
770        );
771        Ok(())
772    }
773
774    /// PINS A KNOWN GAP, tracked as **#2463** — this is NOT desired behaviour.
775    ///
776    /// The memo-scannable profile chain (`DID coin -> ordinary EVEN-amount coin -> launcher (memos
777    /// intact) -> store`, SPEC §3.1a) is NOT lineage-resolvable: the launcher's creator is an
778    /// ordinary coin, which is neither a DID nor the recognised intermediate launcher, so the walk
779    /// stops at `Ok(None)` and a DID-rooted profile store reports as not-DID-owned.
780    ///
781    /// Extending the walk over that hop is refused deliberately: an ordinary coin's outputs are
782    /// knowable only by EXECUTING its puzzle — the chain-supplied CLVM the walk exists to never run
783    /// (see [`is_launcher_intermediate`]) — and accepting the parent claim unexamined would let any
784    /// store whose launcher's parent happened to be DID-created falsely claim DID ownership.
785    ///
786    /// Every spend here is real and was accepted by the simulator, so this pins what the composition
787    /// the SPEC recommends actually resolves to today, and will fail the moment #2463 changes it.
788    #[test]
789    fn the_memo_scannable_profile_chain_does_not_resolve_its_did() -> anyhow::Result<()> {
790        let mut sim = Simulator::new();
791        let ctx = &mut SpendContext::new();
792        let alice = sim.bls(1_000_000);
793        let alice_p2 = StandardLayer::new(alice.pk);
794
795        // Every block's spends are kept, because the chain source must serve the WHOLE lineage —
796        // the walk stopping early must be the resolver's doing, not a source that forgot the DID.
797        let mut settled: Vec<CoinSpend> = Vec::new();
798
799        // Block 1 — the DID exists.
800        let (create_did, did) =
801            Launcher::new(alice.coin.coin_id(), 1).create_simple_did(ctx, &alice_p2)?;
802        alice_p2.spend(ctx, alice.coin, create_did)?;
803        let block = ctx.take();
804        sim.spend_coins(block.clone(), std::slice::from_ref(&alice.sk))?;
805        settled.extend(block);
806
807        // Block 2 — the DID creates an ORDINARY, EVEN-amount coin. Even keeps the singleton's
808        // one-odd-`CREATE_COIN` rule satisfied, which is what makes this composition legal.
809        let did_coin_id = did.coin.coin_id();
810        let ordinary = Coin::new(did_coin_id, alice.puzzle_hash, 2);
811        let hint = ctx.hint(alice.puzzle_hash)?;
812        let _child = did.update(
813            ctx,
814            &alice_p2,
815            Conditions::new().create_coin(alice.puzzle_hash, 2, hint),
816        )?;
817        // The DID recreates itself AND emits the 2-mojo coin, so the bundle needs those 2 mojos from
818        // elsewhere; Chia balances a bundle in aggregate, not per coin.
819        let funder = sim.bls(2);
820        StandardLayer::new(funder.pk).spend(ctx, funder.coin, Conditions::new())?;
821        let block = ctx.take();
822        sim.spend_coins(block.clone(), &[alice.sk.clone(), funder.sk.clone()])?;
823        settled.extend(block);
824
825        // Block 3 — the ordinary coin launches the store DIRECTLY, so the memos ARE written.
826        let launch = crate::mint_datastore_launch_with_kind(
827            ctx,
828            crate::StoreKind::DidProfile,
829            Launcher::new(ordinary.coin_id(), 1),
830            Bytes32::new([0x6d; 32]),
831            None,
832            None,
833            None,
834            None,
835            None,
836            alice.puzzle_hash,
837            vec![],
838        )?;
839        assert!(
840            launch.launcher_memos_written,
841            "the direct shape is the one that IS memo-scannable (test precondition)"
842        );
843        alice_p2.spend(ctx, ordinary, launch.parent_conditions.clone())?;
844        let block = ctx.take();
845        sim.spend_coins(block.clone(), std::slice::from_ref(&alice.sk))?;
846        settled.extend(block);
847
848        let store_id = launch.datastore.info.launcher_id;
849        let chain = settled.iter().fold(MockChainSource::new(), |chain, spend| {
850            chain.with_spend(spend.coin.coin_id(), spend.clone())
851        });
852
853        // The `None` must be caused by the ORDINARY hop, not by a fixture that lost its DID: the
854        // launcher's creator really is the ordinary coin, that coin's creator really is the DID coin,
855        // and the source really can serve every spend in between. Without these the assertion below
856        // would also pass on a chain that simply had no DID in it.
857        let launcher_spend = chain
858            .coin_spend(store_id)?
859            .expect("the source serves the launcher spend");
860        assert_eq!(
861            launcher_spend.coin.parent_coin_info,
862            ordinary.coin_id(),
863            "the launcher's creator is the ordinary even-amount coin"
864        );
865        let ordinary_spend = chain
866            .coin_spend(ordinary.coin_id())?
867            .expect("the source serves the ordinary coin's spend");
868        assert_eq!(
869            ordinary_spend.coin.parent_coin_info, did_coin_id,
870            "and that coin's own creator is the DID — the DID sits exactly two hops up"
871        );
872        assert!(
873            crate::did_ref_from_spend(&ordinary_spend)?.is_none()
874                && !super::is_launcher_intermediate(&ordinary_spend, launcher_spend.coin),
875            "the creator is neither a DID nor the recognised intermediate launcher — the only two \
876             shapes the walk can traverse, which is exactly why it stops"
877        );
878        assert!(
879            crate::did_ref_from_spend(
880                &chain
881                    .coin_spend(did_coin_id)?
882                    .expect("the source serves the DID spend")
883            )?
884            .is_some(),
885            "the DID spend IS present and IS recognisable, so only the ordinary hop stops the walk"
886        );
887
888        assert_eq!(
889            resolve_owner_did(store_id, &chain)?,
890            None,
891            "KNOWN GAP #2463: the memo-scannable profile chain resolves to None, because the \
892             launcher's creator is an ordinary coin the walk cannot traverse without running \
893             chain-supplied CLVM"
894        );
895        Ok(())
896    }
897
898    /// A hostile [`ChainSource`] cannot attribute a DID to a store that has none by pairing a GENUINE
899    /// coin with a FORGED puzzle reveal.
900    ///
901    /// The coin-id binding cannot catch this: a coin id is computed from the coin's own fields, so it
902    /// is satisfied by any reveal whatsoever, and `Did::parse` never compares the reveal to
903    /// `coin.puzzle_hash`. Here the creator coin is an ordinary standard-p2 coin with no DID link at
904    /// all, served with a real DID's puzzle reveal — and the walk must refuse rather than name a DID.
905    ///
906    /// The control is the same walk over the same store with the coin's OWN reveal, which correctly
907    /// resolves to `None`: so this observes the reveal binding, not a fixture that could not resolve
908    /// anything.
909    #[test]
910    fn a_forged_puzzle_reveal_cannot_attribute_a_did() -> anyhow::Result<()> {
911        let mut sim = Simulator::new();
912        let (did_spend, _did_launcher_id) = did_coin_and_spend(&mut sim)?;
913
914        // An ordinary standard-p2 coin, spent honestly — it is nobody's DID.
915        let ctx = &mut SpendContext::new();
916        let alice = sim.bls(1);
917        let alice_p2 = StandardLayer::new(alice.pk);
918        alice_p2.spend(ctx, alice.coin, Conditions::new())?;
919        let honest_creator = ctx
920            .take()
921            .into_iter()
922            .find(|spend| spend.coin.coin_id() == alice.coin.coin_id())
923            .expect("the ordinary coin's spend is present");
924
925        let launcher_coin = Coin::new(alice.coin.coin_id(), Bytes32::new([0xb2; 32]), 1);
926        let store_id = launcher_coin.coin_id();
927        let launcher_spend = CoinSpend::new(
928            launcher_coin,
929            honest_creator.puzzle_reveal.clone(),
930            honest_creator.solution.clone(),
931        );
932
933        // Control: served honestly, the store is simply not DID-owned.
934        let honest = MockChainSource::new()
935            .with_spend(store_id, launcher_spend.clone())
936            .with_spend(alice.coin.coin_id(), honest_creator.clone());
937        assert_eq!(
938            resolve_owner_did(store_id, &honest)?,
939            None,
940            "the ordinary creator is not a DID (control)"
941        );
942
943        // The attack: the same genuine coin, served with a real DID's puzzle reveal.
944        let forged = CoinSpend::new(
945            honest_creator.coin,
946            did_spend.puzzle_reveal.clone(),
947            did_spend.solution.clone(),
948        );
949        assert_eq!(
950            forged.coin.coin_id(),
951            alice.coin.coin_id(),
952            "the coin-id binding is satisfied — only the reveal is forged"
953        );
954        let hostile = MockChainSource::new()
955            .with_spend(store_id, launcher_spend)
956            .with_spend(alice.coin.coin_id(), forged);
957
958        assert!(
959            matches!(
960                resolve_owner_did(store_id, &hostile),
961                Err(MerkleError::Chain(_))
962            ),
963            "a reveal the coin never committed to must be refused, never resolved to a DID"
964        );
965        Ok(())
966    }
967
968    /// A [`ChainSource`] read ERROR surfaces as [`MerkleError::Chain`] — distinct from a fail-closed
969    /// `None` (the chain could not be consulted, so ownership is unknown).
970    #[test]
971    fn resolve_owner_did_maps_chain_error() {
972        let chain = MockChainSource::new().fail_with(ChainSourceError::Timeout);
973        let result = resolve_owner_did(Bytes32::new([0x44; 32]), &chain);
974        assert!(
975            matches!(result, Err(MerkleError::Chain(_))),
976            "a source read error is a Chain error, not None"
977        );
978    }
979}