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 is created by spending a
4//! DID-authorized coin (an [`crate::Owner::Custom`] mint whose parent is the DID coin). This module
5//! recovers that owning DID by walking the store's launcher lineage one hop up to its creator and
6//! recognising a DID coin spend.
7//!
8//! ## Two layers
9//!
10//! [`did_ref_from_spend`] is the pure, network-free core — it recognises a DID from a single coin
11//! spend. [`resolve_owner_did`] is the launcher-lineage WALK on top: it fetches the two coin spends
12//! (`store_id` → its creator) through the injected CANONICAL
13//! [`dig_chainsource_interface::ChainSource`] read interface (a reference-DOWN pure leaf) and passes
14//! the creator spend to `did_ref_from_spend`, fail-closed to `Ok(None)` at every missing hop.
15//! dig-merkle itself opens no socket (INV-1) — the caller implements the chain read.
16
17use chia_wallet_sdk::driver::{Did, Puzzle};
18use chia_wallet_sdk::prelude::Allocator;
19use clvm_traits::ToClvm;
20use dig_chainsource_interface::ChainSource;
21
22use crate::types::{Bytes32, CoinSpend};
23use crate::{MerkleError, MerkleResult};
24
25/// A reference to a DID, identified by its immutable `launcher_id` (the DID's on-chain identity).
26///
27/// This is the successful result of owner-DID discovery: the launcher id uniquely names the DID that
28/// authorized a store's creation, and a caller resolves it to a full DID document via its own DID
29/// tooling (dig-merkle deliberately holds no `dig-did` dependency).
30#[derive(Debug, Clone, Copy, PartialEq, Eq)]
31pub struct DidRef {
32    /// The DID's launcher id — its permanent on-chain identity.
33    pub launcher_id: Bytes32,
34}
35
36/// Recognises whether a coin spend is a DID spend and, if so, returns its [`DidRef`]. Fail-closed:
37/// any spend that is not a parseable DID yields `Ok(None)` rather than an error.
38///
39/// This is the pure, network-free core of owner-DID discovery. Given the spend of a store launcher's
40/// PARENT coin, a `Some` result means that parent was a DID — i.e. the store is DID-owned — and names
41/// the owning DID. A `None` result means the parent was an ordinary coin (e.g. a plain standard mint,
42/// SPEC §3.7 fail-closed).
43///
44/// The parse runs in a private [`Allocator`], allocating the spend's puzzle and solution and handing
45/// them to the SDK's [`Did::parse`] (the byte-source-of-truth, INV-4). It performs NO network I/O and
46/// never signs or spends — it only inspects the given bytes.
47///
48/// # Authoritative ONLY for a CONFIRMED on-chain spend (NC-9)
49///
50/// This function trusts its input and only recognises STRUCTURE; it does NOT verify that the spend
51/// happened on chain. A caller feeding an unconfirmed, attacker-shaped, or otherwise unverified spend
52/// can be made to mis-attribute ownership — a crafted puzzle that parses as a DID yields a `Some`
53/// that proves nothing. Genuine chain-proven attribution MUST fetch the store launcher's parent spend
54/// from a TRUSTED chain source and verify it was actually spent on chain before trusting the result.
55/// Do NOT treat a `Some` result from an unverified spend as proof of ownership.
56///
57/// # Errors
58///
59/// Returns [`MerkleError::Parse`] if the spend's puzzle/solution CLVM cannot be allocated, or
60/// [`MerkleError::Driver`] if the SDK's DID parser errors on a puzzle that structurally should have
61/// been a DID. A puzzle that simply is not a DID is `Ok(None)`, not an error.
62pub fn did_ref_from_spend(spend: &CoinSpend) -> MerkleResult<Option<DidRef>> {
63    let mut allocator = Allocator::new();
64
65    let puzzle_ptr = spend
66        .puzzle_reveal
67        .to_clvm(&mut allocator)
68        .map_err(|error| MerkleError::Parse(format!("puzzle reveal: {error}")))?;
69    let solution_ptr = spend
70        .solution
71        .to_clvm(&mut allocator)
72        .map_err(|error| MerkleError::Parse(format!("solution: {error}")))?;
73
74    let puzzle = Puzzle::parse(&allocator, puzzle_ptr);
75
76    match Did::parse(&allocator, spend.coin, puzzle, solution_ptr)? {
77        Some((did, _p2_spend)) => Ok(Some(DidRef {
78            launcher_id: did.info.launcher_id,
79        })),
80        None => Ok(None),
81    }
82}
83
84/// Recovers the DID that OWNS the store launched at `store_id`, walking one lineage hop up (SPEC §3.7).
85///
86/// A DID-owned store has its launcher coin created by spending a DID-authorized coin. This walks that
87/// lineage via the injected [`ChainSource`] (INV-1 — dig-merkle opens no socket; the caller supplies
88/// the chain read):
89///
90/// 1. `chain.coin_spend(store_id)` — the launcher coin's spend (`store_id == launcher_id`).
91/// 2. `launcher_spend.coin.parent_coin_info` — the coin that CREATED the launcher.
92/// 3. `chain.coin_spend(parent_id)` — that creator's spend.
93/// 4. [`did_ref_from_spend`] — `Some(DidRef)` if the creator was a DID, else `None`.
94///
95/// It is **fail-closed to `Ok(None)`** at every missing/non-DID step — a store that is simply not
96/// DID-owned is `Ok(None)`, never an error — and READ-ONLY (never signs, spends, or broadcasts). A
97/// [`ChainSource`] read error surfaces as [`MerkleError::Chain`].
98///
99/// # Authoritative ONLY for a CONFIRMED on-chain spend (NC-9)
100///
101/// The DID recognition in step 4 trusts STRUCTURE, not confirmation (see [`did_ref_from_spend`]).
102/// The result is chain-proven ownership ONLY when the injected [`ChainSource`] returns genuine,
103/// confirmed on-chain spends. A source that can be made to return unconfirmed or attacker-shaped
104/// spends can be made to mis-attribute ownership; do not treat a `Some` result as proof of ownership
105/// unless the `ChainSource` is trusted to return confirmed spends.
106///
107/// # Errors
108///
109/// Returns [`MerkleError::Chain`] if a [`ChainSource`] read fails, or [`MerkleError::Parse`] /
110/// [`MerkleError::Driver`] if the creator spend fails to parse (propagated from
111/// [`did_ref_from_spend`]).
112pub fn resolve_owner_did<C: ChainSource>(
113    store_id: Bytes32,
114    chain: &C,
115) -> MerkleResult<Option<DidRef>> {
116    let Some(launcher_spend) = read_coin_spend(chain, store_id)? else {
117        return Ok(None);
118    };
119
120    // Fail-closed identity binding (NC-9): a DIG store id IS its launcher coin id (read.rs docstring
121    // step 1). The injected ChainSource is only trusted to return CONFIRMED spends, never to return
122    // the RIGHT coin — a hostile/buggy source (e.g. the attacker-influenceable public gateway, §5.3)
123    // can answer this read with a DIFFERENT store's valid, DID-rooted launcher. Without this check the
124    // walk would attribute that other store's owning DID to `store_id`. Reject with an error (not
125    // Ok(None)) so a substituted answer is distinguishable from a genuinely non-DID-owned store.
126    if launcher_spend.coin.coin_id() != store_id {
127        return Err(MerkleError::Chain(format!(
128            "launcher spend for {store_id} is coin {}, not the requested store's launcher",
129            launcher_spend.coin.coin_id()
130        )));
131    }
132
133    let parent_id = launcher_spend.coin.parent_coin_info;
134    let Some(creator_spend) = read_coin_spend(chain, parent_id)? else {
135        return Ok(None);
136    };
137
138    // Fail-closed identity binding (NC-9) for the second hop: the creator spend fetched under
139    // `parent_id` must actually BE the coin that created the launcher. As above, a source could return
140    // an unrelated DID spend under this id; without binding it to `parent_id` the walk would recognise
141    // a DID that never authorized this store.
142    if creator_spend.coin.coin_id() != parent_id {
143        return Err(MerkleError::Chain(format!(
144            "creator spend for {parent_id} is coin {}, not the launcher's parent",
145            creator_spend.coin.coin_id()
146        )));
147    }
148
149    did_ref_from_spend(&creator_spend)
150}
151
152/// Reads the spend that spent `coin_id`, mapping the source's own error into [`MerkleError::Chain`]
153/// so the crate's error surface never leaks a generic `ChainSource::Error` type parameter.
154fn read_coin_spend<C: ChainSource>(chain: &C, coin_id: Bytes32) -> MerkleResult<Option<CoinSpend>> {
155    chain
156        .coin_spend(coin_id)
157        .map_err(|error| MerkleError::Chain(format!("chain read for {coin_id}: {error}")))
158}
159
160#[cfg(test)]
161mod tests {
162    use super::*;
163    use chia_wallet_sdk::driver::{Launcher, SpendContext, StandardLayer};
164    use chia_wallet_sdk::test::Simulator;
165
166    /// A real DID coin spend is recognised, and the returned [`DidRef`] carries the DID's own
167    /// launcher id — the proof `did_ref_from_spend` drives the SDK's DID parser correctly.
168    #[test]
169    fn did_spend_is_recognised_with_its_launcher_id() -> anyhow::Result<()> {
170        let mut sim = Simulator::new();
171        let ctx = &mut SpendContext::new();
172
173        let alice = sim.bls(1);
174        let alice_p2 = StandardLayer::new(alice.pk);
175
176        // Create a DID, then settle it on chain so its coin exists to be spent again.
177        let (create_did, did) =
178            Launcher::new(alice.coin.coin_id(), 1).create_simple_did(ctx, &alice_p2)?;
179        alice_p2.spend(ctx, alice.coin, create_did)?;
180        sim.spend_coins(ctx.take(), std::slice::from_ref(&alice.sk))?;
181
182        // Spend the DID coin (an update spend recreates it) — this is the spend we recognise.
183        let did_coin = did.coin;
184        let _child = did.update(ctx, &alice_p2, chia_wallet_sdk::types::Conditions::new())?;
185        let coin_spends = ctx.take();
186        sim.spend_coins(coin_spends.clone(), std::slice::from_ref(&alice.sk))?;
187
188        let did_spend = coin_spends
189            .iter()
190            .find(|s| s.coin.coin_id() == did_coin.coin_id())
191            .expect("the DID coin spend is present");
192
193        let did_ref = did_ref_from_spend(did_spend)?.expect("a DID spend is recognised");
194        assert_eq!(
195            did_ref.launcher_id, did.info.launcher_id,
196            "the DidRef names the DID's own launcher id"
197        );
198        Ok(())
199    }
200
201    /// A plain standard-coin spend is NOT a DID — discovery fails closed to `None`, never an error
202    /// (SPEC §3.7).
203    #[test]
204    fn plain_standard_spend_is_not_a_did() -> anyhow::Result<()> {
205        let mut sim = Simulator::new();
206        let ctx = &mut SpendContext::new();
207
208        let alice = sim.bls(1);
209        let alice_p2 = StandardLayer::new(alice.pk);
210        let memos = ctx.hint(alice.puzzle_hash)?;
211        alice_p2.spend(
212            ctx,
213            alice.coin,
214            chia_wallet_sdk::types::Conditions::new().create_coin(alice.puzzle_hash, 1, memos),
215        )?;
216        let coin_spends = ctx.take();
217
218        let standard_spend = coin_spends
219            .iter()
220            .find(|s| s.coin.coin_id() == alice.coin.coin_id())
221            .expect("the standard coin spend is present");
222
223        assert_eq!(
224            did_ref_from_spend(standard_spend)?,
225            None,
226            "a plain standard spend is not a DID"
227        );
228        Ok(())
229    }
230
231    use crate::resolve_owner_did;
232    use crate::types::{Coin, CoinSpend};
233    use chia_wallet_sdk::types::Conditions;
234    use dig_chainsource_interface::{ChainSourceError, MockChainSource};
235
236    /// Builds a real, on-chain DID and returns (its coin spend, its launcher id). The DID coin is
237    /// created then update-spent so a genuine DID spend exists to be recognised.
238    fn did_coin_and_spend(sim: &mut Simulator) -> anyhow::Result<(CoinSpend, Bytes32)> {
239        let ctx = &mut SpendContext::new();
240        let alice = sim.bls(1);
241        let alice_p2 = StandardLayer::new(alice.pk);
242
243        let (create_did, did) =
244            Launcher::new(alice.coin.coin_id(), 1).create_simple_did(ctx, &alice_p2)?;
245        alice_p2.spend(ctx, alice.coin, create_did)?;
246        sim.spend_coins(ctx.take(), std::slice::from_ref(&alice.sk))?;
247
248        let did_coin = did.coin;
249        let _child = did.update(ctx, &alice_p2, Conditions::new())?;
250        let coin_spends = ctx.take();
251        sim.spend_coins(coin_spends.clone(), std::slice::from_ref(&alice.sk))?;
252
253        let did_spend = coin_spends
254            .into_iter()
255            .find(|s| s.coin.coin_id() == did_coin.coin_id())
256            .expect("the DID coin spend is present");
257        Ok((did_spend, did.info.launcher_id))
258    }
259
260    /// A DID-owned store resolves to the owning DID: the walk fetches the launcher spend, reads its
261    /// creator (the DID coin), and recognises the DID (SPEC §3.7).
262    #[test]
263    fn resolve_owner_did_returns_the_did_for_a_did_rooted_store() -> anyhow::Result<()> {
264        let mut sim = Simulator::new();
265        let (did_spend, did_launcher_id) = did_coin_and_spend(&mut sim)?;
266        let did_coin_id = did_spend.coin.coin_id();
267
268        // The store's launcher coin was created by spending the DID coin: its parent IS the DID coin.
269        // A DIG store id IS its launcher coin id, so derive it from the launcher coin (the binding
270        // the walk now enforces).
271        let launcher_coin = Coin::new(did_coin_id, Bytes32::new([0xb2; 32]), 1);
272        let store_id = launcher_coin.coin_id();
273        // Only `launcher_spend.coin.parent_coin_info` is read by the walk; reuse a real program pair.
274        let launcher_spend = CoinSpend::new(
275            launcher_coin,
276            did_spend.puzzle_reveal.clone(),
277            did_spend.solution.clone(),
278        );
279
280        let chain = MockChainSource::new()
281            .with_spend(store_id, launcher_spend)
282            .with_spend(did_coin_id, did_spend);
283
284        let did_ref = resolve_owner_did(store_id, &chain)?.expect("store is DID-owned");
285        assert_eq!(
286            did_ref.launcher_id, did_launcher_id,
287            "resolve names the owning DID's launcher id"
288        );
289        Ok(())
290    }
291
292    /// A plain (non-DID) store resolves to `None`: the launcher's creator is an ordinary coin, not a
293    /// DID — fail-closed, never an error (SPEC §3.7).
294    #[test]
295    fn resolve_owner_did_returns_none_for_a_plain_store() -> anyhow::Result<()> {
296        let mut sim = Simulator::new();
297        let ctx = &mut SpendContext::new();
298        let alice = sim.bls(1);
299        let alice_p2 = StandardLayer::new(alice.pk);
300        let memos = ctx.hint(alice.puzzle_hash)?;
301        alice_p2.spend(
302            ctx,
303            alice.coin,
304            Conditions::new().create_coin(alice.puzzle_hash, 1, memos),
305        )?;
306        let creator_spend = ctx
307            .take()
308            .into_iter()
309            .find(|s| s.coin.coin_id() == alice.coin.coin_id())
310            .expect("standard creator spend present");
311
312        // A DIG store id IS its launcher coin id (the binding the walk enforces).
313        let launcher_coin = Coin::new(alice.coin.coin_id(), Bytes32::new([0xd4; 32]), 1);
314        let store_id = launcher_coin.coin_id();
315        let launcher_spend = CoinSpend::new(
316            launcher_coin,
317            creator_spend.puzzle_reveal.clone(),
318            creator_spend.solution.clone(),
319        );
320
321        let chain = MockChainSource::new()
322            .with_spend(store_id, launcher_spend)
323            .with_spend(alice.coin.coin_id(), creator_spend);
324
325        assert_eq!(
326            resolve_owner_did(store_id, &chain)?,
327            None,
328            "a plainly-minted store has no owning DID"
329        );
330        Ok(())
331    }
332
333    /// A missing launcher spend fails closed to `Ok(None)` — the store is unknown to the source.
334    #[test]
335    fn resolve_owner_did_none_when_launcher_spend_missing() -> anyhow::Result<()> {
336        let chain = MockChainSource::new();
337        assert_eq!(
338            resolve_owner_did(Bytes32::new([0xee; 32]), &chain)?,
339            None,
340            "an unknown store id resolves to None"
341        );
342        Ok(())
343    }
344
345    /// A missing CREATOR spend (launcher present, its parent unknown) also fails closed to `Ok(None)`.
346    #[test]
347    fn resolve_owner_did_none_when_creator_spend_missing() -> anyhow::Result<()> {
348        let parent_id = Bytes32::new([0x2b; 32]);
349        // A launcher spend whose creator (parent) is not in the source. A DIG store id IS its
350        // launcher coin id (the binding the walk enforces).
351        let mut sim = Simulator::new();
352        let (any_spend, _) = did_coin_and_spend(&mut sim)?;
353        let launcher_coin = Coin::new(parent_id, Bytes32::new([0x3c; 32]), 1);
354        let store_id = launcher_coin.coin_id();
355        let launcher_spend = CoinSpend::new(
356            launcher_coin,
357            any_spend.puzzle_reveal.clone(),
358            any_spend.solution.clone(),
359        );
360
361        let chain = MockChainSource::new().with_spend(store_id, launcher_spend);
362        assert_eq!(resolve_owner_did(store_id, &chain)?, None);
363        Ok(())
364    }
365
366    /// A SUBSTITUTED launcher — the source answers `store_id` with a DIFFERENT store's valid,
367    /// DID-rooted launcher — fails closed to `Err(MerkleError::Chain)`, NOT the wrong DID and NOT
368    /// `Ok(None)`. Without the `launcher_spend.coin.coin_id() == store_id` binding this returns
369    /// `Ok(Some(other_did))` and mis-attributes ownership (NC-9, §5.3).
370    #[test]
371    fn resolve_owner_did_rejects_a_substituted_launcher() -> anyhow::Result<()> {
372        let mut sim = Simulator::new();
373        let (did_spend, _did_launcher_id) = did_coin_and_spend(&mut sim)?;
374        let did_coin_id = did_spend.coin.coin_id();
375
376        // A genuine, DID-rooted launcher for store B (its coin_id is store B's real id).
377        let launcher_coin = Coin::new(did_coin_id, Bytes32::new([0xb2; 32]), 1);
378        let store_b_id = launcher_coin.coin_id();
379        let launcher_spend = CoinSpend::new(
380            launcher_coin,
381            did_spend.puzzle_reveal.clone(),
382            did_spend.solution.clone(),
383        );
384
385        // The caller asks for store A, but the source returns store B's launcher under A's id.
386        let store_a_id = Bytes32::new([0xa1; 32]);
387        assert_ne!(
388            store_a_id, store_b_id,
389            "the requested id differs from the answer's coin id"
390        );
391        let chain = MockChainSource::new()
392            .with_spend(store_a_id, launcher_spend)
393            .with_spend(did_coin_id, did_spend);
394
395        assert!(
396            matches!(
397                resolve_owner_did(store_a_id, &chain),
398                Err(MerkleError::Chain(_))
399            ),
400            "a substituted launcher is rejected, not attributed to the other store's DID"
401        );
402        Ok(())
403    }
404
405    /// A WRONG creator — the launcher is genuine, but the source answers `parent_id` with a spend of
406    /// a coin whose `coin_id() != parent_id` — fails closed to `Err(MerkleError::Chain)`. Without the
407    /// `creator_spend.coin.coin_id() == parent_id` binding this recognises a DID that never authorized
408    /// the store (NC-9).
409    #[test]
410    fn resolve_owner_did_rejects_a_wrong_creator() -> anyhow::Result<()> {
411        let mut sim = Simulator::new();
412        let (did_spend, _did_launcher_id) = did_coin_and_spend(&mut sim)?;
413        let did_coin_id = did_spend.coin.coin_id();
414
415        // The launcher's parent is a fabricated id that is NOT the DID coin's id.
416        let fake_parent_id = Bytes32::new([0x7f; 32]);
417        assert_ne!(
418            fake_parent_id, did_coin_id,
419            "the parent id is not the DID coin's id"
420        );
421        let launcher_coin = Coin::new(fake_parent_id, Bytes32::new([0xb2; 32]), 1);
422        let store_id = launcher_coin.coin_id();
423        let launcher_spend = CoinSpend::new(
424            launcher_coin,
425            did_spend.puzzle_reveal.clone(),
426            did_spend.solution.clone(),
427        );
428
429        // The source returns the real DID spend (coin_id == did_coin_id) under the fake parent id.
430        let chain = MockChainSource::new()
431            .with_spend(store_id, launcher_spend)
432            .with_spend(fake_parent_id, did_spend);
433
434        assert!(
435            matches!(
436                resolve_owner_did(store_id, &chain),
437                Err(MerkleError::Chain(_))
438            ),
439            "a creator spend not bound to the launcher's parent is rejected"
440        );
441        Ok(())
442    }
443
444    /// A [`ChainSource`] read ERROR surfaces as [`MerkleError::Chain`] — distinct from a fail-closed
445    /// `None` (the chain could not be consulted, so ownership is unknown).
446    #[test]
447    fn resolve_owner_did_maps_chain_error() {
448        let chain = MockChainSource::new().fail_with(ChainSourceError::Timeout);
449        let result = resolve_owner_did(Bytes32::new([0x44; 32]), &chain);
450        assert!(
451            matches!(result, Err(MerkleError::Chain(_))),
452            "a source read error is a Chain error, not None"
453        );
454    }
455}