Skip to main content

chip35_dl_coin/
metadata.rs

1//! CHIP-0007 NFT metadata builder + validator (roadmap #36).
2//!
3//! CHIP-0007 is the off-chain NFT metadata JSON standard — the document an NFT's `metadata_uris`
4//! point at. The on-chain NFT coin pins three SHA-256 hashes (`data_hash`, `metadata_hash`,
5//! `license_hash`) that MUST equal the hash of the bytes the corresponding URIs actually serve.
6//!
7//! This module is the single, shared, tested implementation of: generating canonical CHIP-0007
8//! JSON, computing those hashes FROM bytes, and validating URI↔hash agreement + schema. It exists
9//! to kill the footgun of every consumer (hub badge minting, MintGarden adapter, CLI) hand-rolling
10//! SHA-256 and trusting raw input. Pure data + hashing — no chain, no keys.
11
12use chia_protocol::Bytes32;
13use serde::{Deserialize, Serialize};
14use thiserror::Error as ThisError;
15
16/// The canonical CHIP-0007 `format` discriminator value.
17pub const CHIP0007_FORMAT: &str = "CHIP-0007";
18
19/// Errors from CHIP-0007 metadata building/validation. Distinct from the spend-builder [`Error`]
20/// so JS gets actionable, metadata-specific messages.
21///
22/// [`Error`]: crate::Error
23#[derive(Debug, ThisError, PartialEq, Eq)]
24pub enum MetadataError {
25    /// `format` is not `"CHIP-0007"`.
26    #[error("invalid format: expected \"{CHIP0007_FORMAT}\", got {0:?}")]
27    BadFormat(String),
28
29    /// A required field (e.g. `name`) is missing or empty.
30    #[error("missing required field: {0}")]
31    MissingField(&'static str),
32
33    /// A computed hash does not equal the on-chain hash for the same asset (URI↔hash disagreement).
34    /// `which` is "data" | "metadata" | "license".
35    #[error("{which} hash mismatch: bytes hash to {computed} but on-chain hash is {expected}")]
36    HashMismatch {
37        which: &'static str,
38        computed: String,
39        expected: String,
40    },
41
42    /// `series_number > series_total`.
43    #[error("series_number ({number}) exceeds series_total ({total})")]
44    BadSeries { number: u64, total: u64 },
45
46    /// JSON (de)serialization failed.
47    #[error("json error: {0}")]
48    Json(String),
49}
50
51/// A single CHIP-0007 attribute (trait) on an NFT **item**.
52///
53/// Distinct from [`CollectionAttribute`] (issue #189, the emit-side twin of digstore's #187): per
54/// CHIP-0007, an NFT item's `attributes` use the field name `trait_type`; a *collection's*
55/// `attributes` use `type`. Reusing one struct for both was the #189 bug (chip35 emitted
56/// non-conformant `trait_type` for collection-level attributes, since it is the canonical wasm
57/// builder hub's NFT collection studio drives). Keep item attributes on `trait_type` — do not merge
58/// the two shapes back together.
59#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq, Eq)]
60pub struct Attribute {
61    /// The trait category (e.g. `"Background"`).
62    pub trait_type: String,
63    /// The trait value (e.g. `"Blue"`). Stored as a string for byte-stable hashing; numeric
64    /// values are the caller's responsibility to stringify consistently.
65    pub value: String,
66}
67
68/// A single CHIP-0007 **collection-level** attribute (icon/banner/website/twitter/etc).
69///
70/// Per CHIP-0007, collection-level attributes use `type` as the field name — DISTINCT from an NFT
71/// item's `attributes`, which use `trait_type` ([`Attribute`]). Issue #189 (the emit-side twin of
72/// digstore's #187): chip35 wrongly reused [`Attribute`] (with `trait_type`) for collection
73/// attributes too, pinning non-conformant collection metadata on-chain for every hub-minted
74/// collection. This type serializes with `type` going forward; on READ it also accepts the legacy
75/// `trait_type` spelling (`#[serde(alias)]`, §5.1 back-compat) so already-emitted collection.json
76/// documents keep parsing.
77#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq, Eq)]
78pub struct CollectionAttribute {
79    /// The attribute category (e.g. `"icon"`, `"banner"`, `"website"`). Serializes as CHIP-0007's
80    /// `type`; also accepts the older, non-conformant `trait_type` spelling on read.
81    #[serde(rename = "type", alias = "trait_type")]
82    pub kind: String,
83    /// The attribute value (e.g. a URI).
84    pub value: String,
85}
86
87/// The collection block embedded in a CHIP-0007 item, linking the item to its [`Collection`].
88///
89/// [`Collection`]: crate::collection::Collection
90#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq, Eq)]
91pub struct CollectionRef {
92    /// The collection id (stable across all items in the collection).
93    pub id: String,
94    /// The human-readable collection name.
95    pub name: String,
96    /// Collection-level attributes (icon/banner/website/etc), as CHIP-0007 `type`/`value` pairs
97    /// ([`CollectionAttribute`] — NOT [`Attribute`]; see #189).
98    #[serde(default, skip_serializing_if = "Vec::is_empty")]
99    pub attributes: Vec<CollectionAttribute>,
100}
101
102/// A CHIP-0007 metadata document (the off-chain JSON an NFT's `metadata_uris` point at).
103///
104/// Serializes to deterministic JSON (fixed field order, empty optionals omitted) so
105/// [`compute_metadata_hash`](Self::compute_metadata_hash) is reproducible byte-for-byte across
106/// callers — a requirement, because that hash is pinned on-chain.
107#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
108pub struct Chip0007Metadata {
109    /// MUST be `"CHIP-0007"`.
110    pub format: String,
111    /// The NFT name. Required.
112    pub name: String,
113    /// Free-text description.
114    #[serde(default, skip_serializing_if = "Option::is_none")]
115    pub description: Option<String>,
116    /// Whether the content is sensitive/NSFW.
117    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
118    pub sensitive_content: bool,
119    /// The collection this item belongs to, if any.
120    #[serde(default, skip_serializing_if = "Option::is_none")]
121    pub collection: Option<CollectionRef>,
122    /// Per-item traits.
123    #[serde(default, skip_serializing_if = "Vec::is_empty")]
124    pub attributes: Vec<Attribute>,
125    /// 1-based position of this item within its series/collection.
126    #[serde(default, skip_serializing_if = "Option::is_none")]
127    pub series_number: Option<u64>,
128    /// Total items in the series/collection.
129    #[serde(default, skip_serializing_if = "Option::is_none")]
130    pub series_total: Option<u64>,
131    /// The tool that minted this (e.g. `"DIG"`).
132    #[serde(default, skip_serializing_if = "Option::is_none")]
133    pub minting_tool: Option<String>,
134}
135
136impl Chip0007Metadata {
137    /// Build a minimal valid CHIP-0007 document with the canonical `format` set.
138    pub fn new(name: impl Into<String>) -> Self {
139        Self {
140            format: CHIP0007_FORMAT.to_string(),
141            name: name.into(),
142            description: None,
143            sensitive_content: false,
144            collection: None,
145            attributes: Vec::new(),
146            series_number: None,
147            series_total: None,
148            minting_tool: None,
149        }
150    }
151
152    /// Serialize to canonical (deterministic) JSON bytes. Field order is fixed by the struct
153    /// definition and empty optionals are omitted, so two callers building the same logical
154    /// document produce byte-identical JSON — and therefore the same [`compute_metadata_hash`].
155    ///
156    /// [`compute_metadata_hash`]: Self::compute_metadata_hash
157    pub fn to_canonical_json(&self) -> Result<String, MetadataError> {
158        serde_json::to_string(self).map_err(|e| MetadataError::Json(e.to_string()))
159    }
160
161    /// Compute `metadata_hash = sha256(canonical_json_bytes)` — the value pinned on-chain in the
162    /// NFT's `metadata_hash`. Never hand-roll this; mismatches silently break verification.
163    pub fn compute_metadata_hash(&self) -> Result<Bytes32, MetadataError> {
164        Ok(sha256(self.to_canonical_json()?.as_bytes()))
165    }
166
167    /// Validate the document's schema (roadmap #36's "validates schema"):
168    /// `format == "CHIP-0007"`, `name` non-empty, and `series_number <= series_total`.
169    ///
170    /// This is the cheap structural check. Use [`validate_uri_hash`] to additionally confirm a
171    /// hash matches real bytes.
172    ///
173    /// [`validate_uri_hash`]: validate_uri_hash
174    pub fn validate_schema(&self) -> Result<(), MetadataError> {
175        if self.format != CHIP0007_FORMAT {
176            return Err(MetadataError::BadFormat(self.format.clone()));
177        }
178        if self.name.trim().is_empty() {
179            return Err(MetadataError::MissingField("name"));
180        }
181        if let (Some(number), Some(total)) = (self.series_number, self.series_total) {
182            if number > total {
183                return Err(MetadataError::BadSeries { number, total });
184            }
185        }
186        Ok(())
187    }
188}
189
190/// SHA-256 of arbitrary bytes → the 32-byte hash pinned on-chain. The one true hash primitive for
191/// `data_hash`, `metadata_hash`, and `license_hash`.
192pub fn sha256(bytes: &[u8]) -> Bytes32 {
193    let mut h = chia_sha2::Sha256::new();
194    h.update(bytes);
195    Bytes32::new(h.finalize())
196}
197
198/// Validate URI↔hash agreement for one asset: the on-chain `expected` hash MUST equal
199/// `sha256(bytes)` of what the URI actually serves. `which` is "data" | "metadata" | "license"
200/// for error reporting. This is the footgun-closing check roadmap #36 calls for — the on-chain
201/// hash must match the served bytes, or every verifying client rejects the NFT.
202pub fn validate_uri_hash(
203    which: &'static str,
204    bytes: &[u8],
205    expected: Bytes32,
206) -> Result<(), MetadataError> {
207    let computed = sha256(bytes);
208    if computed != expected {
209        return Err(MetadataError::HashMismatch {
210            which,
211            computed: format!("{computed}"),
212            expected: format!("{expected}"),
213        });
214    }
215    Ok(())
216}