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}