use dig_merkle::{hydrate, resolve_owner_did, MerkleError};
use std::fmt::Write as _;
use crate::chain::ChainSource;
use crate::error::{DigStoreError, DigStoreResult};
use crate::size::SizeBucket;
use crate::types::{
Bytes32, Confirmations, DataStore, DidRef, DigDataStoreMetadata, RootHistory, StoreStatus,
StoreStatusKind,
};
pub fn get_store_urn(store_id: Bytes32) -> String {
crate::urn::store_urn(store_id)
}
pub fn get_latest_root_urn<C: ChainSource>(chain: &C, store_id: Bytes32) -> DigStoreResult<String> {
let root = get_latest_root(chain, store_id)?;
Ok(crate::urn::capsule_urn(store_id, root))
}
pub fn get_store_did_owner<C: ChainSource>(
chain: &C,
store_id: Bytes32,
) -> DigStoreResult<Option<DidRef>> {
resolve_owner_did(store_id, chain)
.map_err(|error| DigStoreError::Proof(format!("owner-DID discovery: {error}")))
}
pub fn get_store_singleton_tip<C: ChainSource>(
chain: &C,
store_id: Bytes32,
) -> DigStoreResult<DataStore<DigDataStoreMetadata>> {
walk_lineage(chain, store_id)?.tip.ok_or_else(|| {
DigStoreError::Proof(format!("store {store_id} has been melted (no live tip)"))
})
}
pub fn get_root_history<C: ChainSource>(
chain: &C,
store_id: Bytes32,
) -> DigStoreResult<RootHistory> {
Ok(RootHistory {
roots: walk_lineage(chain, store_id)?.roots,
})
}
pub fn get_latest_root<C: ChainSource>(chain: &C, store_id: Bytes32) -> DigStoreResult<Bytes32> {
Ok(get_store_singleton_tip(chain, store_id)?
.info
.metadata
.root_hash)
}
pub fn get_store_label<C: ChainSource>(
chain: &C,
store_id: Bytes32,
) -> DigStoreResult<Option<String>> {
Ok(get_store_singleton_tip(chain, store_id)?
.info
.metadata
.label)
}
pub fn get_store_description<C: ChainSource>(
chain: &C,
store_id: Bytes32,
) -> DigStoreResult<Option<String>> {
Ok(get_store_singleton_tip(chain, store_id)?
.info
.metadata
.description)
}
pub fn get_store_size_bucket<C: ChainSource>(
chain: &C,
store_id: Bytes32,
) -> DigStoreResult<Option<SizeBucket>> {
Ok(get_store_singleton_tip(chain, store_id)?
.info
.metadata
.size_bucket)
}
pub fn get_store_program_hash<C: ChainSource>(
chain: &C,
store_id: Bytes32,
) -> DigStoreResult<Option<Bytes32>> {
Ok(get_store_singleton_tip(chain, store_id)?
.info
.metadata
.program_hash)
}
pub const DEFAULT_CONFIRMATION_TARGET: u32 = 32;
pub fn get_store_status<C: ChainSource>(
chain: &C,
store_id: Bytes32,
confirmation_target: u32,
) -> DigStoreResult<StoreStatus> {
let store_id_hex = to_hex(store_id);
match walk_outcome(chain, store_id)? {
WalkOutcome::NotFound => Ok(StoreStatus {
status: StoreStatusKind::NotFound,
store_id: store_id_hex,
confirmations: None,
owner_puzzle_hash: None,
live_root: None,
program_hash: None,
head_signature: None,
coin_id: None,
verified: false,
generation_count: 0,
}),
WalkOutcome::Melted { roots } => Ok(StoreStatus {
status: StoreStatusKind::Melted,
store_id: store_id_hex,
confirmations: None,
owner_puzzle_hash: None,
live_root: None,
program_hash: None,
head_signature: None,
coin_id: None,
verified: false,
generation_count: roots.len(),
}),
WalkOutcome::Live { roots, tip } => {
let coin_id = tip.coin.coin_id();
let record = chain.coin_record(coin_id).map_err(|error| {
DigStoreError::Proof(format!("coin record for tip {coin_id}: {error}"))
})?;
if record.as_ref().is_some_and(|r| r.is_spent()) {
return Err(DigStoreError::Proof(format!(
"store {store_id} tip {coin_id} resolved Live by the lineage walk, but its coin \
record reports it spent (contradiction) — refusing to report Live"
)));
}
let confirmations = match (
peak_height(chain)?,
record.as_ref().and_then(|r| r.confirmed_height),
) {
(Some(peak), Some(confirmed)) => Some(Confirmations {
have: peak.saturating_sub(confirmed),
target: confirmation_target,
}),
_ => None,
};
let verified = record.map(|r| !r.is_spent()).unwrap_or(false);
Ok(StoreStatus {
status: StoreStatusKind::Live,
store_id: store_id_hex,
confirmations,
owner_puzzle_hash: Some(to_hex(tip.info.owner_puzzle_hash)),
live_root: Some(to_hex(tip.info.metadata.root_hash)),
program_hash: tip.info.metadata.program_hash.map(to_hex),
head_signature: None,
coin_id: Some(to_hex(coin_id)),
verified,
generation_count: roots.len(),
})
}
}
}
fn peak_height<C: ChainSource>(chain: &C) -> DigStoreResult<Option<u32>> {
chain
.peak_height()
.map_err(|error| DigStoreError::Proof(format!("peak-height read: {error}")))
}
fn to_hex(value: Bytes32) -> String {
let mut out = String::with_capacity(64);
for byte in value.as_ref() {
let _ = write!(out, "{byte:02x}");
}
out
}
struct Lineage {
roots: Vec<Bytes32>,
tip: Option<DataStore<DigDataStoreMetadata>>,
}
enum WalkOutcome {
NotFound,
Melted {
roots: Vec<Bytes32>,
},
Live {
roots: Vec<Bytes32>,
tip: Box<DataStore<DigDataStoreMetadata>>,
},
}
const MAX_LINEAGE_GENERATIONS: usize = 100_000;
fn walk_lineage<C: ChainSource>(chain: &C, store_id: Bytes32) -> DigStoreResult<Lineage> {
walk_lineage_bounded(chain, store_id, MAX_LINEAGE_GENERATIONS)
}
fn walk_lineage_bounded<C: ChainSource>(
chain: &C,
store_id: Bytes32,
max_generations: usize,
) -> DigStoreResult<Lineage> {
match walk_outcome_bounded(chain, store_id, max_generations)? {
WalkOutcome::NotFound => Err(DigStoreError::Proof(format!(
"store {store_id} launcher not found on chain"
))),
WalkOutcome::Melted { roots } => Ok(Lineage { roots, tip: None }),
WalkOutcome::Live { roots, tip } => Ok(Lineage {
roots,
tip: Some(*tip),
}),
}
}
fn walk_outcome<C: ChainSource>(chain: &C, store_id: Bytes32) -> DigStoreResult<WalkOutcome> {
walk_outcome_bounded(chain, store_id, MAX_LINEAGE_GENERATIONS)
}
fn walk_outcome_bounded<C: ChainSource>(
chain: &C,
store_id: Bytes32,
max_generations: usize,
) -> DigStoreResult<WalkOutcome> {
let Some(launcher_spend) = read_verified_spend(chain, store_id)? else {
return Ok(WalkOutcome::NotFound);
};
let mut current = hydrate(&launcher_spend).map_err(|error| {
DigStoreError::Proof(format!("hydrate launcher of {store_id}: {error}"))
})?;
if current.info.launcher_id != store_id {
return Err(DigStoreError::Proof(format!(
"launcher mismatch: coin_spend({store_id}) hydrated store {}, not the requested store",
current.info.launcher_id
)));
}
let mut roots = Vec::new();
loop {
if roots.len() >= max_generations {
return Err(DigStoreError::Proof(format!(
"store {store_id} lineage exceeds the {max_generations}-generation cap; \
refusing to follow further (possible hostile chain source)"
)));
}
roots.push(current.info.metadata.root_hash);
match read_verified_spend(chain, current.coin.coin_id())? {
None => {
return Ok(WalkOutcome::Live {
roots,
tip: Box::new(current),
})
}
Some(spend) => match hydrate(&spend) {
Ok(child) => current = child,
Err(MerkleError::MissingLineage) => return Ok(WalkOutcome::Melted { roots }),
Err(error) => {
return Err(DigStoreError::Proof(format!(
"hydrate generation of {store_id}: {error}"
)))
}
},
}
}
}
fn read_verified_spend<C: ChainSource>(
chain: &C,
coin_id: Bytes32,
) -> DigStoreResult<Option<crate::types::CoinSpend>> {
let spend = chain
.coin_spend(coin_id)
.map_err(|error| DigStoreError::Proof(format!("chain read for {coin_id}: {error}")))?;
if let Some(spend) = &spend {
let returned = spend.coin.coin_id();
if returned != coin_id {
return Err(DigStoreError::Proof(format!(
"chain source returned a spend for coin {returned} when asked for {coin_id}"
)));
}
}
Ok(spend)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::lifecycle::melt_store;
use crate::lifecycle::{create_store, modify_store, CreateStoreParams, StoreOwner};
use chia_puzzle_types::standard::StandardArgs;
use chia_wallet_sdk::test::Simulator;
use dig_chainsource_interface::{CoinRecord, MockChainSource};
fn id(b: u8) -> Bytes32 {
Bytes32::new([b; 32])
}
#[test]
fn get_store_urn_is_rootless_and_needs_no_chain() {
assert_eq!(
get_store_urn(id(0xaa)),
format!("urn:dig:chia:{}", "aa".repeat(32))
);
}
fn spend_of(
coin_spends: &[crate::types::CoinSpend],
coin_id: Bytes32,
) -> crate::types::CoinSpend {
coin_spends
.iter()
.find(|s| s.coin.coin_id() == coin_id)
.expect("spend present")
.clone()
}
fn two_generation_store() -> anyhow::Result<(Bytes32, MockChainSource)> {
let mut sim = Simulator::new();
let owner = sim.bls(1_000_000);
let owner_ph: Bytes32 = StandardArgs::curry_tree_hash(owner.pk).into();
let mint = create_store(
owner.coin,
StoreOwner::Standard(owner.pk),
owner_ph,
CreateStoreParams {
root_hash: id(0x5a),
size: SizeBucket::from_exponent(0).unwrap(),
label: None,
description: None,
program_hash: None,
fee: 0,
},
)?;
sim.spend_coins(mint.coin_spends.clone(), std::slice::from_ref(&owner.sk))?;
let eve = mint.child.clone().expect("mint yields a child");
let store_id = eve.info.launcher_id;
let modified = modify_store(&eve, StoreOwner::Standard(owner.pk), id(0x77))?;
sim.spend_coins(
modified.coin_spends.clone(),
std::slice::from_ref(&owner.sk),
)?;
let chain = MockChainSource::new()
.with_spend(store_id, spend_of(&mint.coin_spends, store_id))
.with_spend(
eve.coin.coin_id(),
spend_of(&modified.coin_spends, eve.coin.coin_id()),
);
Ok((store_id, chain))
}
fn melted_store() -> anyhow::Result<(Bytes32, MockChainSource)> {
let mut sim = Simulator::new();
let owner = sim.bls(1_000_000);
let owner_ph: Bytes32 = StandardArgs::curry_tree_hash(owner.pk).into();
let mint = create_store(
owner.coin,
StoreOwner::Standard(owner.pk),
owner_ph,
CreateStoreParams {
root_hash: id(0x5a),
size: SizeBucket::from_exponent(0).unwrap(),
label: None,
description: None,
program_hash: None,
fee: 0,
},
)?;
sim.spend_coins(mint.coin_spends.clone(), std::slice::from_ref(&owner.sk))?;
let eve = mint.child.clone().expect("mint yields a child");
let store_id = eve.info.launcher_id;
let modified = modify_store(&eve, StoreOwner::Standard(owner.pk), id(0x77))?;
sim.spend_coins(
modified.coin_spends.clone(),
std::slice::from_ref(&owner.sk),
)?;
let tip = modified.child.clone().expect("modify yields a child");
let melted = melt_store(&tip, StoreOwner::Standard(owner.pk))?;
sim.spend_coins(melted.coin_spends.clone(), std::slice::from_ref(&owner.sk))?;
let chain = MockChainSource::new()
.with_spend(store_id, spend_of(&mint.coin_spends, store_id))
.with_spend(
eve.coin.coin_id(),
spend_of(&modified.coin_spends, eve.coin.coin_id()),
)
.with_spend(
tip.coin.coin_id(),
spend_of(&melted.coin_spends, tip.coin.coin_id()),
);
Ok((store_id, chain))
}
fn live_tip(chain: &MockChainSource, store_id: Bytes32) -> DataStore<DigDataStoreMetadata> {
walk_lineage(chain, store_id)
.expect("walk succeeds")
.tip
.expect("a live tip")
}
fn record(coin: crate::types::Coin, confirmed: Option<u32>, spent: Option<u32>) -> CoinRecord {
CoinRecord {
coin,
confirmed_height: confirmed,
spent_height: spent,
timestamp: None,
coinbase: false,
}
}
#[test]
fn status_live_reports_identity_confirmations_and_verified() -> anyhow::Result<()> {
let (store_id, chain) = two_generation_store()?;
let tip = live_tip(&chain, store_id);
let coin_id = tip.coin.coin_id();
let chain = chain
.with_coin(coin_id, record(tip.coin, Some(100), None))
.with_peak(150);
let status = get_store_status(&chain, store_id, DEFAULT_CONFIRMATION_TARGET)?;
assert_eq!(status.status, StoreStatusKind::Live);
assert_eq!(status.store_id, to_hex(store_id));
assert_eq!(status.live_root, Some(to_hex(id(0x77))));
assert_eq!(
status.owner_puzzle_hash,
Some(to_hex(tip.info.owner_puzzle_hash))
);
assert_eq!(status.coin_id, Some(to_hex(coin_id)));
assert_eq!(status.program_hash, None); assert_eq!(
status.confirmations,
Some(Confirmations {
have: 50,
target: DEFAULT_CONFIRMATION_TARGET
})
);
assert!(status.verified);
assert_eq!(status.generation_count, 2);
assert_eq!(status.head_signature, None);
Ok(())
}
#[test]
fn status_melted_preserves_generation_count_and_clears_identity() -> anyhow::Result<()> {
let (store_id, chain) = melted_store()?;
let status = get_store_status(&chain, store_id, DEFAULT_CONFIRMATION_TARGET)?;
assert_eq!(status.status, StoreStatusKind::Melted);
assert_eq!(status.store_id, to_hex(store_id));
assert_eq!(status.generation_count, 2);
assert_eq!(status.live_root, None);
assert_eq!(status.owner_puzzle_hash, None);
assert_eq!(status.program_hash, None);
assert_eq!(status.coin_id, None);
assert_eq!(status.confirmations, None);
assert!(!status.verified);
Ok(())
}
#[test]
fn status_not_found_clears_every_field() -> anyhow::Result<()> {
let chain = MockChainSource::new();
let status = get_store_status(&chain, id(0x01), DEFAULT_CONFIRMATION_TARGET)?;
assert_eq!(status.status, StoreStatusKind::NotFound);
assert_eq!(status.store_id, to_hex(id(0x01)));
assert_eq!(status.generation_count, 0);
assert!(!status.verified);
assert_eq!(status.confirmations, None);
assert_eq!(status.live_root, None);
assert_eq!(status.owner_puzzle_hash, None);
assert_eq!(status.program_hash, None);
assert_eq!(status.coin_id, None);
assert_eq!(status.head_signature, None);
Ok(())
}
#[test]
fn status_live_unverified_without_a_coin_record() -> anyhow::Result<()> {
let (store_id, chain) = two_generation_store()?;
let status = get_store_status(&chain, store_id, DEFAULT_CONFIRMATION_TARGET)?;
assert_eq!(status.status, StoreStatusKind::Live);
assert!(!status.verified);
assert_eq!(status.confirmations, None);
assert!(status.live_root.is_some());
Ok(())
}
#[test]
fn status_live_vs_spent_record_is_a_contradiction() -> anyhow::Result<()> {
let (store_id, chain) = two_generation_store()?;
let tip = live_tip(&chain, store_id);
let coin_id = tip.coin.coin_id();
let chain = chain.with_coin(coin_id, record(tip.coin, Some(100), Some(120)));
assert!(
matches!(
get_store_status(&chain, store_id, DEFAULT_CONFIRMATION_TARGET),
Err(DigStoreError::Proof(_))
),
"a Live walk contradicted by a spent coin record must fail closed"
);
Ok(())
}
#[test]
fn status_serde_round_trips_and_hex_is_stable() -> anyhow::Result<()> {
let (store_id, chain) = two_generation_store()?;
let tip = live_tip(&chain, store_id);
let coin_id = tip.coin.coin_id();
let chain = chain
.with_coin(coin_id, record(tip.coin, Some(10), None))
.with_peak(42);
let status = get_store_status(&chain, store_id, DEFAULT_CONFIRMATION_TARGET)?;
assert_eq!(
status.store_id,
get_store_urn(store_id).trim_start_matches("urn:dig:chia:")
);
assert_eq!(status.store_id.len(), 64);
assert!(status
.store_id
.bytes()
.all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b)));
let json = serde_json::to_string(&status)?;
let back: StoreStatus = serde_json::from_str(&json)?;
assert_eq!(status, back);
assert!(json.contains("\"status\":\"live\""));
Ok(())
}
#[test]
fn bounded_walk_follows_lineage_under_the_cap() -> anyhow::Result<()> {
let (store_id, chain) = two_generation_store()?;
let lineage = walk_lineage_bounded(&chain, store_id, 10)?;
assert_eq!(lineage.roots, vec![id(0x5a), id(0x77)]);
assert!(lineage.tip.is_some());
Ok(())
}
#[test]
fn bounded_walk_rejects_lineage_over_the_cap() -> anyhow::Result<()> {
let (store_id, chain) = two_generation_store()?;
assert!(
matches!(
walk_lineage_bounded(&chain, store_id, 1),
Err(DigStoreError::Proof(_))
),
"a lineage longer than the cap must fail closed"
);
Ok(())
}
}