diff --git a/Cargo.lock b/Cargo.lock index b2ad331c..77345702 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2289,7 +2289,7 @@ dependencies = [ [[package]] name = "digstore-chain" -version = "0.14.0" +version = "0.15.0" dependencies = [ "aes-gcm", "anyhow", @@ -2327,7 +2327,7 @@ dependencies = [ [[package]] name = "digstore-chunker" -version = "0.14.0" +version = "0.15.0" dependencies = [ "digstore-core", "hex", @@ -2337,7 +2337,7 @@ dependencies = [ [[package]] name = "digstore-cli" -version = "0.14.0" +version = "0.15.0" dependencies = [ "anstream 0.6.21", "anstyle", @@ -2403,7 +2403,7 @@ dependencies = [ [[package]] name = "digstore-core" -version = "0.14.0" +version = "0.15.0" dependencies = [ "aes-gcm-siv", "hex", @@ -2500,7 +2500,7 @@ dependencies = [ [[package]] name = "digstore-remote" -version = "0.14.0" +version = "0.15.0" dependencies = [ "async-trait", "axum", diff --git a/Cargo.toml b/Cargo.toml index ace89a9e..0cf3ac12 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.14.0" +version = "0.15.0" license = "GPL-2.0-only" [workspace.dependencies] diff --git a/SPEC.md b/SPEC.md index b487be11..0540bc23 100644 --- a/SPEC.md +++ b/SPEC.md @@ -426,6 +426,24 @@ split into cost-bounded batches. A reimplementation: a coinset.org connectivity/`error decoding response body` problem (the transient-retry path is for genuine transport hiccups only). +### 11.4 On-chain NFT media URIs — canonical URN + https backup (NFT1 multi-url) + +`nft mint` writes the art + generated CHIP-0007 metadata into a real capsule and sets the minted +NFT's on-chain NFT1 `data_uris` / `metadata_uris` to TWO entries each, in this fixed order: + +1. the canonical **bare root-pinned URN** `urn:dig:chia::/` (the + data resource key is `art`; the metadata resource key is `metadata.json`) — the PRIMARY entry; +2. an **https gateway url** `/urn:dig:chia::/` — the + FALLBACK (`` defaults to `https://rpc.dig.net`; `--gateway ` overrides the host). + +NFT1 `uris`/`meta_uris` are lists that accept multiple backup urls, so both are emitted: a DIG-aware +wallet resolves the URN natively (dig-node / rpc.dig.net) while a legacy wallet (Sage) uses the https +url. The URN is root-PINNED because NFT media is immutable content — it names the exact capsule +generation the on-chain `data_hash`/`metadata_hash` are pinned to. A conforming reimplementation MUST +emit the canonical bare `urn:dig:chia:…` form (the single resource-identifier grammar, §URN) — +**never** a `dig://`-prefixed URN — and MUST keep the URN first. The list is additive: an old reader +simply reads whichever entry it understands. + ## 12. Release pipeline — nightly cron + manual dispatch How the `dig-store` CLI binary + its `digs` alias are built and released. The shape is copied from diff --git a/crates/digstore-cli/src/commands/nft.rs b/crates/digstore-cli/src/commands/nft.rs index 2d64b19c..8b7e0424 100644 --- a/crates/digstore-cli/src/commands/nft.rs +++ b/crates/digstore-cli/src/commands/nft.rs @@ -3,8 +3,8 @@ //! `mint` is the headline path (#33, "truly permanent NFTs"): it WRITES the art + the generated //! CHIP-0007 metadata JSON into a real DIG capsule, COMPUTES `data_hash`/`metadata_hash` from the //! REAL bytes (via [`digstore_chain::metadata`]), and sets the on-chain `data_uris`/`metadata_uris` -//! to the capsule's `dig://` URN (primary) + an optional https gateway URI (fallback) BEFORE building -//! the mint spend ([`digstore_chain::nft::build_nft_mint`]). The media lives on DIG, not a +//! to the capsule's canonical bare root-pinned URN (primary) + an https gateway URI (fallback) BEFORE +//! building the mint spend ([`digstore_chain::nft::build_nft_mint`]). The media lives on DIG, not a //! centralized host, and the on-chain hashes are pinned to what the URIs actually serve. //! //! `bulk`/`transfer`/`list` surface the matching `digstore-chain` builders. `--dry-run` (on @@ -33,6 +33,10 @@ use digstore_chain::nft::{ const SINGLETON_MOJO: u64 = 1; /// The canonical resource key for the NFT's media inside its capsule. const ART_RESOURCE: &str = "art"; +/// The default https gateway base for the fallback media url when `--gateway` is not given — the +/// public read gateway (§5.3 terminal fallback), so a minted NFT always carries a working https url +/// beside its canonical URN. +const DEFAULT_GATEWAY_BASE: &str = "https://rpc.dig.net"; pub fn run(ctx: &CliContext, ui: &Ui, args: NftArgs) -> Result<(), CliError> { match args.action { @@ -61,7 +65,7 @@ struct CapsuleMedia { /// Write the art + generated CHIP-0007 metadata into a fresh capsule and compute the on-chain media /// fields (#33). The capsule is a real DIG store built in `ctx`'s (ephemeral) dig dir: it stages the /// art under `ART_RESOURCE` and the metadata under `metadata.json`, commits a generation, and returns -/// the resulting `storeId:rootHash` plus the byte-computed hashes + the dig://(+gateway) URIs. +/// the resulting `storeId:rootHash` plus the byte-computed hashes + the URN(+gateway) URIs. fn build_media_capsule( ctx: &CliContext, art_path: &Path, @@ -112,18 +116,12 @@ fn build_media_capsule( let root_hash = outcome.roothash; let store_id = ctx.find_store_id()?; - // The dig:// URN is the PRIMARY URI; the https gateway (if given) is the fallback. - let mut data_uris = vec![assets::dig_uri(store_id, root_hash, ART_RESOURCE)]; - let mut metadata_uris = vec![assets::dig_uri(store_id, root_hash, "metadata.json")]; - if let Some(gw) = gateway { - data_uris.push(assets::gateway_uri(gw, store_id, root_hash, ART_RESOURCE)); - metadata_uris.push(assets::gateway_uri( - gw, - store_id, - root_hash, - "metadata.json", - )); - } + // NFT1 multi-url backup (#663): the canonical bare root-pinned URN is the PRIMARY entry and the + // https gateway url is the fallback, so a DIG-aware wallet resolves the URN while a legacy wallet + // (Sage) uses the https url. Both are always emitted; `--gateway` overrides the fallback host. + let gateway_base = gateway.unwrap_or(DEFAULT_GATEWAY_BASE); + let data_uris = assets::media_uris(store_id, root_hash, ART_RESOURCE, gateway_base); + let metadata_uris = assets::media_uris(store_id, root_hash, "metadata.json", gateway_base); Ok(CapsuleMedia { store_id, @@ -148,7 +146,7 @@ fn mint(ctx: &CliContext, ui: &Ui, args: NftMintArgs) -> Result<(), CliError> { args.gateway.as_deref(), )?; - // 2. Build the on-chain NFT metadata program from the capsule media (dig:// + hashes). + // 2. Build the on-chain NFT metadata program from the capsule media (URN + https + hashes). let item = ManifestItem { name: args.name.clone(), description: args.description.clone(), diff --git a/crates/digstore-cli/src/ops/assets.rs b/crates/digstore-cli/src/ops/assets.rs index 544fce85..49240aba 100644 --- a/crates/digstore-cli/src/ops/assets.rs +++ b/crates/digstore-cli/src/ops/assets.rs @@ -11,7 +11,8 @@ //! * [`scan_and_select_funding`] — scan the HD wallet and pick an XCH coin to fund a mint/create; //! * [`push_signed`] — push a signed [`SpendBundle`] and return its tx id; //! * [`parse_xch_address`] / [`parse_launcher_id`] — input parsing with CLI-friendly errors; -//! * [`dig_uri`] / [`gateway_uri`] — the dig:// + https-fallback URI pair for capsule media (#33). +//! * [`urn`] / [`gateway_uri`] / [`media_uris`] — the canonical URN + https-fallback URI pair for +//! capsule media (#33/#663): [`media_uris`] returns `[bare root-pinned URN, https gateway url]`. //! //! The backend is mock-gated by `DIGSTORE_ANCHOR_MOCK` (the same gate `init`/`commit` use), so the //! offline integration suite drives the asset BUILD paths (`--dry-run`) and the capsule-media path @@ -25,7 +26,7 @@ use digstore_chain::coinset::{ChainReads, CoinInfo, Coinset}; use digstore_chain::keys::IndexedKeys; use digstore_chain::wallet::scan_wallet; use digstore_chain::Result as ChainResult; -use digstore_core::Bytes32 as CoreBytes32; +use digstore_core::{Bytes32 as CoreBytes32, Urn, CHAIN}; use zeroize::Zeroizing; use crate::error::CliError; @@ -247,19 +248,23 @@ pub fn parse_did_arg(s: &str) -> Result { parse_launcher_id(trimmed) } -/// The permanent `dig://` URI for a resource in a capsule — the PRIMARY media URI (#33). +/// The canonical **bare root-pinned URN** for a resource in a capsule — the PRIMARY media URI +/// (#663/#686). /// -/// `dig://:/` is the rootless-friendly capsule form the DIG Browser / -/// resolver understand. This is the URI a verifier should prefer; [`gateway_uri`] is the https -/// fallback. The capsule identity is a `digstore_core::Bytes32` (the store/root types digstore-core -/// emits). -pub fn dig_uri(store_id: CoreBytes32, root_hash: CoreBytes32, resource: &str) -> String { - format!( - "dig://{}:{}/{}", - store_id.to_hex(), - root_hash.to_hex(), - resource - ) +/// Emits `urn:dig:chia::/`, the single normative resource-identifier form +/// (`digstore_core::Urn::canonical`). It is root-PINNED because NFT media is immutable content — +/// the URN names the exact capsule generation the on-chain hashes are pinned to. DIG-aware wallets +/// resolve this URN natively (via dig-node / rpc.dig.net); [`gateway_uri`] is the https fallback for +/// legacy wallets. NEVER a `dig://`-prefixed URN (`dig://` is the §21 remote-transport locator, not a +/// resource scheme — the #686 double-scheme bug). +pub fn urn(store_id: CoreBytes32, root_hash: CoreBytes32, resource: &str) -> String { + Urn { + chain: CHAIN.to_string(), + store_id, + root_hash: Some(root_hash), + resource_key: Some(resource.to_string()), + } + .canonical() } /// The https gateway fallback URI for a capsule resource (#33): `/urn:dig:chia:…/`. @@ -279,6 +284,26 @@ pub fn gateway_uri( ) } +/// The NFT1 multi-url backup pair for a capsule resource (#663): the canonical **bare root-pinned +/// URN first** (the primary, DIG-native entry) followed by the **https gateway url** (the fallback +/// for legacy wallets like Sage). +/// +/// NFT1 `data_uris`/`metadata_uris` are LISTS that accept multiple backup urls; a minted NFT carries +/// BOTH so a DIG-aware wallet resolves the URN while a legacy wallet uses the https url — the same +/// URN-first ordering chip35/hub/create-dig-app emit. The list stays additive (§5.1): an old reader +/// simply reads whichever entry it understands. +pub fn media_uris( + store_id: CoreBytes32, + root_hash: CoreBytes32, + resource: &str, + gateway_base: &str, +) -> Vec { + vec![ + urn(store_id, root_hash, resource), + gateway_uri(gateway_base, store_id, root_hash, resource), + ] +} + #[cfg(test)] mod tests { use super::*; @@ -288,12 +313,32 @@ mod tests { } #[test] - fn dig_uri_is_capsule_form() { - let u = dig_uri(b(0xaa), b(0xbb), "art.png"); - assert!(u.starts_with("dig://")); - assert!(u.contains(&b(0xaa).to_hex())); - assert!(u.contains(&b(0xbb).to_hex())); - assert!(u.ends_with("/art.png")); + fn urn_is_bare_canonical_root_pinned() { + let u = urn(b(0xaa), b(0xbb), "art.png"); + // Canonical bare root-pinned URN (#686) — NEVER a `dig://`-prefixed URN. + assert_eq!( + u, + format!( + "urn:dig:chia:{}:{}/art.png", + b(0xaa).to_hex(), + b(0xbb).to_hex() + ) + ); + assert!(!u.starts_with("dig://"), "must not be dig://-prefixed"); + } + + #[test] + fn media_uris_are_urn_first_then_https() { + let uris = media_uris(b(0x11), b(0x22), "art.png", "https://rpc.dig.net"); + // The NFT1 multi-url backup: canonical URN first (primary), https second (fallback). + assert_eq!(uris.len(), 2, "both the URN and the https url are present"); + assert_eq!(uris[0], urn(b(0x11), b(0x22), "art.png")); + assert!(uris[0].starts_with("urn:dig:chia:"), "URN is first"); + assert_eq!( + uris[1], + gateway_uri("https://rpc.dig.net", b(0x11), b(0x22), "art.png") + ); + assert!(uris[1].starts_with("https://"), "https gateway is second"); } #[test] diff --git a/crates/digstore-cli/tests/cli_assets.rs b/crates/digstore-cli/tests/cli_assets.rs index b10b4256..f85ab7e4 100644 --- a/crates/digstore-cli/tests/cli_assets.rs +++ b/crates/digstore-cli/tests/cli_assets.rs @@ -36,7 +36,8 @@ fn did_create_dry_run_json() { /// spending and proves the #33 capsule-media contract: /// * the art is written into a capsule (storeId:rootHash present), /// * `data_hash` == sha256(art bytes) and `metadata_hash` == sha256(canonical CHIP-0007 JSON), -/// * the primary `data_uris[0]` / `metadata_uris[0]` are the capsule's `dig://` URN, +/// * the primary `data_uris[0]` / `metadata_uris[0]` are the capsule's canonical bare root-pinned +/// URN and the fallback `[1]` is the https gateway url (#663 NFT1 multi-url backup), /// * the embedded metadata JSON is canonical CHIP-0007 (`"format":"CHIP-0007"`). #[test] fn nft_mint_capsule_media_dry_run_json() { @@ -100,19 +101,70 @@ fn nft_mint_capsule_media_dry_run_json() { "on-chain metadata_hash must be sha256 of the canonical metadata JSON" ); - // The PRIMARY data/metadata URIs are the capsule's dig:// URN; the https gateway is the fallback. - let data_uris = cap["data_uris"].as_array().unwrap(); - assert!(data_uris[0] + // #663 NFT1 multi-url backup: the PRIMARY entry is the canonical BARE root-pinned URN + // `urn:dig:chia::/` (never a `dig://`-prefixed URN — the #686 bug), and the + // fallback is the https gateway url. Both are always present, URN first. + for uris_key in ["data_uris", "metadata_uris"] { + let uris = cap[uris_key].as_array().unwrap(); + assert_eq!(uris.len(), 2, "{uris_key} carries the URN + the https url"); + let primary = uris[0].as_str().unwrap(); + assert_eq!( + primary, + format!( + "urn:dig:chia:{store_id}:{root_hash}/{}", + if uris_key == "data_uris" { + "art" + } else { + "metadata.json" + } + ), + "{uris_key}[0] is the canonical bare root-pinned URN" + ); + assert!( + !primary.starts_with("dig://"), + "URN must not be dig://-prefixed (#686)" + ); + assert!( + uris[1] + .as_str() + .unwrap() + .starts_with("https://rpc.dig.net/urn:dig:chia:"), + "{uris_key}[1] is the https gateway fallback" + ); + } +} + +/// #663: WITHOUT `--gateway`, the mint still emits BOTH uris — the canonical URN first and the +/// DEFAULT https gateway (`https://rpc.dig.net`) as the fallback — so a minted NFT is never +/// URN-only (a legacy wallet always has a working https url). +#[test] +fn nft_mint_defaults_gateway_when_omitted() { + let dir = tmp_dig(); + let art = dir.path().join("art.png"); + std::fs::write(&art, b"fake-png").unwrap(); + + let out = dig(&dir) + .args([ + "--json", + "nft", + "mint", + "--art", + art.to_str().unwrap(), + "--name", + "X", + "--dry-run", + ]) + .output() + .unwrap(); + assert!(out.status.success()); + let v: serde_json::Value = serde_json::from_slice(&out.stdout).unwrap(); + let uris = v["capsule"]["data_uris"].as_array().unwrap(); + assert_eq!(uris.len(), 2, "both uris present even without --gateway"); + assert!(uris[0].as_str().unwrap().starts_with("urn:dig:chia:")); + assert!(uris[1] .as_str() .unwrap() - .starts_with(&format!("dig://{store_id}:{root_hash}/"))); - assert!( - data_uris[1] - .as_str() - .unwrap() - .starts_with("https://rpc.dig.net/urn:dig:chia:"), - "second data uri is the https gateway fallback" - ); + .starts_with("https://rpc.dig.net/urn:dig:chia:")); } /// An empty `--art` file is rejected with a clear invalid-argument error (exit 2).