Skip to main content

dig_store/
store.rs

1//! The comprehensive on-chain GETTER surface (SPEC §5/§7): every chain-anchored store property.
2//!
3//! Getters split by concern:
4//!
5//! - **URN** getters format the canonical `urn:dig:chia:…` scheme; the rootless store URN needs no
6//!   chain read ([`get_store_urn`], buildable now), the latest-root URN reads the tip first.
7//! - **On-chain** getters read the DataLayer singleton through a [`ChainSource`] and prove every
8//!   value against the chain (NC-9). They are generic over `C: ChainSource` and network-free at the
9//!   crate boundary — the caller supplies the (trusted) chain source.
10//!
11//! The on-chain reads share ONE lineage walk ([`walk_lineage`]): starting from the launcher spend
12//! (`coin_spend(store_id)`), it hydrates each generation with `dig-merkle` and follows the singleton
13//! forward — `coin_spend(tip_coin) == None` marks the live tip; a `MissingLineage` hydration marks a
14//! melt. This yields both the ordered root history and the tip in a single pass, fail-closed at every
15//! missing hop.
16//!
17//! The OFF-CHAIN `.dig` capsule getters (`open_capsule` / `get_capsule_identity`) live in
18//! [`crate::capsule`] — they read a capsule's declared identity from compiled `.dig` module bytes via
19//! `dig-capsule`'s lightweight, wasmtime-free reader (SPEC §5/§11), independent of any chain read.
20
21use dig_merkle::{hydrate, resolve_owner_did, MerkleError};
22
23use std::fmt::Write as _;
24
25use crate::chain::ChainSource;
26use crate::error::{DigStoreError, DigStoreResult};
27use crate::size::SizeBucket;
28use crate::types::{
29    Bytes32, Confirmations, DataStore, DidRef, DigDataStoreMetadata, RootHistory, StoreStatus,
30    StoreStatusKind,
31};
32
33// ---------------------------------------------------------------------------
34// URN getters.
35// ---------------------------------------------------------------------------
36
37/// The ROOTLESS store URN `urn:dig:chia:<store_id>` — the stable handle across all generations.
38///
39/// Needs no chain read: a store id fully determines its store URN. See [`crate::urn::store_urn`].
40pub fn get_store_urn(store_id: Bytes32) -> String {
41    crate::urn::store_urn(store_id)
42}
43
44/// The capsule URN `urn:dig:chia:<store_id>:<latest_root>` pinning the store's latest generation.
45///
46/// Reads the latest root from chain first (NC-9), then formats. See [`get_latest_root`].
47///
48/// # Errors
49///
50/// Returns a [`DigStoreResult`] error if the on-chain read/proof fails.
51pub fn get_latest_root_urn<C: ChainSource>(chain: &C, store_id: Bytes32) -> DigStoreResult<String> {
52    let root = get_latest_root(chain, store_id)?;
53    Ok(crate::urn::capsule_urn(store_id, root))
54}
55
56// ---------------------------------------------------------------------------
57// On-chain getters — proven against the chain via the shared lineage walk (NC-9).
58// ---------------------------------------------------------------------------
59
60/// The DID that owns the store, resolved by walking the launcher's parent spend on chain (NC-9).
61///
62/// Returns `None` for a store minted from an ordinary (non-DID) coin. Fail-closed at every missing
63/// lineage step (SPEC §3.7). Delegated to [`dig_merkle::resolve_owner_did`].
64///
65/// # Errors
66///
67/// Returns a [`DigStoreResult`] error if the chain source fails.
68pub fn get_store_did_owner<C: ChainSource>(
69    chain: &C,
70    store_id: Bytes32,
71) -> DigStoreResult<Option<DidRef>> {
72    resolve_owner_did(store_id, chain)
73        .map_err(|error| DigStoreError::Proof(format!("owner-DID discovery: {error}")))
74}
75
76/// The current confirmed tip of the store singleton — the coin a `modify`/`melt` must consume, fully
77/// hydrated (coin + lineage proof + metadata + owner) so it can be passed straight to
78/// [`crate::modify_store`] / [`crate::melt_store`].
79///
80/// # Errors
81///
82/// Returns [`DigStoreError::Proof`] if the store is absent, has been melted (no live tip), or the
83/// chain source fails.
84pub fn get_store_singleton_tip<C: ChainSource>(
85    chain: &C,
86    store_id: Bytes32,
87) -> DigStoreResult<DataStore<DigDataStoreMetadata>> {
88    walk_lineage(chain, store_id)?.tip.ok_or_else(|| {
89        DigStoreError::Proof(format!("store {store_id} has been melted (no live tip)"))
90    })
91}
92
93/// The ordered history of every merkle root the store has anchored (oldest → newest), proven by
94/// walking the singleton's lineage on chain (NC-9). A melted store still reports every root it
95/// anchored while live.
96///
97/// # Errors
98///
99/// Returns a [`DigStoreResult`] error if the store is absent, the chain source fails, or a lineage
100/// step does not verify.
101pub fn get_root_history<C: ChainSource>(
102    chain: &C,
103    store_id: Bytes32,
104) -> DigStoreResult<RootHistory> {
105    Ok(RootHistory {
106        roots: walk_lineage(chain, store_id)?.roots,
107    })
108}
109
110/// The latest anchored merkle root (the root at the current tip), proven on chain (NC-9).
111///
112/// # Errors
113///
114/// Returns a [`DigStoreResult`] error if the store is absent/melted or the chain source fails.
115pub fn get_latest_root<C: ChainSource>(chain: &C, store_id: Bytes32) -> DigStoreResult<Bytes32> {
116    Ok(get_store_singleton_tip(chain, store_id)?
117        .info
118        .metadata
119        .root_hash)
120}
121
122/// The store's on-chain human label (`dig-merkle` metadata `"l"`), if set.
123///
124/// # Errors
125///
126/// Returns a [`DigStoreResult`] error if the store is absent/melted or the chain source fails.
127pub fn get_store_label<C: ChainSource>(
128    chain: &C,
129    store_id: Bytes32,
130) -> DigStoreResult<Option<String>> {
131    Ok(get_store_singleton_tip(chain, store_id)?
132        .info
133        .metadata
134        .label)
135}
136
137/// The store's on-chain human description (`dig-merkle` metadata `"d"`), if set.
138///
139/// # Errors
140///
141/// Returns a [`DigStoreResult`] error if the store is absent/melted or the chain source fails.
142pub fn get_store_description<C: ChainSource>(
143    chain: &C,
144    store_id: Bytes32,
145) -> DigStoreResult<Option<String>> {
146    Ok(get_store_singleton_tip(chain, store_id)?
147        .info
148        .metadata
149        .description)
150}
151
152/// The store's on-chain size bucket (`dig-merkle` metadata `"sz"`) — the value the SIZE PROOF checks
153/// a download against (SPEC §4).
154///
155/// # Errors
156///
157/// Returns a [`DigStoreResult`] error if the store is absent/melted or the chain source fails.
158pub fn get_store_size_bucket<C: ChainSource>(
159    chain: &C,
160    store_id: Bytes32,
161) -> DigStoreResult<Option<SizeBucket>> {
162    Ok(get_store_singleton_tip(chain, store_id)?
163        .info
164        .metadata
165        .size_bucket)
166}
167
168/// The store's on-chain program hash (`dig-merkle` metadata `"p"`), if set.
169///
170/// # Errors
171///
172/// Returns a [`DigStoreResult`] error if the store is absent/melted or the chain source fails.
173pub fn get_store_program_hash<C: ChainSource>(
174    chain: &C,
175    store_id: Bytes32,
176) -> DigStoreResult<Option<Bytes32>> {
177    Ok(get_store_singleton_tip(chain, store_id)?
178        .info
179        .metadata
180        .program_hash)
181}
182
183// ---------------------------------------------------------------------------
184// The aggregate status getter — one consistent walk + one supplementary tip read.
185// ---------------------------------------------------------------------------
186
187/// The default confirmation depth at which a live tip is treated as settled (blocks under the peak).
188pub const DEFAULT_CONFIRMATION_TARGET: u32 = 32;
189
190/// The aggregate on-chain status of a store, derived from a SINGLE consistent lineage walk plus ONE
191/// supplementary read on the already-resolved tip (NC-9, SPEC §5).
192///
193/// Unlike calling the individual getters (each of which would walk the lineage again, risking a torn
194/// read across a mid-flight spend), this resolves everything from ONE [`walk_outcome`]: `status`,
195/// `generation_count`, and — when live — `live_root` / `owner_puzzle_hash` / `program_hash` /
196/// `coin_id`. `confirmations` and `verified` come from a single `coin_record` read on that SAME
197/// resolved tip coin (never a second lineage walk).
198///
199/// # Consistency + fail-closed rules (NC-9)
200///
201/// - `confirmations` is `Some` only when the source exposed BOTH `peak_height()` and the tip's
202///   `confirmed_height`; `have = peak.saturating_sub(confirmed)`, `target = confirmation_target`.
203/// - `verified` reflects the tip's coin record (`!is_spent()`); a MISSING record yields `false` — the
204///   status is never self-asserted.
205/// - If the walk concluded `Live` (tip unspent) but the coin record reports that SAME tip spent, that
206///   is a contradiction between the two reads — this returns [`DigStoreError::Proof`] rather than
207///   papering over it.
208/// - `head_signature` is always `None`: a per-coin BLS signature is structurally unavailable through
209///   [`ChainSource`] (see [`StoreStatus`]).
210///
211/// # Errors
212///
213/// Returns [`DigStoreError::Proof`] if the chain source fails, a lineage hop does not verify, or the
214/// Live/coin-record contradiction above is detected. A genuinely absent store is NOT an error — it is
215/// [`StoreStatusKind::NotFound`].
216pub fn get_store_status<C: ChainSource>(
217    chain: &C,
218    store_id: Bytes32,
219    confirmation_target: u32,
220) -> DigStoreResult<StoreStatus> {
221    let store_id_hex = to_hex(store_id);
222    match walk_outcome(chain, store_id)? {
223        WalkOutcome::NotFound => Ok(StoreStatus {
224            status: StoreStatusKind::NotFound,
225            store_id: store_id_hex,
226            confirmations: None,
227            owner_puzzle_hash: None,
228            live_root: None,
229            program_hash: None,
230            head_signature: None,
231            coin_id: None,
232            verified: false,
233            generation_count: 0,
234        }),
235        WalkOutcome::Melted { roots } => Ok(StoreStatus {
236            status: StoreStatusKind::Melted,
237            store_id: store_id_hex,
238            confirmations: None,
239            owner_puzzle_hash: None,
240            live_root: None,
241            program_hash: None,
242            head_signature: None,
243            coin_id: None,
244            verified: false,
245            generation_count: roots.len(),
246        }),
247        WalkOutcome::Live { roots, tip } => {
248            let coin_id = tip.coin.coin_id();
249
250            // ONE supplementary read on the ALREADY-resolved tip — never a second lineage walk.
251            let record = chain.coin_record(coin_id).map_err(|error| {
252                DigStoreError::Proof(format!("coin record for tip {coin_id}: {error}"))
253            })?;
254
255            // NC-9 fail-closed cross-check: the walk concluded Live because the tip is unspent
256            // (`coin_spend(tip) == None`); if the coin record reports that SAME tip spent, the two
257            // chain reads disagree — refuse to report a stale Live rather than paper over it.
258            if record.as_ref().is_some_and(|r| r.is_spent()) {
259                return Err(DigStoreError::Proof(format!(
260                    "store {store_id} tip {coin_id} resolved Live by the lineage walk, but its coin \
261                     record reports it spent (contradiction) — refusing to report Live"
262                )));
263            }
264
265            let confirmations = match (
266                peak_height(chain)?,
267                record.as_ref().and_then(|r| r.confirmed_height),
268            ) {
269                (Some(peak), Some(confirmed)) => Some(Confirmations {
270                    have: peak.saturating_sub(confirmed),
271                    target: confirmation_target,
272                }),
273                _ => None,
274            };
275
276            // Never self-assert: a missing coin record leaves the tip unverified.
277            let verified = record.map(|r| !r.is_spent()).unwrap_or(false);
278
279            Ok(StoreStatus {
280                status: StoreStatusKind::Live,
281                store_id: store_id_hex,
282                confirmations,
283                owner_puzzle_hash: Some(to_hex(tip.info.owner_puzzle_hash)),
284                live_root: Some(to_hex(tip.info.metadata.root_hash)),
285                program_hash: tip.info.metadata.program_hash.map(to_hex),
286                head_signature: None,
287                coin_id: Some(to_hex(coin_id)),
288                verified,
289                generation_count: roots.len(),
290            })
291        }
292    }
293}
294
295/// Reads the source's current peak height, mapping its read error into the crate's [`DigStoreError`]
296/// so the getter surface never leaks the generic `ChainSource::Error` type parameter.
297fn peak_height<C: ChainSource>(chain: &C) -> DigStoreResult<Option<u32>> {
298    chain
299        .peak_height()
300        .map_err(|error| DigStoreError::Proof(format!("peak-height read: {error}")))
301}
302
303/// Formats a 32-byte identifier as bare lowercase hex — byte-identical to the URN body form, so a
304/// `StoreStatus` id string equals the id in the store's `urn:dig:chia:<store_id>`.
305fn to_hex(value: Bytes32) -> String {
306    let mut out = String::with_capacity(64);
307    for byte in value.as_ref() {
308        // Infallible: writing to a String never errors.
309        let _ = write!(out, "{byte:02x}");
310    }
311    out
312}
313
314// ---------------------------------------------------------------------------
315// The shared lineage walk.
316// ---------------------------------------------------------------------------
317
318/// The result of walking a store's singleton lineage from the launcher forward.
319struct Lineage {
320    /// Every anchored merkle root, oldest → newest.
321    roots: Vec<Bytes32>,
322    /// The live tip DataStore, or `None` if the store has been melted (its lineage ends in a melt).
323    tip: Option<DataStore<DigDataStoreMetadata>>,
324}
325
326/// The three terminal outcomes of a lineage walk, distinguished so [`get_store_status`] can report
327/// each faithfully from ONE pass (the individual getters map these onto the coarser [`Lineage`] via
328/// [`walk_lineage_bounded`]).
329enum WalkOutcome {
330    /// No launcher spend exists on chain for the requested store id.
331    NotFound,
332    /// The launcher resolved but the lineage ends in a terminal melt — no live tip.
333    Melted {
334        /// Every root anchored while the store was live, oldest → newest.
335        roots: Vec<Bytes32>,
336    },
337    /// The launcher resolved and the walk reached an unspent live tip.
338    Live {
339        /// Every anchored root, oldest → newest (the last is the live root).
340        roots: Vec<Bytes32>,
341        /// The unspent live tip DataStore (boxed — it is far larger than the other variants).
342        tip: Box<DataStore<DigDataStoreMetadata>>,
343    },
344}
345
346/// The hard ceiling on the number of generations the lineage walk will follow.
347///
348/// A well-behaved chain source returns a finite recreation chain, but a hostile or buggy one could
349/// return an endless stream of valid-looking recreation spends, hanging the walk (a DoS). The cap
350/// bounds the walk and fails closed past it. It is deliberately far above any real store's generation
351/// count (a store recreates once per content update).
352const MAX_LINEAGE_GENERATIONS: usize = 100_000;
353
354/// Walks the store singleton and maps the outcome onto the coarser [`Lineage`] the individual
355/// getters consume — BEHAVIOR-PRESERVING over the pre-#1336 walk: a `NotFound` outcome becomes the
356/// same "launcher not found" [`DigStoreError::Proof`], a melt becomes `tip: None`, and a live walk
357/// becomes `tip: Some(_)`. See [`walk_lineage_bounded`]; this is the production entry with the
358/// [`MAX_LINEAGE_GENERATIONS`] cap.
359fn walk_lineage<C: ChainSource>(chain: &C, store_id: Bytes32) -> DigStoreResult<Lineage> {
360    walk_lineage_bounded(chain, store_id, MAX_LINEAGE_GENERATIONS)
361}
362
363/// Maps [`walk_outcome_bounded`] onto the coarser [`Lineage`] the individual getters consume,
364/// BEHAVIOR-PRESERVING: `NotFound` → the "launcher not found" [`DigStoreError::Proof`] the getters
365/// have always returned; `Melted` → `tip: None`; `Live` → `tip: Some(_)`. Every existing getter is
366/// therefore unchanged by the #1336 [`WalkOutcome`] refactor.
367fn walk_lineage_bounded<C: ChainSource>(
368    chain: &C,
369    store_id: Bytes32,
370    max_generations: usize,
371) -> DigStoreResult<Lineage> {
372    match walk_outcome_bounded(chain, store_id, max_generations)? {
373        WalkOutcome::NotFound => Err(DigStoreError::Proof(format!(
374            "store {store_id} launcher not found on chain"
375        ))),
376        WalkOutcome::Melted { roots } => Ok(Lineage { roots, tip: None }),
377        WalkOutcome::Live { roots, tip } => Ok(Lineage {
378            roots,
379            tip: Some(*tip),
380        }),
381    }
382}
383
384/// Walks the store singleton from its launcher spend forward to the tip (or melt), collecting each
385/// generation's anchored root in order — the identity-checked, bounded core of the on-chain reads.
386/// See [`walk_outcome_bounded`] for the walk contract; this is the production entry with the
387/// [`MAX_LINEAGE_GENERATIONS`] cap.
388fn walk_outcome<C: ChainSource>(chain: &C, store_id: Bytes32) -> DigStoreResult<WalkOutcome> {
389    walk_outcome_bounded(chain, store_id, MAX_LINEAGE_GENERATIONS)
390}
391
392/// Walks the store singleton from its launcher spend forward to the tip (or melt), returning the
393/// distinguished [`WalkOutcome`] — the identity-checked, bounded core of every on-chain read.
394///
395/// The walk hydrates the eve store from `coin_spend(store_id)` (the launcher spend), then follows the
396/// singleton: `coin_spend(current_coin)` is the spend that recreated it. A missing launcher spend is
397/// [`WalkOutcome::NotFound`]; `None` at a hop means `current` is the unspent live tip
398/// ([`WalkOutcome::Live`]); a `MissingLineage` hydration means that spend was a terminal melt
399/// ([`WalkOutcome::Melted`]).
400///
401/// # Identity proof against the chain (NC-9) — never trust the source blindly
402///
403/// A `ChainSource` is caller-supplied and, in real deployments, attacker-influenceable (the §5.3
404/// ladder includes the public `rpc.dig.net` gateway). A hostile or buggy source could return a
405/// DIFFERENT store's valid-looking spend for a coin id we asked about; without a check the getters
406/// would then return the WRONG store's root/owner instead of failing closed. So every hop is proven:
407///
408/// - the returned spend's `coin.coin_id()` MUST equal the coin id we requested (a spend for a
409///   different coin is rejected), and
410/// - the hydrated launcher's `launcher_id` MUST equal `store_id` (the store we were asked to read).
411///
412/// Every mismatched / failed hop fails closed with [`DigStoreError::Proof`], and the walk is bounded
413/// by `max_generations` (a hostile endless-recreation stream is rejected, not followed). A genuinely
414/// absent launcher is the fail-CLOSED [`WalkOutcome::NotFound`], not an error.
415fn walk_outcome_bounded<C: ChainSource>(
416    chain: &C,
417    store_id: Bytes32,
418    max_generations: usize,
419) -> DigStoreResult<WalkOutcome> {
420    let Some(launcher_spend) = read_verified_spend(chain, store_id)? else {
421        return Ok(WalkOutcome::NotFound);
422    };
423    let mut current = hydrate(&launcher_spend).map_err(|error| {
424        DigStoreError::Proof(format!("hydrate launcher of {store_id}: {error}"))
425    })?;
426
427    // The hydrated launcher MUST actually be the store we were asked about — otherwise a source that
428    // returned another store's launcher spend would silently answer for the wrong store (NC-9).
429    if current.info.launcher_id != store_id {
430        return Err(DigStoreError::Proof(format!(
431            "launcher mismatch: coin_spend({store_id}) hydrated store {}, not the requested store",
432            current.info.launcher_id
433        )));
434    }
435
436    let mut roots = Vec::new();
437    loop {
438        if roots.len() >= max_generations {
439            return Err(DigStoreError::Proof(format!(
440                "store {store_id} lineage exceeds the {max_generations}-generation cap; \
441                 refusing to follow further (possible hostile chain source)"
442            )));
443        }
444        roots.push(current.info.metadata.root_hash);
445
446        match read_verified_spend(chain, current.coin.coin_id())? {
447            // The current coin is unspent — it is the live tip.
448            None => {
449                return Ok(WalkOutcome::Live {
450                    roots,
451                    tip: Box::new(current),
452                })
453            }
454            Some(spend) => match hydrate(&spend) {
455                // The spend recreated the singleton — advance to the next generation.
456                Ok(child) => current = child,
457                // A terminal melt recreated no successor — the store is closed, no live tip.
458                Err(MerkleError::MissingLineage) => return Ok(WalkOutcome::Melted { roots }),
459                Err(error) => {
460                    return Err(DigStoreError::Proof(format!(
461                        "hydrate generation of {store_id}: {error}"
462                    )))
463                }
464            },
465        }
466    }
467}
468
469/// Reads the spend that spent `coin_id` and PROVES the returned spend actually spent that coin.
470///
471/// `ChainSource::coin_spend(coin_id)` is contracted to return the spend of `coin_id`, but a hostile or
472/// buggy source could return an unrelated spend. This asserts `spend.coin.coin_id() == coin_id` so the
473/// lineage walk can never be steered onto a different coin/store (NC-9). The source's own read error
474/// is mapped into [`DigStoreError::Proof`] so the crate's surface never leaks the generic
475/// `ChainSource::Error` type parameter.
476fn read_verified_spend<C: ChainSource>(
477    chain: &C,
478    coin_id: Bytes32,
479) -> DigStoreResult<Option<crate::types::CoinSpend>> {
480    let spend = chain
481        .coin_spend(coin_id)
482        .map_err(|error| DigStoreError::Proof(format!("chain read for {coin_id}: {error}")))?;
483
484    if let Some(spend) = &spend {
485        let returned = spend.coin.coin_id();
486        if returned != coin_id {
487            return Err(DigStoreError::Proof(format!(
488                "chain source returned a spend for coin {returned} when asked for {coin_id}"
489            )));
490        }
491    }
492    Ok(spend)
493}
494
495#[cfg(test)]
496mod tests {
497    use super::*;
498    use crate::lifecycle::melt_store;
499    use crate::lifecycle::{create_store, modify_store, CreateStoreParams, StoreOwner};
500    use chia_puzzle_types::standard::StandardArgs;
501    use chia_wallet_sdk::test::Simulator;
502    use dig_chainsource_interface::{CoinRecord, MockChainSource};
503
504    fn id(b: u8) -> Bytes32 {
505        Bytes32::new([b; 32])
506    }
507
508    #[test]
509    fn get_store_urn_is_rootless_and_needs_no_chain() {
510        assert_eq!(
511            get_store_urn(id(0xaa)),
512            format!("urn:dig:chia:{}", "aa".repeat(32))
513        );
514    }
515
516    /// The spend of `coin_id` within a set of built coin spends.
517    fn spend_of(
518        coin_spends: &[crate::types::CoinSpend],
519        coin_id: Bytes32,
520    ) -> crate::types::CoinSpend {
521        coin_spends
522            .iter()
523            .find(|s| s.coin.coin_id() == coin_id)
524            .expect("spend present")
525            .clone()
526    }
527
528    /// Builds a real TWO-generation store (mint → one modify) on the simulator and returns its
529    /// `store_id` plus a chain source loaded with the launcher + modify spends.
530    fn two_generation_store() -> anyhow::Result<(Bytes32, MockChainSource)> {
531        let mut sim = Simulator::new();
532        let owner = sim.bls(1_000_000);
533        let owner_ph: Bytes32 = StandardArgs::curry_tree_hash(owner.pk).into();
534        let mint = create_store(
535            owner.coin,
536            StoreOwner::Standard(owner.pk),
537            owner_ph,
538            CreateStoreParams {
539                root_hash: id(0x5a),
540                size: SizeBucket::from_exponent(0).unwrap(),
541                label: None,
542                description: None,
543                program_hash: None,
544                fee: 0,
545            },
546        )?;
547        sim.spend_coins(mint.coin_spends.clone(), std::slice::from_ref(&owner.sk))?;
548        let eve = mint.child.clone().expect("mint yields a child");
549        let store_id = eve.info.launcher_id;
550
551        let modified = modify_store(&eve, StoreOwner::Standard(owner.pk), id(0x77))?;
552        sim.spend_coins(
553            modified.coin_spends.clone(),
554            std::slice::from_ref(&owner.sk),
555        )?;
556
557        let chain = MockChainSource::new()
558            .with_spend(store_id, spend_of(&mint.coin_spends, store_id))
559            .with_spend(
560                eve.coin.coin_id(),
561                spend_of(&modified.coin_spends, eve.coin.coin_id()),
562            );
563        Ok((store_id, chain))
564    }
565
566    /// Builds a real THREE-spend store (mint → modify → melt) and returns its `store_id` plus a
567    /// chain source loaded with all three spends, so the lineage walk terminates in a melt.
568    fn melted_store() -> anyhow::Result<(Bytes32, MockChainSource)> {
569        let mut sim = Simulator::new();
570        let owner = sim.bls(1_000_000);
571        let owner_ph: Bytes32 = StandardArgs::curry_tree_hash(owner.pk).into();
572        let mint = create_store(
573            owner.coin,
574            StoreOwner::Standard(owner.pk),
575            owner_ph,
576            CreateStoreParams {
577                root_hash: id(0x5a),
578                size: SizeBucket::from_exponent(0).unwrap(),
579                label: None,
580                description: None,
581                program_hash: None,
582                fee: 0,
583            },
584        )?;
585        sim.spend_coins(mint.coin_spends.clone(), std::slice::from_ref(&owner.sk))?;
586        let eve = mint.child.clone().expect("mint yields a child");
587        let store_id = eve.info.launcher_id;
588
589        let modified = modify_store(&eve, StoreOwner::Standard(owner.pk), id(0x77))?;
590        sim.spend_coins(
591            modified.coin_spends.clone(),
592            std::slice::from_ref(&owner.sk),
593        )?;
594        let tip = modified.child.clone().expect("modify yields a child");
595
596        let melted = melt_store(&tip, StoreOwner::Standard(owner.pk))?;
597        sim.spend_coins(melted.coin_spends.clone(), std::slice::from_ref(&owner.sk))?;
598
599        let chain = MockChainSource::new()
600            .with_spend(store_id, spend_of(&mint.coin_spends, store_id))
601            .with_spend(
602                eve.coin.coin_id(),
603                spend_of(&modified.coin_spends, eve.coin.coin_id()),
604            )
605            .with_spend(
606                tip.coin.coin_id(),
607                spend_of(&melted.coin_spends, tip.coin.coin_id()),
608            );
609        Ok((store_id, chain))
610    }
611
612    /// The live tip DataStore the lineage walk resolves for `store_id`.
613    fn live_tip(chain: &MockChainSource, store_id: Bytes32) -> DataStore<DigDataStoreMetadata> {
614        walk_lineage(chain, store_id)
615            .expect("walk succeeds")
616            .tip
617            .expect("a live tip")
618    }
619
620    /// A coin record for `coin`, confirmed at `confirmed` and spent at `spent` (both optional).
621    fn record(coin: crate::types::Coin, confirmed: Option<u32>, spent: Option<u32>) -> CoinRecord {
622        CoinRecord {
623            coin,
624            confirmed_height: confirmed,
625            spent_height: spent,
626            timestamp: None,
627            coinbase: false,
628        }
629    }
630
631    /// (a) A live store reports Live with every identity field, confirmations from the single tip
632    /// read, and `verified` true.
633    #[test]
634    fn status_live_reports_identity_confirmations_and_verified() -> anyhow::Result<()> {
635        let (store_id, chain) = two_generation_store()?;
636        let tip = live_tip(&chain, store_id);
637        let coin_id = tip.coin.coin_id();
638        let chain = chain
639            .with_coin(coin_id, record(tip.coin, Some(100), None))
640            .with_peak(150);
641
642        let status = get_store_status(&chain, store_id, DEFAULT_CONFIRMATION_TARGET)?;
643
644        assert_eq!(status.status, StoreStatusKind::Live);
645        assert_eq!(status.store_id, to_hex(store_id));
646        assert_eq!(status.live_root, Some(to_hex(id(0x77))));
647        assert_eq!(
648            status.owner_puzzle_hash,
649            Some(to_hex(tip.info.owner_puzzle_hash))
650        );
651        assert_eq!(status.coin_id, Some(to_hex(coin_id)));
652        assert_eq!(status.program_hash, None); // minted without a program hash.
653        assert_eq!(
654            status.confirmations,
655            Some(Confirmations {
656                have: 50,
657                target: DEFAULT_CONFIRMATION_TARGET
658            })
659        );
660        assert!(status.verified);
661        assert_eq!(status.generation_count, 2);
662        assert_eq!(status.head_signature, None);
663        Ok(())
664    }
665
666    /// (b) A melted store reports Melted, preserves the generation count, and is neither verified nor
667    /// carries identity/confirmation fields.
668    #[test]
669    fn status_melted_preserves_generation_count_and_clears_identity() -> anyhow::Result<()> {
670        let (store_id, chain) = melted_store()?;
671        let status = get_store_status(&chain, store_id, DEFAULT_CONFIRMATION_TARGET)?;
672
673        assert_eq!(status.status, StoreStatusKind::Melted);
674        assert_eq!(status.store_id, to_hex(store_id));
675        assert_eq!(status.generation_count, 2);
676        assert_eq!(status.live_root, None);
677        assert_eq!(status.owner_puzzle_hash, None);
678        assert_eq!(status.program_hash, None);
679        assert_eq!(status.coin_id, None);
680        assert_eq!(status.confirmations, None);
681        assert!(!status.verified);
682        Ok(())
683    }
684
685    /// (c) An unknown store id (empty chain) reports NotFound with every field cleared.
686    #[test]
687    fn status_not_found_clears_every_field() -> anyhow::Result<()> {
688        let chain = MockChainSource::new();
689        let status = get_store_status(&chain, id(0x01), DEFAULT_CONFIRMATION_TARGET)?;
690
691        assert_eq!(status.status, StoreStatusKind::NotFound);
692        assert_eq!(status.store_id, to_hex(id(0x01)));
693        assert_eq!(status.generation_count, 0);
694        assert!(!status.verified);
695        assert_eq!(status.confirmations, None);
696        assert_eq!(status.live_root, None);
697        assert_eq!(status.owner_puzzle_hash, None);
698        assert_eq!(status.program_hash, None);
699        assert_eq!(status.coin_id, None);
700        assert_eq!(status.head_signature, None);
701        Ok(())
702    }
703
704    /// (d) With no coin record on the tip, a live store is still Live but `verified` is false and
705    /// confirmations are absent — the status is never self-asserted.
706    #[test]
707    fn status_live_unverified_without_a_coin_record() -> anyhow::Result<()> {
708        let (store_id, chain) = two_generation_store()?;
709        let status = get_store_status(&chain, store_id, DEFAULT_CONFIRMATION_TARGET)?;
710
711        assert_eq!(status.status, StoreStatusKind::Live);
712        assert!(!status.verified);
713        assert_eq!(status.confirmations, None);
714        assert!(status.live_root.is_some());
715        Ok(())
716    }
717
718    /// (e) NC-9 fail-closed: the walk resolves Live (tip unspent) but the coin record reports that
719    /// SAME tip spent — the contradiction MUST error, not report a stale Live.
720    #[test]
721    fn status_live_vs_spent_record_is_a_contradiction() -> anyhow::Result<()> {
722        let (store_id, chain) = two_generation_store()?;
723        let tip = live_tip(&chain, store_id);
724        let coin_id = tip.coin.coin_id();
725        let chain = chain.with_coin(coin_id, record(tip.coin, Some(100), Some(120)));
726
727        assert!(
728            matches!(
729                get_store_status(&chain, store_id, DEFAULT_CONFIRMATION_TARGET),
730                Err(DigStoreError::Proof(_))
731            ),
732            "a Live walk contradicted by a spent coin record must fail closed"
733        );
734        Ok(())
735    }
736
737    /// (f) A `StoreStatus` snapshot round-trips through JSON unchanged, and its id strings are stable
738    /// bare lowercase hex (byte-identical to the URN body form).
739    #[test]
740    fn status_serde_round_trips_and_hex_is_stable() -> anyhow::Result<()> {
741        let (store_id, chain) = two_generation_store()?;
742        let tip = live_tip(&chain, store_id);
743        let coin_id = tip.coin.coin_id();
744        let chain = chain
745            .with_coin(coin_id, record(tip.coin, Some(10), None))
746            .with_peak(42);
747
748        let status = get_store_status(&chain, store_id, DEFAULT_CONFIRMATION_TARGET)?;
749
750        // Hex is bare lowercase and byte-identical to the URN body form.
751        assert_eq!(
752            status.store_id,
753            get_store_urn(store_id).trim_start_matches("urn:dig:chia:")
754        );
755        assert_eq!(status.store_id.len(), 64);
756        assert!(status
757            .store_id
758            .bytes()
759            .all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b)));
760
761        let json = serde_json::to_string(&status)?;
762        let back: StoreStatus = serde_json::from_str(&json)?;
763        assert_eq!(status, back);
764        // The kind serializes snake_case.
765        assert!(json.contains("\"status\":\"live\""));
766        Ok(())
767    }
768
769    /// A generous cap follows the whole two-generation lineage: both roots, live tip.
770    #[test]
771    fn bounded_walk_follows_lineage_under_the_cap() -> anyhow::Result<()> {
772        let (store_id, chain) = two_generation_store()?;
773        let lineage = walk_lineage_bounded(&chain, store_id, 10)?;
774        assert_eq!(lineage.roots, vec![id(0x5a), id(0x77)]);
775        assert!(lineage.tip.is_some());
776        Ok(())
777    }
778
779    /// A cap below the real generation count fails closed — the walk refuses to follow a lineage past
780    /// the cap (the guard against a hostile endless-recreation chain source). Non-vacuous: the SAME
781    /// lineage succeeds above the cap (see `bounded_walk_follows_lineage_under_the_cap`).
782    #[test]
783    fn bounded_walk_rejects_lineage_over_the_cap() -> anyhow::Result<()> {
784        let (store_id, chain) = two_generation_store()?;
785        assert!(
786            matches!(
787                walk_lineage_bounded(&chain, store_id, 1),
788                Err(DigStoreError::Proof(_))
789            ),
790            "a lineage longer than the cap must fail closed"
791        );
792        Ok(())
793    }
794}