Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 5 additions & 5 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down
18 changes: 18 additions & 0 deletions SPEC.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:<storeId>:<rootHash>/<resourceKey>` (the
data resource key is `art`; the metadata resource key is `metadata.json`) — the PRIMARY entry;
2. an **https gateway url** `<gateway>/urn:dig:chia:<storeId>:<rootHash>/<resourceKey>` — the
FALLBACK (`<gateway>` defaults to `https://rpc.dig.net`; `--gateway <base>` 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
Expand Down
30 changes: 14 additions & 16 deletions crates/digstore-cli/src/commands/nft.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand All @@ -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(),
Expand Down
85 changes: 65 additions & 20 deletions crates/digstore-cli/src/ops/assets.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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;
Expand Down Expand Up @@ -247,19 +248,23 @@ pub fn parse_did_arg(s: &str) -> Result<Bytes32, CliError> {
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://<storeId>:<rootHash>/<resource>` 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:<storeId>:<root>/<resource>`, 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): `<gateway>/urn:dig:chia:…/<resource>`.
Expand All @@ -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<String> {
vec![
urn(store_id, root_hash, resource),
gateway_uri(gateway_base, store_id, root_hash, resource),
]
}

#[cfg(test)]
mod tests {
use super::*;
Expand All @@ -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]
Expand Down
76 changes: 64 additions & 12 deletions crates/digstore-cli/tests/cli_assets.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down Expand Up @@ -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:<store>:<root>/<key>` (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).
Expand Down
Loading