Skip to content

Commit 6870f29

Browse files
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.
1 parent 9e56748 commit 6870f29

6 files changed

Lines changed: 298 additions & 15 deletions

File tree

Cargo.lock

Lines changed: 5 additions & 5 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ exclude = ["crates/digstore-prover/guest", "crates/dig-client-wasm"]
55

66
[workspace.package]
77
edition = "2021"
8-
version = "0.9.0"
8+
version = "0.9.1"
99
license = "GPL-2.0-only"
1010

1111
[workspace.dependencies]

SPEC.md

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -241,3 +241,60 @@ The per-capsule price is **dynamic and USD-pegged**, NOT a fixed token amount:
241241
returns a usable `mint_dig`; digstore surfaces a note when `source` is `"fallback"`/`"… (stale)"`.)
242242
- The amount displayed to the user (and in `--dry-run`'s `cost_dig`) is byte-for-byte the amount
243243
built into the on-chain DIG-CAT payment (`digstore_chain::cat::build_dig_store_payment`).
244+
245+
## 11. CHIP-0007 NFT & collection metadata (nft/collection commands)
246+
247+
Scope note: like §9–10, this is a CLI/off-chain-JSON contract (`nft mint`/`nft bulk`/`collection
248+
create`/`collection mint`), not a `.dig` byte-format contract; it is normative for how a
249+
`digstore` reimplementation reads/writes CHIP-0007 documents so third-party tooling (and the
250+
`chip35_dl_coin` wasm) stays byte-compatible (see `SYSTEM.md` → CHIP-0007 metadata contract).
251+
252+
CHIP-0007 defines **two distinct attribute shapes** that MUST NOT be confused (issue #187):
253+
254+
- **NFT item `attributes`** (an individual NFT's traits, `Chip0007Metadata.attributes` /
255+
`ManifestItem.attributes`) — each entry is `{"trait_type": "<category>", "value": "<value>"}`.
256+
The field is `trait_type`.
257+
- **Collection `attributes`** (the collection-level block — icon/banner/website/twitter/etc,
258+
`Collection.attributes` and the `collection` block embedded in each item's CHIP-0007 JSON,
259+
`CollectionRef.attributes`) — each entry is `{"type": "<category>", "value": "<value>"}`. The
260+
field is `type`, **NOT** `trait_type`.
261+
262+
A `digstore` implementation:
263+
264+
- MUST serialize collection-level attributes with the field name `type` (never `trait_type`).
265+
- MUST serialize NFT-item attributes with the field name `trait_type` (never `type`).
266+
- MUST, on READ, additionally accept `trait_type` as an alias for a collection attribute's `type`
267+
field (back-compat, §5.2/format-compat discipline: an already-emitted DIG collection.json using
268+
the old, non-conformant `trait_type` spelling still parses). This is a READ-only accommodation —
269+
it MUST NOT change what is WRITTEN.
270+
- MUST NOT accept `type` in place of `trait_type` for an NFT item's attributes — the two shapes
271+
stay distinct; item attributes are conformant CHIP-0007 as originally implemented and are not
272+
part of this alias.
273+
274+
Example collection.json fragment (conformant):
275+
276+
```json
277+
{
278+
"id": "dig-punks",
279+
"name": "DIG Punks",
280+
"attributes": [{ "type": "icon", "value": "https://dig.net/icon.png" }],
281+
"royalty_puzzle_hash": "",
282+
"royalty_basis_points": 300
283+
}
284+
```
285+
286+
Example per-item CHIP-0007 JSON fragment (conformant — note `trait_type` for the item's own
287+
attributes vs. `type` inside the embedded `collection` block):
288+
289+
```json
290+
{
291+
"format": "CHIP-0007",
292+
"name": "DIG Punk #1",
293+
"collection": {
294+
"id": "dig-punks",
295+
"name": "DIG Punks",
296+
"attributes": [{ "type": "icon", "value": "https://dig.net/icon.png" }]
297+
},
298+
"attributes": [{ "trait_type": "Background", "value": "Blue" }]
299+
}
300+
```

crates/digstore-chain/src/collection.rs

Lines changed: 58 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@ use serde::{Deserialize, Serialize};
3333

3434
use crate::error::{ChainError, Result};
3535
use crate::keys::IndexedKeys;
36-
use crate::metadata::{Attribute, Chip0007Metadata, CollectionRef};
36+
use crate::metadata::{Attribute, Chip0007Metadata, CollectionAttribute, CollectionRef};
3737

3838
/// A CHIP-0007 collection definition: the shared identity + economics across every item.
3939
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
@@ -42,9 +42,10 @@ pub struct Collection {
4242
pub id: String,
4343
/// Human-readable collection name.
4444
pub name: String,
45-
/// Collection-level attributes (icon/banner/website/twitter/etc) as CHIP-0007 name/value pairs.
45+
/// Collection-level attributes (icon/banner/website/twitter/etc) as CHIP-0007 `type`/`value`
46+
/// pairs ([`CollectionAttribute`] — NOT the NFT-item [`Attribute`]; see #187).
4647
#[serde(default)]
47-
pub attributes: Vec<Attribute>,
48+
pub attributes: Vec<CollectionAttribute>,
4849
/// Shared royalty recipient puzzle hash for every item.
4950
pub royalty_puzzle_hash: Bytes32,
5051
/// Shared royalty in basis points for every item (e.g. 300 = 3%).
@@ -347,8 +348,8 @@ mod tests {
347348
Collection {
348349
id: "dig-punks".into(),
349350
name: "DIG Punks".into(),
350-
attributes: vec![Attribute {
351-
trait_type: "Website".into(),
351+
attributes: vec![CollectionAttribute {
352+
kind: "website".into(),
352353
value: "https://dig.net".into(),
353354
}],
354355
royalty_puzzle_hash: Bytes32::from([0x22; 32]),
@@ -446,13 +447,64 @@ mod tests {
446447

447448
/// The first item's generated CHIP-0007 JSON must be EXACTLY this byte string — the cross-module
448449
/// parity guard for the collection path (it must match `chip35_dl_coin`'s output byte-for-byte).
450+
/// #187: the embedded collection-level attribute renders with `"type"`, NOT `"trait_type"`
451+
/// (CHIP-0007); the item-level attribute stays `"trait_type"`.
449452
#[test]
450453
fn generated_item_json_is_pinned() {
451454
let col = collection();
452455
let mds = generate_item_metadata(&col, &items());
453456
assert_eq!(
454457
mds[0].to_canonical_json().unwrap(),
455-
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"}"#
458+
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"}"#
459+
);
460+
}
461+
462+
// ---------- #187: collection.json parses CHIP-0007 `type` attributes (dkackman's bug) ----------
463+
464+
/// dkackman's exact bug reproduced at the `Collection` deserialization level: a CHIP-0007-
465+
/// conformant collection.json using `"type"` for a collection attribute must parse. Before the
466+
/// #187 fix this failed with "missing field `trait_type`" because `Collection::attributes` was
467+
/// typed `Vec<Attribute>` (the NFT-item shape).
468+
#[test]
469+
fn collection_json_with_chip0007_type_attribute_parses() {
470+
let raw = r#"{
471+
"id": "dig-punks",
472+
"name": "DIG Punks",
473+
"attributes": [{"type": "icon", "value": "https://dig.net/icon.png"}],
474+
"royalty_puzzle_hash": "2222222222222222222222222222222222222222222222222222222222222222",
475+
"royalty_basis_points": 300
476+
}"#;
477+
let col: Collection = serde_json::from_str(raw)
478+
.expect("a CHIP-0007-conformant collection.json (attribute `type`) must parse");
479+
assert_eq!(col.attributes[0].kind, "icon");
480+
assert_eq!(col.attributes[0].value, "https://dig.net/icon.png");
481+
}
482+
483+
/// Back-compat (§5.1): a collection.json already emitted with the OLD, non-conformant
484+
/// `trait_type` field on its collection attributes still parses (the alias).
485+
#[test]
486+
fn collection_json_with_legacy_trait_type_attribute_still_parses() {
487+
let raw = r#"{
488+
"id": "dig-punks",
489+
"name": "DIG Punks",
490+
"attributes": [{"trait_type": "icon", "value": "https://dig.net/icon.png"}],
491+
"royalty_puzzle_hash": "2222222222222222222222222222222222222222222222222222222222222222",
492+
"royalty_basis_points": 300
493+
}"#;
494+
let col: Collection = serde_json::from_str(raw)
495+
.expect("the legacy trait_type collection attribute spelling must still parse");
496+
assert_eq!(col.attributes[0].kind, "icon");
497+
}
498+
499+
/// A parsed manifest item's own `attributes` are UNCHANGED by #187 — they still use
500+
/// `trait_type`, and a collection-style `type` field is rejected (the two shapes stay distinct).
501+
#[test]
502+
fn manifest_item_attribute_still_requires_trait_type() {
503+
let raw = r#"{"name":"A","attributes":[{"type":"Foo","value":"Bar"}],"media":{}}"#;
504+
let err = serde_json::from_str::<ManifestItem>(raw).unwrap_err();
505+
assert!(
506+
err.to_string().contains("trait_type"),
507+
"manifest item attributes must still demand trait_type, got: {err}"
456508
);
457509
}
458510

crates/digstore-chain/src/metadata.rs

Lines changed: 82 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -56,7 +56,13 @@ pub enum MetadataError {
5656
Json(String),
5757
}
5858

59-
/// A single CHIP-0007 attribute (trait) on an NFT.
59+
/// A single CHIP-0007 attribute (trait) on an NFT **item**.
60+
///
61+
/// Distinct from [`CollectionAttribute`] (issue #187): per CHIP-0007, an NFT item's `attributes`
62+
/// use the field name `trait_type`; a *collection's* `attributes` use `type`. Reusing one struct
63+
/// for both was the #187 bug (a conformant collection.json's `type` field was rejected because
64+
/// this struct demands `trait_type`). Keep item attributes on `trait_type` — do not merge the two
65+
/// shapes back together.
6066
#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq, Eq)]
6167
pub struct Attribute {
6268
/// The trait category (e.g. `"Background"`).
@@ -66,6 +72,24 @@ pub struct Attribute {
6672
pub value: String,
6773
}
6874

75+
/// A single CHIP-0007 **collection-level** attribute (icon/banner/website/twitter/etc).
76+
///
77+
/// Per CHIP-0007, collection-level attributes use `type` as the field name — DISTINCT from an NFT
78+
/// item's `attributes`, which use `trait_type` ([`Attribute`]). Issue #187: a prior version of
79+
/// this codebase wrongly reused [`Attribute`] (with `trait_type`) for collection attributes too,
80+
/// rejecting every conformant collection.json. This type serializes with `type` going forward;
81+
/// on READ it also accepts the legacy `trait_type` spelling (`#[serde(alias)]`, §5.1 back-compat)
82+
/// so already-emitted DIG collection.json documents keep parsing.
83+
#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq, Eq)]
84+
pub struct CollectionAttribute {
85+
/// The attribute category (e.g. `"icon"`, `"banner"`, `"website"`). Serializes as CHIP-0007's
86+
/// `type`; also accepts the older, non-conformant `trait_type` spelling on read.
87+
#[serde(rename = "type", alias = "trait_type")]
88+
pub kind: String,
89+
/// The attribute value (e.g. a URI).
90+
pub value: String,
91+
}
92+
6993
/// The collection block embedded in a CHIP-0007 item, linking the item to its [`Collection`].
7094
///
7195
/// [`Collection`]: crate::collection::Collection
@@ -75,9 +99,10 @@ pub struct CollectionRef {
7599
pub id: String,
76100
/// The human-readable collection name.
77101
pub name: String,
78-
/// Collection-level attributes (icon/banner/website/etc), as CHIP-0007 name/value pairs.
102+
/// Collection-level attributes (icon/banner/website/etc), as CHIP-0007 `type`/`value` pairs
103+
/// ([`CollectionAttribute`] — NOT [`Attribute`]; see #187).
79104
#[serde(default, skip_serializing_if = "Vec::is_empty")]
80-
pub attributes: Vec<Attribute>,
105+
pub attributes: Vec<CollectionAttribute>,
81106
}
82107

83108
/// A CHIP-0007 metadata document (the off-chain JSON an NFT's `metadata_uris` point at).
@@ -326,4 +351,58 @@ mod tests {
326351
MetadataError::HashMismatch { which: "data", .. }
327352
));
328353
}
354+
355+
// ---------- #187: collection attributes use `type`, item attributes use `trait_type` ----------
356+
357+
/// A CHIP-0007-conformant collection attribute (`"type"`, NOT `"trait_type"`) parses. This is
358+
/// dkackman's exact failure reproduced at the struct level: before the #187 fix, `CollectionRef`
359+
/// reused `Attribute` (which demands `trait_type`) and rejected this with "missing field
360+
/// `trait_type`".
361+
#[test]
362+
fn collection_attribute_parses_chip0007_type_field() {
363+
let attr: CollectionAttribute =
364+
serde_json::from_str(r#"{"type":"icon","value":"https://dig.net/icon.png"}"#)
365+
.expect("a conformant CHIP-0007 collection attribute must parse");
366+
assert_eq!(attr.kind, "icon");
367+
assert_eq!(attr.value, "https://dig.net/icon.png");
368+
}
369+
370+
/// Back-compat (§5.1): a collection attribute written with the OLD, non-conformant
371+
/// `trait_type` field (already-emitted DIG collection.json) still parses via the alias.
372+
#[test]
373+
fn collection_attribute_accepts_legacy_trait_type_alias() {
374+
let attr: CollectionAttribute =
375+
serde_json::from_str(r#"{"trait_type":"icon","value":"https://dig.net/icon.png"}"#)
376+
.expect("the legacy trait_type spelling must still parse");
377+
assert_eq!(attr.kind, "icon");
378+
}
379+
380+
/// Going forward, a collection attribute always WRITES `type` (never `trait_type`), so newly
381+
/// emitted collection.json documents are CHIP-0007 conformant.
382+
#[test]
383+
fn collection_attribute_serializes_as_type_not_trait_type() {
384+
let attr = CollectionAttribute {
385+
kind: "banner".into(),
386+
value: "https://dig.net/banner.png".into(),
387+
};
388+
let json = serde_json::to_string(&attr).unwrap();
389+
assert_eq!(
390+
json,
391+
r#"{"type":"banner","value":"https://dig.net/banner.png"}"#
392+
);
393+
}
394+
395+
/// NFT **item** attributes are unaffected by #187: they still require `trait_type` and reject a
396+
/// collection-style `type` field (the two shapes stay distinct).
397+
#[test]
398+
fn item_attribute_still_requires_trait_type_not_type() {
399+
let err = serde_json::from_str::<Attribute>(r#"{"type":"icon","value":"x"}"#).unwrap_err();
400+
assert!(
401+
err.to_string().contains("trait_type"),
402+
"item Attribute must still demand trait_type, got: {err}"
403+
);
404+
let ok: Attribute =
405+
serde_json::from_str(r#"{"trait_type":"Background","value":"Blue"}"#).unwrap();
406+
assert_eq!(ok.trait_type, "Background");
407+
}
329408
}

0 commit comments

Comments
 (0)