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}