From ca6cff59e270e4cd5b0e0293679a4adbc2040a60 Mon Sep 17 00:00:00 2001 From: Michael Taylor Date: Mon, 20 Jul 2026 16:19:04 -0700 Subject: [PATCH 1/2] feat(cli): scaffold store-status subcommand (#1349) WIP stub: establishes the branch for the `dig-store store-status ` subcommand consuming dig-store 0.5.0 get_store_status via a small coinset-backed dig_chainsource_interface::ChainSource adapter. Implementation follows. From 6aa3a0d64a6a1d99b12bac9343804adb29eb9645 Mon Sep 17 00:00:00 2001 From: Michael Taylor Date: Mon, 20 Jul 2026 17:31:48 -0700 Subject: [PATCH 2/2] feat(cli): add store-status subcommand consuming dig_store::get_store_status (#1349) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add `dig-store store-status [--json] [--confirmation-target N] [--coinset-url URL]`, reporting a store's aggregate ON-CHAIN status (live/ melted/not-found, confirmations, live root, owner puzzle hash, program hash, head signature, coin id, verified, generations). The CLI is a THIN consumer of the fail-closed `dig_store::get_store_status` (dig-store 0.5.0, NC-9, already triple-gated) — it never re-implements the lineage walk. A small coinset-backed adapter presents digs' existing hardened `digstore_chain::coinset::Coinset` transport as the canonical `dig_chainsource_interface::ChainSource`, implementing only the three reads the aggregator exercises (coin_spend, coin_record, peak_height) and failing closed (Err, never a false absence) on the rest. Store status is a raw Chia-chain read, not a dig-node content read, so it does NOT use the dig-node content ladder; the endpoint resolves as --coinset-url > $DIG_COINSET_URL > the default coinset.org URL, giving the user a first-class custom-endpoint override for this surface. Bumps the workspace 0.17.2 -> 0.18.0 (additive minor: a new subcommand). Tests: adapter transport-mapping + fail-closed (None vs Err) + off-path Unsupported; store-id parsing (hex / 0x / URN); coinset-url resolution; Live/Melted/NotFound rendering + --json shape; and end-to-end NotFound + fail-closed through the library against the canonical MockChainSource. --- CHANGELOG.md | 5 + Cargo.lock | 82 +++- Cargo.toml | 2 +- crates/digstore-cli/Cargo.toml | 9 + crates/digstore-cli/src/cli.rs | 61 +++ crates/digstore-cli/src/commands/mod.rs | 6 + .../digstore-cli/src/commands/store_status.rs | 333 +++++++++++++++ .../src/ops/coinset_chain_source.rs | 389 ++++++++++++++++++ crates/digstore-cli/src/ops/mod.rs | 1 + 9 files changed, 881 insertions(+), 7 deletions(-) create mode 100644 crates/digstore-cli/src/commands/store_status.rs create mode 100644 crates/digstore-cli/src/ops/coinset_chain_source.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index 07473a94..f6a345d6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,11 @@ All notable changes to this project are documented here. This project adheres to [Semantic Versioning](https://semver.org) and [Conventional Commits](https://www.conventionalcommits.org). +## [0.18.0] - 2026-07-20 + +### Features +- **cli:** Add `store-status ` subcommand — reports a store's aggregate on-chain status (live/melted/not-found, confirmations, live root, owner, coin id) by consuming the fail-closed `dig_store::get_store_status` via a coinset-backed `dig_chainsource_interface::ChainSource` adapter; supports `--json`, `--confirmation-target`, and a `--coinset-url` / `$DIG_COINSET_URL` endpoint override (#1349) + ## [0.17.2] - 2026-07-20 ### Chores diff --git a/Cargo.lock b/Cargo.lock index 33d0bb37..5dada7b6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2218,6 +2218,32 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6184e33543162437515c2e2b48714794e37845ec9851711914eec9d308f6ebe8" +[[package]] +name = "dig-capsule" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "710b7a953f37c0fe30e1262c32c8a25f76f06d5cbd668904239d65588bc76645" +dependencies = [ + "aes-gcm-siv", + "dig-urn-protocol", + "hex", + "hkdf", + "serde", + "serde_json", + "sha2 0.10.9", + "wasmparser 0.221.3", +] + +[[package]] +name = "dig-chainsource-interface" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "01084c5fb2b4a14db1569da4fef039de14e3db8c5588ded3504945d7333a133c" +dependencies = [ + "chia-protocol", + "thiserror 2.0.18", +] + [[package]] name = "dig-constants" version = "0.5.0" @@ -2229,6 +2255,22 @@ dependencies = [ "hex-literal", ] +[[package]] +name = "dig-merkle" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1dcc3f7b4de976bc2670a01ab241d02f929c8c6688d8a48b1f1885e13476dac6" +dependencies = [ + "chia-protocol", + "chia-puzzle-types", + "chia-sha2 0.26.0", + "chia-wallet-sdk", + "clvm-traits", + "dig-chainsource-interface", + "hex-literal", + "thiserror 2.0.18", +] + [[package]] name = "dig-resolver" version = "0.1.0" @@ -2239,6 +2281,32 @@ dependencies = [ "tokio", ] +[[package]] +name = "dig-store" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e981ef4ea50dbddc5ef435215d0b8c9ad631066d7debf345e76389c7db4af641" +dependencies = [ + "dig-capsule", + "dig-chainsource-interface", + "dig-merkle", + "dig-urn-protocol", + "serde", + "thiserror 2.0.18", +] + +[[package]] +name = "dig-urn-protocol" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "197617ce594fadb31c784717ac37369cbdbe13dc8b5651866f5221fe55be0afc" +dependencies = [ + "hex", + "serde", + "sha2 0.10.9", + "thiserror 1.0.69", +] + [[package]] name = "dig-wallet-backend" version = "0.4.0" @@ -2282,7 +2350,7 @@ dependencies = [ [[package]] name = "digstore-chain" -version = "0.17.2" +version = "0.18.0" dependencies = [ "aes-gcm", "anyhow", @@ -2320,7 +2388,7 @@ dependencies = [ [[package]] name = "digstore-chunker" -version = "0.17.2" +version = "0.18.0" dependencies = [ "digstore-core", "hex", @@ -2330,7 +2398,7 @@ dependencies = [ [[package]] name = "digstore-cli" -version = "0.17.2" +version = "0.18.0" dependencies = [ "anstream 0.6.21", "anstyle", @@ -2343,6 +2411,8 @@ dependencies = [ "clap", "clap_complete", "clap_mangen", + "dig-chainsource-interface", + "dig-store", "digstore-chain", "digstore-chunker", "digstore-compiler", @@ -2396,7 +2466,7 @@ dependencies = [ [[package]] name = "digstore-core" -version = "0.17.2" +version = "0.18.0" dependencies = [ "aes-gcm-siv", "hex", @@ -2493,7 +2563,7 @@ dependencies = [ [[package]] name = "digstore-remote" -version = "0.17.2" +version = "0.18.0" dependencies = [ "async-trait", "axum", @@ -2551,7 +2621,7 @@ dependencies = [ [[package]] name = "digstore-subscription" -version = "0.17.2" +version = "0.18.0" dependencies = [ "async-trait", "digstore-core", diff --git a/Cargo.toml b/Cargo.toml index a5be7025..42a1c601 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -5,7 +5,7 @@ exclude = ["crates/digstore-prover/guest", "crates/dig-client-wasm"] [workspace.package] edition = "2021" -version = "0.17.2" +version = "0.18.0" license = "GPL-2.0-only" [workspace.dependencies] diff --git a/crates/digstore-cli/Cargo.toml b/crates/digstore-cli/Cargo.toml index ff572a85..18859b55 100644 --- a/crates/digstore-cli/Cargo.toml +++ b/crates/digstore-cli/Cargo.toml @@ -42,6 +42,12 @@ clap_mangen = "0.2" anyhow = "1" thiserror = "1" chia-protocol = "0.26" +# `store-status` (#1349): the fail-closed on-chain store-status aggregator lives in the `dig-store` +# LIBRARY crate (NC-9, triple-gated); this CLI is a thin consumer of `dig_store::get_store_status`, +# reading through a coinset-backed adapter over the canonical `dig_chainsource_interface::ChainSource` +# trait. NEVER re-implement the status/lineage logic here. +dig-store = "0.5" +dig-chainsource-interface = "0.1" async-trait = "0.1" tokio = { version = "1", features = ["rt", "rt-multi-thread", "macros", "net", "time"] } reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "json"] } @@ -69,3 +75,6 @@ axum = "0.7" assert_cmd = "2" predicates = "3" tempfile = "3" +# The in-memory canonical ChainSource for exercising `get_store_status` (Live/Melted/NotFound + +# fail-closed) without a network, and the adapter's transport-mapping tests. +dig-chainsource-interface = { version = "0.1", features = ["testing"] } diff --git a/crates/digstore-cli/src/cli.rs b/crates/digstore-cli/src/cli.rs index 4994f5aa..9cdd81e9 100644 --- a/crates/digstore-cli/src/cli.rs +++ b/crates/digstore-cli/src/cli.rs @@ -147,6 +147,32 @@ pub enum Command { Did(DidArgs), /// Make, take, and inspect Chia offers (XCH/CAT trades). Offer(OfferArgs), + /// Show a store's aggregate ON-CHAIN status by store id (live/melted/not-found, + /// confirmations, live root, owner, coin id). Reads Chia chain state via coinset; + /// needs no local store. Distinct from `status` (the local working-tree view). + #[command(name = "store-status")] + StoreStatus(StoreStatusArgs), +} + +/// Arguments for `store-status` (#1349) — a read-only on-chain status lookup by store id. +#[derive(Debug, Args)] +#[command( + after_help = "Reads the store's on-chain status from Chia chain state (coin records + spends) \ +via coinset — NOT the dig-node content ladder. Point at a custom Chia read endpoint with \ +--coinset-url or $DIG_COINSET_URL.\n\nEXAMPLES:\n dig-store store-status \ +<64-hex-store-id>\n dig-store store-status urn:dig:chia: --json\n dig-store store-status \ + --confirmation-target 16 --coinset-url https://api.coinset.org" +)] +pub struct StoreStatusArgs { + /// The store id: 32-byte hex (with or without `0x`), or a `urn:dig:chia:` URN. + pub store_id: String, + /// Confirmation depth (blocks under the peak) at which the live tip is treated as settled. + #[arg(long, default_value_t = dig_store::DEFAULT_CONFIRMATION_TARGET)] + pub confirmation_target: u32, + /// Custom coinset read endpoint (overrides `$DIG_COINSET_URL` and the default). This surface + /// reads raw Chia chain state, so it uses a coinset endpoint, not the §5.3 node ladder. + #[arg(long)] + pub coinset_url: Option, } // =========================================================================== @@ -1310,6 +1336,41 @@ mod tests { } } + #[test] + fn parses_store_status_defaults_and_flags() { + // Defaults: confirmation-target from the library const, no coinset override. + let cli = Cli::try_parse_from(["digstore", "store-status", "abcd"]).unwrap(); + match cli.command { + Command::StoreStatus(a) => { + assert_eq!(a.store_id, "abcd"); + assert_eq!( + a.confirmation_target, + dig_store::DEFAULT_CONFIRMATION_TARGET + ); + assert!(a.coinset_url.is_none()); + } + _ => panic!("expected store-status"), + } + // Flags parse. + let cli = Cli::try_parse_from([ + "digstore", + "store-status", + "urn:dig:chia:ab", + "--confirmation-target", + "16", + "--coinset-url", + "https://example.org", + ]) + .unwrap(); + match cli.command { + Command::StoreStatus(a) => { + assert_eq!(a.confirmation_target, 16); + assert_eq!(a.coinset_url.as_deref(), Some("https://example.org")); + } + _ => panic!("expected store-status"), + } + } + #[test] fn parses_cat_urn() { let cli = Cli::try_parse_from(["digstore", "cat", "urn:dig:chia:abcd/readme"]).unwrap(); diff --git a/crates/digstore-cli/src/commands/mod.rs b/crates/digstore-cli/src/commands/mod.rs index 7f18c66a..b84c5f1d 100644 --- a/crates/digstore-cli/src/commands/mod.rs +++ b/crates/digstore-cli/src/commands/mod.rs @@ -43,6 +43,7 @@ pub mod serve; pub mod setup; pub mod staged; pub mod status; +pub mod store_status; pub mod stores; pub mod unstage; pub mod update; @@ -222,6 +223,10 @@ pub fn dispatch(cli: Cli) -> Result<(), CliError> { let ctx = CliContext::workspace_only(workspace_dir, cli.json, cli.verbose); return balance::run(&ctx, &ui); } + // `store-status` reads a store's on-chain status by store id alone — it needs no local + // store/workspace (like `did`/`offer`), and reads raw Chia chain state via coinset (NOT + // the §5.3 dig-node content ladder). See `store_status` for the endpoint resolution. + Command::StoreStatus(a) => return store_status::run(&ui, a), _ => {} } @@ -317,6 +322,7 @@ pub fn dispatch(cli: Cli) -> Result<(), CliError> { | Command::Did(_) | Command::Offer(_) | Command::Collection(_) + | Command::StoreStatus(_) | Command::Cat(_) => { unreachable!("handled above") } diff --git a/crates/digstore-cli/src/commands/store_status.rs b/crates/digstore-cli/src/commands/store_status.rs new file mode 100644 index 00000000..2487f454 --- /dev/null +++ b/crates/digstore-cli/src/commands/store_status.rs @@ -0,0 +1,333 @@ +//! `dig-store store-status ` — report a store's aggregate on-chain status (#1349). +//! +//! This is a THIN consumer of the fail-closed [`dig_store::get_store_status`] aggregator: it +//! resolves a Chia read transport (coinset, with a user override), wraps it as a canonical +//! [`ChainSource`](dig_chainsource_interface::ChainSource) via +//! [`CoinsetChainSource`](crate::ops::coinset_chain_source::CoinsetChainSource), asks the library +//! for the store's status, and renders it. It NEVER re-implements the lineage walk or the +//! Live/Melted/NotFound decision — that money-critical logic is owned + triple-gated in `dig-store`. +//! +//! ## This is a raw Chia-chain read, not a dig-node content read +//! +//! Store status is read from Chia chain state (coin records + spends), so it deliberately does NOT +//! use the §5.3 `dig.local -> localhost -> rpc.dig.net` DIG-node content ladder (a dig-node does +//! not answer raw `coin_record`/`coin_spend` queries). The endpoint is resolved as: +//! `--coinset-url` > `$DIG_COINSET_URL` > the default coinset.org URL — giving the user a +//! first-class way to point at a custom Chia read endpoint. + +use chia_protocol::Bytes32; +use dig_store::{get_store_status, StoreStatus, StoreStatusKind}; + +use crate::cli::StoreStatusArgs; +use crate::error::CliError; +use crate::ops::coinset_chain_source::CoinsetChainSource; +use crate::ui::Ui; + +/// Environment variable that overrides the coinset read endpoint (below `--coinset-url`). +const COINSET_URL_ENV: &str = "DIG_COINSET_URL"; + +/// Runs `store-status`: resolve the endpoint, read the status via the library, render it. +pub fn run(ui: &Ui, args: StoreStatusArgs) -> Result<(), CliError> { + let store_id = parse_store_id(&args.store_id)?; + let coinset_url = resolve_coinset_url(args.coinset_url.as_deref()); + + let chain = CoinsetChainSource::new(digstore_chain::coinset::Coinset::with_url(coinset_url)) + .map_err(|e| CliError::Network(format!("chain source: {e}")))?; + + let status = get_store_status(&chain, store_id, args.confirmation_target).map_err(|e| { + // A library error means the chain could NOT be read reliably (fail closed) — it is NOT a + // "store not found" (that is a successful `StoreStatusKind::NotFound` result). + CliError::Chain(format!("could not read store status: {e}")) + })?; + + if ui.json() { + ui.emit_json(&status); + } else { + for (label, value) in human_rows(&status) { + ui.line(format!("{label:<20} {value}")); + } + } + Ok(()) +} + +/// Resolves the coinset read endpoint: `--coinset-url` wins, then `$DIG_COINSET_URL`, then the +/// crate default (coinset.org). The env var is read directly (not a clap `env=`) so the flag and +/// the env var stay independently observable, mirroring how `--node` is resolved elsewhere. +fn resolve_coinset_url(flag: Option<&str>) -> String { + if let Some(url) = flag { + return url.to_string(); + } + if let Ok(url) = std::env::var(COINSET_URL_ENV) { + if !url.trim().is_empty() { + return url; + } + } + digstore_chain::config::DEFAULT_COINSET_URL.to_string() +} + +/// Parses a store id from a bare 32-byte hex string, an `0x`-prefixed hex string, or a +/// `urn:dig:chia:[:]` URN (the store-id body is taken). +fn parse_store_id(raw: &str) -> Result { + let trimmed = raw.trim(); + // Accept a full store/capsule URN by taking the store-id body. + let body = trimmed + .strip_prefix("urn:dig:chia:") + .unwrap_or(trimmed) + // A capsule URN pins a root after `:`; the store id is the part before it. + .split(':') + .next() + .unwrap_or("") + .trim_start_matches("0x"); + + let bytes = hex::decode(body).map_err(|_| { + CliError::InvalidArgument(format!( + "store id must be 32-byte hex (64 hex chars), got: {raw}" + )) + })?; + let arr: [u8; 32] = bytes.try_into().map_err(|_| { + CliError::InvalidArgument(format!( + "store id must be exactly 32 bytes (64 hex chars), got: {raw}" + )) + })?; + Ok(Bytes32::new(arr)) +} + +/// The human-readable label/value rows for a [`StoreStatus`], in display order. A `None` optional +/// field renders as `-`. Pure (no I/O) so the rendering is unit-testable. +fn human_rows(status: &StoreStatus) -> Vec<(&'static str, String)> { + let dash = || "-".to_string(); + let confirmations = match &status.confirmations { + Some(c) => format!("{} / {}", c.have, c.target), + None => dash(), + }; + vec![ + ("Status", status_label(status.status).to_string()), + ("Confirmations", confirmations), + ("Store ID", status.store_id.clone()), + ( + "Owner puzzle hash", + status.owner_puzzle_hash.clone().unwrap_or_else(dash), + ), + ("Live root", status.live_root.clone().unwrap_or_else(dash)), + ( + "Program hash", + status.program_hash.clone().unwrap_or_else(dash), + ), + ( + "Head signature", + status.head_signature.clone().unwrap_or_else(dash), + ), + ("Coin ID", status.coin_id.clone().unwrap_or_else(dash)), + ("Verified", status.verified.to_string()), + ("Generations", status.generation_count.to_string()), + ] +} + +/// The human label for a [`StoreStatusKind`]. +fn status_label(kind: StoreStatusKind) -> &'static str { + match kind { + StoreStatusKind::Live => "live", + StoreStatusKind::Melted => "melted", + StoreStatusKind::NotFound => "not found", + } +} + +#[cfg(test)] +mod tests { + use super::*; + use dig_chainsource_interface::{ChainSourceError, MockChainSource}; + use dig_store::{Confirmations, DEFAULT_CONFIRMATION_TARGET}; + + fn rows_map(status: &StoreStatus) -> std::collections::HashMap<&'static str, String> { + human_rows(status).into_iter().collect() + } + + fn hex64(byte: u8) -> String { + format!("{byte:02x}").repeat(32) + } + + // --- store-id parsing ------------------------------------------------------------------- + + #[test] + fn parses_bare_hex_store_id() { + let id = parse_store_id(&hex64(0xab)).unwrap(); + assert_eq!(id, Bytes32::new([0xab; 32])); + } + + #[test] + fn parses_0x_prefixed_and_urn_forms() { + let expected = Bytes32::new([0xcd; 32]); + assert_eq!( + parse_store_id(&format!("0x{}", hex64(0xcd))).unwrap(), + expected + ); + assert_eq!( + parse_store_id(&format!("urn:dig:chia:{}", hex64(0xcd))).unwrap(), + expected + ); + // A capsule URN (store:root) resolves to the store-id body. + assert_eq!( + parse_store_id(&format!("urn:dig:chia:{}:{}", hex64(0xcd), hex64(0x11))).unwrap(), + expected + ); + } + + #[test] + fn rejects_non_hex_and_wrong_length() { + assert!(matches!( + parse_store_id("zzzz").unwrap_err(), + CliError::InvalidArgument(_) + )); + assert!(matches!( + parse_store_id("abcd").unwrap_err(), + CliError::InvalidArgument(_) + )); + } + + // --- coinset-url resolution ------------------------------------------------------------- + + #[test] + fn flag_beats_env_and_default() { + assert_eq!( + resolve_coinset_url(Some("https://flag.example")), + "https://flag.example" + ); + } + + #[test] + fn falls_back_to_default_when_no_flag_or_env() { + // Not asserting on the env var here (tests share a process); a None flag with no env set + // must yield the crate default. + if std::env::var(COINSET_URL_ENV).is_err() { + assert_eq!( + resolve_coinset_url(None), + digstore_chain::config::DEFAULT_COINSET_URL + ); + } + } + + // --- rendering: Live / Melted / NotFound ------------------------------------------------ + + fn live_status() -> StoreStatus { + StoreStatus { + status: StoreStatusKind::Live, + store_id: hex64(0x01), + confirmations: Some(Confirmations { + have: 40, + target: 32, + }), + owner_puzzle_hash: Some(hex64(0x02)), + live_root: Some(hex64(0x03)), + program_hash: Some(hex64(0x04)), + head_signature: None, + coin_id: Some(hex64(0x05)), + verified: true, + generation_count: 3, + } + } + + #[test] + fn renders_live_status_fields() { + let rows = rows_map(&live_status()); + assert_eq!(rows["Status"], "live"); + assert_eq!(rows["Confirmations"], "40 / 32"); + assert_eq!(rows["Store ID"], hex64(0x01)); + assert_eq!(rows["Owner puzzle hash"], hex64(0x02)); + assert_eq!(rows["Live root"], hex64(0x03)); + assert_eq!(rows["Program hash"], hex64(0x04)); + assert_eq!(rows["Head signature"], "-"); + assert_eq!(rows["Coin ID"], hex64(0x05)); + assert_eq!(rows["Verified"], "true"); + assert_eq!(rows["Generations"], "3"); + } + + #[test] + fn renders_melted_status_with_dashes_and_generation_count() { + let status = StoreStatus { + status: StoreStatusKind::Melted, + store_id: hex64(0x0a), + confirmations: None, + owner_puzzle_hash: None, + live_root: None, + program_hash: None, + head_signature: None, + coin_id: None, + verified: false, + generation_count: 7, + }; + let rows = rows_map(&status); + assert_eq!(rows["Status"], "melted"); + assert_eq!(rows["Confirmations"], "-"); + assert_eq!(rows["Live root"], "-"); + assert_eq!(rows["Coin ID"], "-"); + assert_eq!(rows["Verified"], "false"); + assert_eq!(rows["Generations"], "7"); + } + + #[test] + fn renders_not_found_status() { + let status = StoreStatus { + status: StoreStatusKind::NotFound, + store_id: hex64(0x0b), + confirmations: None, + owner_puzzle_hash: None, + live_root: None, + program_hash: None, + head_signature: None, + coin_id: None, + verified: false, + generation_count: 0, + }; + let rows = rows_map(&status); + assert_eq!(rows["Status"], "not found"); + assert_eq!(rows["Generations"], "0"); + assert_eq!(rows["Verified"], "false"); + } + + // --- JSON shape: StoreStatus serializes directly ---------------------------------------- + + #[test] + fn json_shape_matches_store_status_fields() { + let value = serde_json::to_value(live_status()).unwrap(); + assert_eq!(value["status"], "live"); + assert_eq!(value["store_id"], hex64(0x01)); + assert_eq!(value["confirmations"]["have"], 40); + assert_eq!(value["confirmations"]["target"], 32); + assert_eq!(value["verified"], true); + assert_eq!(value["generation_count"], 3); + assert_eq!(value["head_signature"], serde_json::Value::Null); + } + + // --- end-to-end through the library with the canonical mock source ---------------------- + + #[test] + fn empty_chain_reports_not_found() { + // A launcher with no spend on chain → the library walk concludes NotFound. This exercises + // the real `get_store_status` path against the canonical `MockChainSource`. + let chain = MockChainSource::new().with_peak(1000); + let status = get_store_status( + &chain, + Bytes32::new([0x42; 32]), + DEFAULT_CONFIRMATION_TARGET, + ) + .unwrap(); + assert_eq!(status.status, StoreStatusKind::NotFound); + assert_eq!(status.generation_count, 0); + assert!(!status.verified); + } + + #[test] + fn transport_failure_fails_closed_not_absent() { + // A source that cannot answer must surface an error, NEVER a false NotFound. + let chain = MockChainSource::new().fail_with(ChainSourceError::Timeout); + let result = get_store_status( + &chain, + Bytes32::new([0x42; 32]), + DEFAULT_CONFIRMATION_TARGET, + ); + assert!( + result.is_err(), + "transport failure must not degrade to NotFound" + ); + } +} diff --git a/crates/digstore-cli/src/ops/coinset_chain_source.rs b/crates/digstore-cli/src/ops/coinset_chain_source.rs new file mode 100644 index 00000000..50fab9ce --- /dev/null +++ b/crates/digstore-cli/src/ops/coinset_chain_source.rs @@ -0,0 +1,389 @@ +//! [`CoinsetChainSource`] — a coinset-backed adapter presenting digs' existing +//! [`ChainReads`] transport as the ecosystem-canonical +//! [`dig_chainsource_interface::ChainSource`] trait (#1349). +//! +//! ## Why this exists (a TRANSPORT adapter only) +//! +//! `dig_store::get_store_status` reads chain state through the one canonical +//! [`ChainSource`](dig_chainsource_interface::ChainSource) trait. digs already owns a hardened +//! Chia read transport ([`digstore_chain::coinset::Coinset`], with retry/timeout for coinset.org's +//! transient truncation, #84) behind its OWN [`ChainReads`](digstore_chain::coinset::ChainReads) +//! trait — a *different* trait. Rather than pull in a heavyweight peer-connecting provider just to +//! run one read subcommand, this adapter bridges the two: it maps digs' `ChainReads` results onto +//! `ChainSource`'s result shapes and preserves the fail-closed contract. +//! +//! It supplies ONLY transport reads. The money-critical singleton lineage walk that turns those +//! reads into a `StoreStatus` lives entirely inside `dig-store` (NC-9, already triple-gated) — this +//! adapter never interprets lineage, never decides Live/Melted, and holds no keys. +//! +//! ## Fail-closed mapping (the soundness crux, mirrored from the trait) +//! +//! - `Ok(None)` / an empty answer = the transport reliably reported genuine absence. Safe. +//! - `Err(_)` = the transport could NOT answer (network/parse). The consumer MUST fail closed; a +//! transport failure is NEVER degraded to "absent". Every `ChainReads` error therefore maps to +//! [`ChainSourceError::Transport`], never to `Ok(None)`. +//! +//! ## Scope: only what `get_store_status` calls +//! +//! `get_store_status` exercises exactly three reads — [`coin_spend`](ChainSource::coin_spend), +//! [`coin_record`](ChainSource::coin_record), and [`peak_height`](ChainSource::peak_height). The +//! remaining trait methods are not on that path; they return +//! [`ChainSourceError::Unsupported`] (fail closed) rather than a misleading empty answer, so any +//! future caller that reaches for them gets a loud "not provided by this adapter" instead of a +//! silent wrong absence. + +use chia_protocol::{Bytes32, CoinSpend}; +use dig_chainsource_interface::{ChainSource, ChainSourceError, CoinRecord}; +use digstore_chain::coinset::{ChainReads, CoinInfo}; + +/// Adapts any digs [`ChainReads`] transport into the canonical [`ChainSource`] trait for +/// `dig_store::get_store_status`. See the module docs for the fail-closed contract. +/// +/// Generic over the transport so the production [`Coinset`](digstore_chain::coinset::Coinset) and an +/// in-memory test double share one code path. Because `ChainReads` is async and `ChainSource` is +/// synchronous, the adapter owns a single-threaded Tokio runtime and drives each read to completion +/// on it; `get_store_status` runs on a plain sync call stack (no ambient runtime), so this never +/// nests a `block_on`. +pub struct CoinsetChainSource { + chain: C, + runtime: tokio::runtime::Runtime, +} + +impl CoinsetChainSource { + /// Wraps `chain`, building the dedicated current-thread runtime used to drive its async reads. + /// + /// # Errors + /// + /// Returns a [`ChainSourceError::Transport`] if the Tokio runtime cannot be built. + pub fn new(chain: C) -> Result { + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .map_err(|e| ChainSourceError::Transport(format!("tokio runtime: {e}")))?; + Ok(Self { chain, runtime }) + } +} + +/// Maps digs' [`CoinInfo`] onto the canonical [`CoinRecord`], following the "None means not known" +/// convention: a zero confirmed height/timestamp is coinset's "unknown" sentinel, surfaced as +/// `None`, and a spent height is present only when the coin is actually spent. +fn to_coin_record(info: CoinInfo) -> CoinRecord { + CoinRecord { + coin: info.coin, + confirmed_height: (info.confirmed_block_index != 0).then_some(info.confirmed_block_index), + spent_height: info.spent.then_some(info.spent_block_index), + timestamp: (info.timestamp != 0).then_some(info.timestamp), + coinbase: info.coinbase, + } +} + +impl ChainSource for CoinsetChainSource { + type Error = ChainSourceError; + + fn coin_record(&self, coin_id: Bytes32) -> Result, Self::Error> { + self.runtime + .block_on(self.chain.coin_record(coin_id)) + .map(|opt| opt.map(to_coin_record)) + .map_err(|e| ChainSourceError::Transport(e.to_string())) + } + + fn coin_spend(&self, coin_id: Bytes32) -> Result, Self::Error> { + // digs' `coin_spend` needs the spend height, which the canonical trait doesn't take, so we + // first read the coin record to learn it. This also encodes the correct absence semantics: + // an unknown coin has no spend (`Ok(None)`), and an UNSPENT coin has no spend YET + // (`Ok(None)`) — the latter is exactly how the lineage walk detects a live tip. A transport + // failure on either read fails closed (`Err`), never a false `None`. + let info = match self + .runtime + .block_on(self.chain.coin_record(coin_id)) + .map_err(|e| ChainSourceError::Transport(e.to_string()))? + { + Some(info) => info, + None => return Ok(None), + }; + if !info.spent { + return Ok(None); + } + self.runtime + .block_on(self.chain.coin_spend(coin_id, info.spent_block_index)) + .map_err(|e| ChainSourceError::Transport(e.to_string())) + } + + fn peak_height(&self) -> Result, Self::Error> { + self.runtime + .block_on(self.chain.peak_height()) + .map(Some) + .map_err(|e| ChainSourceError::Transport(e.to_string())) + } + + // --- Not on the `get_store_status` read path: fail closed, never a silent empty answer. ------ + + fn coin_records_by_puzzle_hash( + &self, + _puzzle_hash: Bytes32, + _include_spent: bool, + ) -> Result, Self::Error> { + Err(ChainSourceError::Unsupported("coin_records_by_puzzle_hash")) + } + + fn coin_records_by_parent( + &self, + _parent_coin_id: Bytes32, + ) -> Result, Self::Error> { + Err(ChainSourceError::Unsupported("coin_records_by_parent")) + } + + fn resolve_singleton_lineage( + &self, + _launcher_id: Bytes32, + ) -> Result, Self::Error> { + Err(ChainSourceError::Unsupported("resolve_singleton_lineage")) + } + + fn block_timestamp(&self, _height: u32) -> Result, Self::Error> { + Err(ChainSourceError::Unsupported("block_timestamp")) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use async_trait::async_trait; + use chia_protocol::{Coin, SpendBundle}; + use digstore_chain::error::ChainError; + use digstore_chain::Result as ChainResult; + + fn id(byte: u8) -> Bytes32 { + Bytes32::new([byte; 32]) + } + + fn sample_coin() -> Coin { + Coin::new(id(0xaa), id(0xbb), 1) + } + + fn sample_spend() -> CoinSpend { + CoinSpend::new(sample_coin(), vec![1u8, 2, 3].into(), vec![4u8, 5].into()) + } + + /// A hand-driven [`ChainReads`] double: each read returns a queued canned outcome so the + /// adapter's mapping + fail-closed behaviour can be asserted without a network. + #[derive(Default)] + struct FakeChain { + coin_record: Option>>, + coin_spend: Option>>, + peak: Option>, + } + + #[async_trait] + impl ChainReads for FakeChain { + async fn unspent_coins(&self, _puzzle_hash: Bytes32) -> ChainResult> { + Ok(Vec::new()) + } + async fn unspent_coins_by_hint(&self, _hint: Bytes32) -> ChainResult> { + Ok(Vec::new()) + } + async fn coin_records_by_puzzle_hash( + &self, + _puzzle_hash: Bytes32, + _include_spent: bool, + ) -> ChainResult> { + Ok(Vec::new()) + } + async fn coin_record(&self, _name: Bytes32) -> ChainResult> { + match self.coin_record.as_ref().expect("coin_record queued") { + Ok(v) => Ok(v.clone()), + Err(e) => Err(ChainError::Chain(e.to_string())), + } + } + async fn coin_spend( + &self, + _coin_id: Bytes32, + _spent_height: u32, + ) -> ChainResult> { + match self.coin_spend.as_ref().expect("coin_spend queued") { + Ok(v) => Ok(v.clone()), + Err(e) => Err(ChainError::Chain(e.to_string())), + } + } + async fn peak_height(&self) -> ChainResult { + match self.peak.as_ref().expect("peak queued") { + Ok(v) => Ok(*v), + Err(e) => Err(ChainError::Chain(e.to_string())), + } + } + async fn push(&self, _bundle: SpendBundle) -> ChainResult<()> { + Ok(()) + } + async fn estimate_fee(&self, _bundle: &SpendBundle, _target_secs: u64) -> ChainResult { + Ok(0) + } + } + + fn adapter(chain: FakeChain) -> CoinsetChainSource { + CoinsetChainSource::new(chain).expect("runtime") + } + + #[test] + fn coin_record_maps_spent_coin_fields() { + let info = CoinInfo { + coin: sample_coin(), + spent: true, + confirmed_block_index: 100, + spent_block_index: 150, + timestamp: 1_700_000_000, + coinbase: false, + }; + let chain = FakeChain { + coin_record: Some(Ok(Some(info))), + ..Default::default() + }; + let record = adapter(chain).coin_record(id(1)).unwrap().unwrap(); + assert_eq!(record.coin, sample_coin()); + assert_eq!(record.confirmed_height, Some(100)); + assert_eq!(record.spent_height, Some(150)); + assert_eq!(record.timestamp, Some(1_700_000_000)); + assert!(record.is_spent()); + } + + #[test] + fn coin_record_maps_unspent_and_zero_sentinels_to_none() { + let info = CoinInfo { + coin: sample_coin(), + spent: false, + confirmed_block_index: 0, + spent_block_index: 0, + timestamp: 0, + coinbase: false, + }; + let chain = FakeChain { + coin_record: Some(Ok(Some(info))), + ..Default::default() + }; + let record = adapter(chain).coin_record(id(1)).unwrap().unwrap(); + assert_eq!(record.confirmed_height, None); + assert_eq!(record.spent_height, None); + assert_eq!(record.timestamp, None); + assert!(!record.is_spent()); + } + + #[test] + fn coin_record_absence_is_ok_none() { + let chain = FakeChain { + coin_record: Some(Ok(None)), + ..Default::default() + }; + assert_eq!(adapter(chain).coin_record(id(1)).unwrap(), None); + } + + #[test] + fn coin_record_transport_failure_fails_closed() { + let chain = FakeChain { + coin_record: Some(Err(ChainError::Chain("boom".into()))), + ..Default::default() + }; + let err = adapter(chain).coin_record(id(1)).unwrap_err(); + assert!(matches!(err, ChainSourceError::Transport(_))); + } + + #[test] + fn coin_spend_unknown_coin_is_ok_none() { + let chain = FakeChain { + coin_record: Some(Ok(None)), + ..Default::default() + }; + assert_eq!(adapter(chain).coin_spend(id(1)).unwrap(), None); + } + + #[test] + fn coin_spend_unspent_coin_is_ok_none_without_fetching_spend() { + // An unspent tip has no spend yet — the walk relies on this returning `Ok(None)` (Live), + // and the underlying `coin_spend` must NOT be consulted (none is queued, so a call panics). + let info = CoinInfo { + coin: sample_coin(), + spent: false, + confirmed_block_index: 100, + spent_block_index: 0, + timestamp: 0, + coinbase: false, + }; + let chain = FakeChain { + coin_record: Some(Ok(Some(info))), + ..Default::default() + }; + assert_eq!(adapter(chain).coin_spend(id(1)).unwrap(), None); + } + + #[test] + fn coin_spend_spent_coin_returns_the_spend() { + let info = CoinInfo { + coin: sample_coin(), + spent: true, + confirmed_block_index: 100, + spent_block_index: 150, + timestamp: 0, + coinbase: false, + }; + let chain = FakeChain { + coin_record: Some(Ok(Some(info))), + coin_spend: Some(Ok(Some(sample_spend()))), + ..Default::default() + }; + assert_eq!( + adapter(chain).coin_spend(id(1)).unwrap(), + Some(sample_spend()) + ); + } + + #[test] + fn coin_spend_transport_failure_on_record_fails_closed() { + let chain = FakeChain { + coin_record: Some(Err(ChainError::Chain("boom".into()))), + ..Default::default() + }; + assert!(matches!( + adapter(chain).coin_spend(id(1)).unwrap_err(), + ChainSourceError::Transport(_) + )); + } + + #[test] + fn peak_height_wraps_value_and_maps_error() { + let ok = FakeChain { + peak: Some(Ok(1234)), + ..Default::default() + }; + assert_eq!(adapter(ok).peak_height().unwrap(), Some(1234)); + + let err = FakeChain { + peak: Some(Err(ChainError::Chain("down".into()))), + ..Default::default() + }; + assert!(matches!( + adapter(err).peak_height().unwrap_err(), + ChainSourceError::Transport(_) + )); + } + + #[test] + fn off_path_methods_fail_closed_as_unsupported() { + let source = adapter(FakeChain::default()); + assert!(matches!( + source + .coin_records_by_puzzle_hash(id(1), false) + .unwrap_err(), + ChainSourceError::Unsupported(_) + )); + assert!(matches!( + source.coin_records_by_parent(id(1)).unwrap_err(), + ChainSourceError::Unsupported(_) + )); + assert!(matches!( + source.resolve_singleton_lineage(id(1)).unwrap_err(), + ChainSourceError::Unsupported(_) + )); + assert!(matches!( + source.block_timestamp(1).unwrap_err(), + ChainSourceError::Unsupported(_) + )); + } +} diff --git a/crates/digstore-cli/src/ops/mod.rs b/crates/digstore-cli/src/ops/mod.rs index efa1f9b4..ecc1bb88 100644 --- a/crates/digstore-cli/src/ops/mod.rs +++ b/crates/digstore-cli/src/ops/mod.rs @@ -5,6 +5,7 @@ pub mod anchor_state; pub mod anchor_ux; pub mod assets; pub mod client_crypto; +pub mod coinset_chain_source; pub mod consolidate; pub mod dighub; pub mod discovery;