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
14 changes: 7 additions & 7 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.9.0"
version = "0.9.1"
license = "GPL-2.0-only"

[workspace.dependencies]
Expand Down
57 changes: 57 additions & 0 deletions SPEC.md
Original file line number Diff line number Diff line change
Expand Up @@ -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": "<category>", "value": "<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": "<category>", "value": "<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" }]
}
```
64 changes: 58 additions & 6 deletions crates/digstore-chain/src/collection.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)]
Expand All @@ -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<Attribute>,
pub attributes: Vec<CollectionAttribute>,
/// 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%).
Expand Down Expand Up @@ -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]),
Expand Down Expand Up @@ -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<Attribute>` (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::<ManifestItem>(raw).unwrap_err();
assert!(
err.to_string().contains("trait_type"),
"manifest item attributes must still demand trait_type, got: {err}"
);
}

Expand Down
85 changes: 82 additions & 3 deletions crates/digstore-chain/src/metadata.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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"`).
Expand All @@ -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
Expand All @@ -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<Attribute>,
pub attributes: Vec<CollectionAttribute>,
}

/// A CHIP-0007 metadata document (the off-chain JSON an NFT's `metadata_uris` point at).
Expand Down Expand Up @@ -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::<Attribute>(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");
}
}
Loading
Loading