From 6870f29be21fa5e9e1f1b8a012bdbf7e9ca76710 Mon Sep 17 00:00:00 2001 From: Michael Taylor Date: Tue, 7 Jul 2026 08:26:37 -0700 Subject: [PATCH 1/2] fix(nft): collection attributes use CHIP-0007 `type`, not `trait_type` `Collection`/`CollectionRef.attributes` wrongly reused the NFT-item `Attribute` struct (field `trait_type`), so a CHIP-0007-conformant collection.json using the correct `type` field was rejected with "missing field `trait_type`" (dkackman, #187). Add a distinct `CollectionAttribute` (serde field `type`, with a `trait_type` read-alias for already-emitted collection.json). NFT-item attributes are untouched. Fixes the golden test vectors, which had baked in the wrong format, and documents the two shapes in SPEC.md. --- Cargo.lock | 10 +-- Cargo.toml | 2 +- SPEC.md | 57 +++++++++++++++ crates/digstore-chain/src/collection.rs | 64 +++++++++++++++-- crates/digstore-chain/src/metadata.rs | 85 +++++++++++++++++++++- crates/digstore-cli/tests/cli_assets.rs | 95 +++++++++++++++++++++++++ 6 files changed, 298 insertions(+), 15 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index c304ccf..88673c6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2197,7 +2197,7 @@ dependencies = [ [[package]] name = "digstore-chain" -version = "0.9.0" +version = "0.9.1" dependencies = [ "aes-gcm", "anyhow", @@ -2232,7 +2232,7 @@ dependencies = [ [[package]] name = "digstore-chunker" -version = "0.9.0" +version = "0.9.1" dependencies = [ "digstore-core", "hex", @@ -2242,7 +2242,7 @@ dependencies = [ [[package]] name = "digstore-cli" -version = "0.9.0" +version = "0.9.1" dependencies = [ "anstream 0.6.21", "anstyle", @@ -2308,7 +2308,7 @@ dependencies = [ [[package]] name = "digstore-core" -version = "0.9.0" +version = "0.9.1" dependencies = [ "aes-gcm-siv", "hex", @@ -2405,7 +2405,7 @@ dependencies = [ [[package]] name = "digstore-remote" -version = "0.9.0" +version = "0.9.1" dependencies = [ "async-trait", "axum", diff --git a/Cargo.toml b/Cargo.toml index ad4f52e..56ac4d9 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.9.0" +version = "0.9.1" license = "GPL-2.0-only" [workspace.dependencies] diff --git a/SPEC.md b/SPEC.md index 9989c68..b68a2a6 100644 --- a/SPEC.md +++ b/SPEC.md @@ -241,3 +241,60 @@ The per-capsule price is **dynamic and USD-pegged**, NOT a fixed token amount: returns a usable `mint_dig`; digstore surfaces a note when `source` is `"fallback"`/`"… (stale)"`.) - The amount displayed to the user (and in `--dry-run`'s `cost_dig`) is byte-for-byte the amount built into the on-chain DIG-CAT payment (`digstore_chain::cat::build_dig_store_payment`). + +## 11. CHIP-0007 NFT & collection metadata (nft/collection commands) + +Scope note: like §9–10, this is a CLI/off-chain-JSON contract (`nft mint`/`nft bulk`/`collection +create`/`collection mint`), not a `.dig` byte-format contract; it is normative for how a +`digstore` reimplementation reads/writes CHIP-0007 documents so third-party tooling (and the +`chip35_dl_coin` wasm) stays byte-compatible (see `SYSTEM.md` → CHIP-0007 metadata contract). + +CHIP-0007 defines **two distinct attribute shapes** that MUST NOT be confused (issue #187): + +- **NFT item `attributes`** (an individual NFT's traits, `Chip0007Metadata.attributes` / + `ManifestItem.attributes`) — each entry is `{"trait_type": "", "value": ""}`. + The field is `trait_type`. +- **Collection `attributes`** (the collection-level block — icon/banner/website/twitter/etc, + `Collection.attributes` and the `collection` block embedded in each item's CHIP-0007 JSON, + `CollectionRef.attributes`) — each entry is `{"type": "", "value": ""}`. The + field is `type`, **NOT** `trait_type`. + +A `digstore` implementation: + +- MUST serialize collection-level attributes with the field name `type` (never `trait_type`). +- MUST serialize NFT-item attributes with the field name `trait_type` (never `type`). +- MUST, on READ, additionally accept `trait_type` as an alias for a collection attribute's `type` + field (back-compat, §5.2/format-compat discipline: an already-emitted DIG collection.json using + the old, non-conformant `trait_type` spelling still parses). This is a READ-only accommodation — + it MUST NOT change what is WRITTEN. +- MUST NOT accept `type` in place of `trait_type` for an NFT item's attributes — the two shapes + stay distinct; item attributes are conformant CHIP-0007 as originally implemented and are not + part of this alias. + +Example collection.json fragment (conformant): + +```json +{ + "id": "dig-punks", + "name": "DIG Punks", + "attributes": [{ "type": "icon", "value": "https://dig.net/icon.png" }], + "royalty_puzzle_hash": "…", + "royalty_basis_points": 300 +} +``` + +Example per-item CHIP-0007 JSON fragment (conformant — note `trait_type` for the item's own +attributes vs. `type` inside the embedded `collection` block): + +```json +{ + "format": "CHIP-0007", + "name": "DIG Punk #1", + "collection": { + "id": "dig-punks", + "name": "DIG Punks", + "attributes": [{ "type": "icon", "value": "https://dig.net/icon.png" }] + }, + "attributes": [{ "trait_type": "Background", "value": "Blue" }] +} +``` diff --git a/crates/digstore-chain/src/collection.rs b/crates/digstore-chain/src/collection.rs index 69ba7eb..8ee9142 100644 --- a/crates/digstore-chain/src/collection.rs +++ b/crates/digstore-chain/src/collection.rs @@ -33,7 +33,7 @@ use serde::{Deserialize, Serialize}; use crate::error::{ChainError, Result}; use crate::keys::IndexedKeys; -use crate::metadata::{Attribute, Chip0007Metadata, CollectionRef}; +use crate::metadata::{Attribute, Chip0007Metadata, CollectionAttribute, CollectionRef}; /// A CHIP-0007 collection definition: the shared identity + economics across every item. #[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] @@ -42,9 +42,10 @@ pub struct Collection { pub id: String, /// Human-readable collection name. pub name: String, - /// Collection-level attributes (icon/banner/website/twitter/etc) as CHIP-0007 name/value pairs. + /// Collection-level attributes (icon/banner/website/twitter/etc) as CHIP-0007 `type`/`value` + /// pairs ([`CollectionAttribute`] — NOT the NFT-item [`Attribute`]; see #187). #[serde(default)] - pub attributes: Vec, + pub attributes: Vec, /// Shared royalty recipient puzzle hash for every item. pub royalty_puzzle_hash: Bytes32, /// Shared royalty in basis points for every item (e.g. 300 = 3%). @@ -347,8 +348,8 @@ mod tests { Collection { id: "dig-punks".into(), name: "DIG Punks".into(), - attributes: vec![Attribute { - trait_type: "Website".into(), + attributes: vec![CollectionAttribute { + kind: "website".into(), value: "https://dig.net".into(), }], royalty_puzzle_hash: Bytes32::from([0x22; 32]), @@ -446,13 +447,64 @@ mod tests { /// The first item's generated CHIP-0007 JSON must be EXACTLY this byte string — the cross-module /// parity guard for the collection path (it must match `chip35_dl_coin`'s output byte-for-byte). + /// #187: the embedded collection-level attribute renders with `"type"`, NOT `"trait_type"` + /// (CHIP-0007); the item-level attribute stays `"trait_type"`. #[test] fn generated_item_json_is_pinned() { let col = collection(); let mds = generate_item_metadata(&col, &items()); assert_eq!( mds[0].to_canonical_json().unwrap(), - r#"{"format":"CHIP-0007","name":"DIG Punk #1","description":"first","collection":{"id":"dig-punks","name":"DIG Punks","attributes":[{"trait_type":"Website","value":"https://dig.net"}]},"attributes":[{"trait_type":"Background","value":"Blue"}],"series_number":1,"series_total":2,"minting_tool":"DIG"}"# + r#"{"format":"CHIP-0007","name":"DIG Punk #1","description":"first","collection":{"id":"dig-punks","name":"DIG Punks","attributes":[{"type":"website","value":"https://dig.net"}]},"attributes":[{"trait_type":"Background","value":"Blue"}],"series_number":1,"series_total":2,"minting_tool":"DIG"}"# + ); + } + + // ---------- #187: collection.json parses CHIP-0007 `type` attributes (dkackman's bug) ---------- + + /// dkackman's exact bug reproduced at the `Collection` deserialization level: a CHIP-0007- + /// conformant collection.json using `"type"` for a collection attribute must parse. Before the + /// #187 fix this failed with "missing field `trait_type`" because `Collection::attributes` was + /// typed `Vec` (the NFT-item shape). + #[test] + fn collection_json_with_chip0007_type_attribute_parses() { + let raw = r#"{ + "id": "dig-punks", + "name": "DIG Punks", + "attributes": [{"type": "icon", "value": "https://dig.net/icon.png"}], + "royalty_puzzle_hash": "2222222222222222222222222222222222222222222222222222222222222222", + "royalty_basis_points": 300 + }"#; + let col: Collection = serde_json::from_str(raw) + .expect("a CHIP-0007-conformant collection.json (attribute `type`) must parse"); + assert_eq!(col.attributes[0].kind, "icon"); + assert_eq!(col.attributes[0].value, "https://dig.net/icon.png"); + } + + /// Back-compat (§5.1): a collection.json already emitted with the OLD, non-conformant + /// `trait_type` field on its collection attributes still parses (the alias). + #[test] + fn collection_json_with_legacy_trait_type_attribute_still_parses() { + let raw = r#"{ + "id": "dig-punks", + "name": "DIG Punks", + "attributes": [{"trait_type": "icon", "value": "https://dig.net/icon.png"}], + "royalty_puzzle_hash": "2222222222222222222222222222222222222222222222222222222222222222", + "royalty_basis_points": 300 + }"#; + let col: Collection = serde_json::from_str(raw) + .expect("the legacy trait_type collection attribute spelling must still parse"); + assert_eq!(col.attributes[0].kind, "icon"); + } + + /// A parsed manifest item's own `attributes` are UNCHANGED by #187 — they still use + /// `trait_type`, and a collection-style `type` field is rejected (the two shapes stay distinct). + #[test] + fn manifest_item_attribute_still_requires_trait_type() { + let raw = r#"{"name":"A","attributes":[{"type":"Foo","value":"Bar"}],"media":{}}"#; + let err = serde_json::from_str::(raw).unwrap_err(); + assert!( + err.to_string().contains("trait_type"), + "manifest item attributes must still demand trait_type, got: {err}" ); } diff --git a/crates/digstore-chain/src/metadata.rs b/crates/digstore-chain/src/metadata.rs index d871197..333d9c1 100644 --- a/crates/digstore-chain/src/metadata.rs +++ b/crates/digstore-chain/src/metadata.rs @@ -56,7 +56,13 @@ pub enum MetadataError { Json(String), } -/// A single CHIP-0007 attribute (trait) on an NFT. +/// A single CHIP-0007 attribute (trait) on an NFT **item**. +/// +/// Distinct from [`CollectionAttribute`] (issue #187): per CHIP-0007, an NFT item's `attributes` +/// use the field name `trait_type`; a *collection's* `attributes` use `type`. Reusing one struct +/// for both was the #187 bug (a conformant collection.json's `type` field was rejected because +/// this struct demands `trait_type`). Keep item attributes on `trait_type` — do not merge the two +/// shapes back together. #[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq, Eq)] pub struct Attribute { /// The trait category (e.g. `"Background"`). @@ -66,6 +72,24 @@ pub struct Attribute { pub value: String, } +/// A single CHIP-0007 **collection-level** attribute (icon/banner/website/twitter/etc). +/// +/// Per CHIP-0007, collection-level attributes use `type` as the field name — DISTINCT from an NFT +/// item's `attributes`, which use `trait_type` ([`Attribute`]). Issue #187: a prior version of +/// this codebase wrongly reused [`Attribute`] (with `trait_type`) for collection attributes too, +/// rejecting every conformant collection.json. This type serializes with `type` going forward; +/// on READ it also accepts the legacy `trait_type` spelling (`#[serde(alias)]`, §5.1 back-compat) +/// so already-emitted DIG collection.json documents keep parsing. +#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq, Eq)] +pub struct CollectionAttribute { + /// The attribute category (e.g. `"icon"`, `"banner"`, `"website"`). Serializes as CHIP-0007's + /// `type`; also accepts the older, non-conformant `trait_type` spelling on read. + #[serde(rename = "type", alias = "trait_type")] + pub kind: String, + /// The attribute value (e.g. a URI). + pub value: String, +} + /// The collection block embedded in a CHIP-0007 item, linking the item to its [`Collection`]. /// /// [`Collection`]: crate::collection::Collection @@ -75,9 +99,10 @@ pub struct CollectionRef { pub id: String, /// The human-readable collection name. pub name: String, - /// Collection-level attributes (icon/banner/website/etc), as CHIP-0007 name/value pairs. + /// Collection-level attributes (icon/banner/website/etc), as CHIP-0007 `type`/`value` pairs + /// ([`CollectionAttribute`] — NOT [`Attribute`]; see #187). #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub attributes: Vec, + pub attributes: Vec, } /// A CHIP-0007 metadata document (the off-chain JSON an NFT's `metadata_uris` point at). @@ -326,4 +351,58 @@ mod tests { MetadataError::HashMismatch { which: "data", .. } )); } + + // ---------- #187: collection attributes use `type`, item attributes use `trait_type` ---------- + + /// A CHIP-0007-conformant collection attribute (`"type"`, NOT `"trait_type"`) parses. This is + /// dkackman's exact failure reproduced at the struct level: before the #187 fix, `CollectionRef` + /// reused `Attribute` (which demands `trait_type`) and rejected this with "missing field + /// `trait_type`". + #[test] + fn collection_attribute_parses_chip0007_type_field() { + let attr: CollectionAttribute = + serde_json::from_str(r#"{"type":"icon","value":"https://dig.net/icon.png"}"#) + .expect("a conformant CHIP-0007 collection attribute must parse"); + assert_eq!(attr.kind, "icon"); + assert_eq!(attr.value, "https://dig.net/icon.png"); + } + + /// Back-compat (§5.1): a collection attribute written with the OLD, non-conformant + /// `trait_type` field (already-emitted DIG collection.json) still parses via the alias. + #[test] + fn collection_attribute_accepts_legacy_trait_type_alias() { + let attr: CollectionAttribute = + serde_json::from_str(r#"{"trait_type":"icon","value":"https://dig.net/icon.png"}"#) + .expect("the legacy trait_type spelling must still parse"); + assert_eq!(attr.kind, "icon"); + } + + /// Going forward, a collection attribute always WRITES `type` (never `trait_type`), so newly + /// emitted collection.json documents are CHIP-0007 conformant. + #[test] + fn collection_attribute_serializes_as_type_not_trait_type() { + let attr = CollectionAttribute { + kind: "banner".into(), + value: "https://dig.net/banner.png".into(), + }; + let json = serde_json::to_string(&attr).unwrap(); + assert_eq!( + json, + r#"{"type":"banner","value":"https://dig.net/banner.png"}"# + ); + } + + /// NFT **item** attributes are unaffected by #187: they still require `trait_type` and reject a + /// collection-style `type` field (the two shapes stay distinct). + #[test] + fn item_attribute_still_requires_trait_type_not_type() { + let err = serde_json::from_str::(r#"{"type":"icon","value":"x"}"#).unwrap_err(); + assert!( + err.to_string().contains("trait_type"), + "item Attribute must still demand trait_type, got: {err}" + ); + let ok: Attribute = + serde_json::from_str(r#"{"trait_type":"Background","value":"Blue"}"#).unwrap(); + assert_eq!(ok.trait_type, "Background"); + } } diff --git a/crates/digstore-cli/tests/cli_assets.rs b/crates/digstore-cli/tests/cli_assets.rs index 71a311e..6180bc9 100644 --- a/crates/digstore-cli/tests/cli_assets.rs +++ b/crates/digstore-cli/tests/cli_assets.rs @@ -279,6 +279,101 @@ fn collection_mint_refuses_multi_item() { .stderr(predicate::str::contains("single DID-attributed item")); } +/// #187 (dkackman, live user): a CHIP-0007-conformant `collection.json` — whose collection-level +/// attribute uses `"type"` (NOT `"trait_type"`) — must be ACCEPTED by `collection mint`. Before the +/// fix this failed at parse time with `--collection is not a valid definition: missing field +/// 'trait_type'` (exit 2), because `Collection::attributes` was wrongly typed as the NFT-item +/// `Attribute` (which demands `trait_type`). After the fix, parsing succeeds and the command +/// proceeds to the DID-ownership check, which fails under the offline mock (no real DID exists) — +/// proving the parse itself is no longer the failure. The distinct exit code (4, NOT 2) and error +/// text (no "trait_type"/"not a valid definition") is the regression guard. +#[test] +fn collection_mint_accepts_chip0007_type_attribute() { + let dir = tmp_dig(); + let col = dir.path().join("col.json"); + std::fs::write( + &col, + r#"{"id":"c","name":"C","attributes":[{"type":"icon","value":"https://dig.net/icon.png"}],"royalty_puzzle_hash":"0000000000000000000000000000000000000000000000000000000000000000","royalty_basis_points":0}"#, + ) + .unwrap(); + let items = dir.path().join("items.json"); + std::fs::write( + &items, + r#"[{"name":"A","media":{"data_uris":["dig://s/a"],"data_hash":"1111111111111111111111111111111111111111111111111111111111111111"}}]"#, + ) + .unwrap(); + let out = dig(&dir) + .args([ + "collection", + "mint", + "--collection", + col.to_str().unwrap(), + "--manifest", + items.to_str().unwrap(), + "--did", + &"ab".repeat(32), + "--dry-run", + ]) + .output() + .unwrap(); + let stderr = String::from_utf8_lossy(&out.stderr); + assert!( + !stderr.contains("trait_type") && !stderr.contains("not a valid definition"), + "a CHIP-0007 `type` collection attribute must parse (dkackman's #187 bug); stderr: {stderr}" + ); + assert!(!out.status.success(), "no real DID exists under the mock"); + assert_eq!( + out.status.code(), + Some(4), + "must fail on DID ownership (NotFound=4), not on parsing (InvalidArgument=2); stderr: {stderr}" + ); + assert!( + stderr.contains("does not own DID"), + "expected the parse to succeed and fail past it on DID ownership; stderr: {stderr}" + ); +} + +/// Back-compat (§5.1): a collection.json already emitted with the OLD, non-conformant +/// `trait_type` field on its collection attributes must STILL parse (the `#[serde(alias)]`), so +/// existing DIG collection definitions are not broken by the #187 fix. +#[test] +fn collection_mint_accepts_legacy_trait_type_collection_attribute() { + let dir = tmp_dig(); + let col = dir.path().join("col.json"); + std::fs::write( + &col, + r#"{"id":"c","name":"C","attributes":[{"trait_type":"icon","value":"https://dig.net/icon.png"}],"royalty_puzzle_hash":"0000000000000000000000000000000000000000000000000000000000000000","royalty_basis_points":0}"#, + ) + .unwrap(); + let items = dir.path().join("items.json"); + std::fs::write( + &items, + r#"[{"name":"A","media":{"data_uris":["dig://s/a"],"data_hash":"1111111111111111111111111111111111111111111111111111111111111111"}}]"#, + ) + .unwrap(); + let out = dig(&dir) + .args([ + "collection", + "mint", + "--collection", + col.to_str().unwrap(), + "--manifest", + items.to_str().unwrap(), + "--did", + &"ab".repeat(32), + "--dry-run", + ]) + .output() + .unwrap(); + let stderr = String::from_utf8_lossy(&out.stderr); + assert_eq!( + out.status.code(), + Some(4), + "the legacy trait_type collection attribute must still parse; stderr: {stderr}" + ); + assert!(stderr.contains("does not own DID")); +} + /// #40 drop scaffold: `collection create` with drop flags records the drop model in /// the definition JSON (committed); enforcement is TODO. A plain create has no drop. #[test] From 7315096c0c9b9bc115cb3c1e61961236f7fdecac Mon Sep 17 00:00:00 2001 From: Michael Taylor Date: Tue, 7 Jul 2026 08:35:05 -0700 Subject: [PATCH 2/2] chore(deps): bump crossbeam-epoch to 0.9.20 (RUSTSEC-2026-0204) Unrelated to the collection-attribute fix, but a new advisory landed on main's transitive crossbeam-epoch 0.9.18 (pulled via ignore/rayon/wasmtime) between the last green main run and this PR, failing the required supply-chain-audit gate. Bump to the patched version per the advisory. --- Cargo.lock | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 88673c6..693ff9a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2006,9 +2006,9 @@ dependencies = [ [[package]] name = "crossbeam-epoch" -version = "0.9.18" +version = "0.9.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e" +checksum = "2d6914041f254d6e9176c01941b21115dcfb7089e55135a35411081bd106ef3f" dependencies = [ "crossbeam-utils", ]