chip35_dl_coin/collection.rs
1//! Collection primitive + traits-manifest bulk mint (roadmap #34).
2//!
3//! Creators think in *collections*, not individual mints. This models a CHIP-0007 collection
4//! (id/name/attributes/shared royalty + DID + license), generates per-item CHIP-0007 metadata from
5//! a *parsed* traits manifest (JSON in — no file IO; the CLI/toolkit parses CSV/JSON into the
6//! manifest), and bulk-mints all items in one bundle using intermediate launchers.
7
8use chia_bls::PublicKey;
9use chia_protocol::{Bytes32, Coin, CoinSpend};
10use chia_puzzle_types::nft::NftMetadata;
11use chia_puzzle_types::standard::StandardArgs;
12use chia_puzzle_types::Memos;
13use chia_sdk_driver::{
14 Did, IntermediateLauncher, NftMint, SingletonInfo, SpendContext, StandardLayer,
15};
16use chia_sdk_types::{conditions::TransferNft, Conditions};
17use serde::{Deserialize, Serialize};
18
19use crate::error::WalletError;
20use crate::metadata::{Attribute, Chip0007Metadata, CollectionAttribute, CollectionRef};
21use crate::nft::{DidAttribution, NftMediaMetadata};
22
23/// A CHIP-0007 collection definition: the shared identity + economics across every item.
24#[derive(Clone, Debug, Serialize, Deserialize)]
25pub struct Collection {
26 /// Stable collection id (the toolkit derives it from the creator DID + name, or supplies it).
27 pub id: String,
28 /// Human-readable collection name.
29 pub name: String,
30 /// Collection-level attributes (icon/banner/website/twitter/etc) as CHIP-0007 `type`/`value`
31 /// pairs ([`CollectionAttribute`] — NOT the NFT-item [`Attribute`]; see #189).
32 #[serde(default)]
33 pub attributes: Vec<CollectionAttribute>,
34 /// Shared royalty recipient puzzle hash for every item.
35 pub royalty_puzzle_hash: Bytes32,
36 /// Shared royalty in basis points for every item.
37 pub royalty_basis_points: u16,
38}
39
40impl Collection {
41 /// The [`CollectionRef`] block embedded into each item's CHIP-0007 metadata.
42 pub fn as_ref_block(&self) -> CollectionRef {
43 CollectionRef {
44 id: self.id.clone(),
45 name: self.name.clone(),
46 attributes: self.attributes.clone(),
47 }
48 }
49}
50
51/// One item in a parsed traits manifest. The toolkit produces this from a CSV/JSON manifest +
52/// the per-item capsule hashes; this crate consumes the parsed form only (no file IO).
53#[derive(Clone, Debug, Serialize, Deserialize)]
54pub struct ManifestItem {
55 /// The item's name (e.g. `"DIG Punk #12"`).
56 pub name: String,
57 /// Optional per-item description.
58 #[serde(default)]
59 pub description: Option<String>,
60 /// Per-item traits.
61 #[serde(default)]
62 pub attributes: Vec<Attribute>,
63 /// On-chain media metadata + hashes for this item (URN + https fallback URIs).
64 pub media: ManifestMedia,
65}
66
67/// The on-chain media fields for a manifest item (mirrors [`NftMediaMetadata`] in a serde-friendly,
68/// hex-hash shape for the parsed manifest).
69#[derive(Clone, Debug, Default, Serialize, Deserialize)]
70pub struct ManifestMedia {
71 /// Primary media URIs (URN first, https fallback second by convention).
72 #[serde(default)]
73 pub data_uris: Vec<String>,
74 /// `sha256(media_bytes)`.
75 #[serde(default)]
76 pub data_hash: Option<Bytes32>,
77 /// CHIP-0007 metadata JSON URIs.
78 #[serde(default)]
79 pub metadata_uris: Vec<String>,
80 /// `sha256(metadata_json_bytes)`.
81 #[serde(default)]
82 pub metadata_hash: Option<Bytes32>,
83 /// License document URIs.
84 #[serde(default)]
85 pub license_uris: Vec<String>,
86 /// `sha256(license_bytes)`.
87 #[serde(default)]
88 pub license_hash: Option<Bytes32>,
89}
90
91impl ManifestMedia {
92 fn to_media(&self, edition_number: u64, edition_total: u64) -> NftMediaMetadata {
93 NftMediaMetadata {
94 data_uris: self.data_uris.clone(),
95 data_hash: self.data_hash,
96 metadata_uris: self.metadata_uris.clone(),
97 metadata_hash: self.metadata_hash,
98 license_uris: self.license_uris.clone(),
99 license_hash: self.license_hash,
100 edition_number,
101 edition_total,
102 }
103 }
104}
105
106/// Generate the per-item CHIP-0007 metadata documents for a collection from a parsed manifest.
107///
108/// Each item gets the collection block, its own traits, and `series_number`/`series_total` filled in
109/// (1-based). This is the off-chain JSON side; the on-chain hashes come from [`ManifestMedia`]. The
110/// toolkit hashes each generated document and writes it into the item's capsule.
111pub fn generate_item_metadata(
112 collection: &Collection,
113 items: &[ManifestItem],
114) -> Vec<Chip0007Metadata> {
115 let total = items.len() as u64;
116 items
117 .iter()
118 .enumerate()
119 .map(|(i, item)| {
120 let mut md = Chip0007Metadata::new(item.name.clone());
121 md.description = item.description.clone();
122 md.attributes = item.attributes.clone();
123 md.collection = Some(collection.as_ref_block());
124 md.series_number = Some(i as u64 + 1);
125 md.series_total = Some(total);
126 md.minting_tool = Some("DIG".to_string());
127 md
128 })
129 .collect()
130}
131
132/// The result of a bulk mint: the coin spends + the launcher id of every minted NFT (in order).
133#[derive(Clone, Debug)]
134pub struct BulkMintResponse {
135 /// Coin spends to be signed and broadcast.
136 pub coin_spends: Vec<CoinSpend>,
137 /// The minted NFTs' launcher ids, in manifest order.
138 pub launcher_ids: Vec<Bytes32>,
139}
140
141/// Build the spend bundle that bulk-mints every item in `items` into `collection`, attributed to
142/// `did`, authorized by a single DID spend.
143///
144/// Uses the canonical chia-sdk-driver bulk-mint pattern: one [`IntermediateLauncher`] per item
145/// (`mint_number`, `mint_total`) → [`Launcher::mint_nft`], with the collection's shared royalty and
146/// the DID `TransferNft` attribution, then spends the DID once with all the mint conditions
147/// extended together. The DID coin is supplied by the caller (created via [`crate::did::create_did`]
148/// or fetched on-chain) and must be spendable by `minter_synthetic_key`.
149///
150/// `recipient_puzzle_hash` owns every minted NFT. Returns the coin spends + the launcher ids.
151///
152/// This builds the launcher loop only — it is correct for N=1 (the DID singleton parents the one
153/// launcher directly). A REAL on-chain mint of N>1 items ALSO needs a separate XCH coin to donate the
154/// extra 1-mojo-per-launcher the intermediate-launcher trick prints; use [`build_bulk_mint_funded`]
155/// for that (coin-value conservation is bundle-wide — see its docs).
156///
157/// [`Launcher::mint_nft`]: chia_sdk_driver::Launcher::mint_nft
158///
159/// # Errors
160/// [`WalletError::Parse`] if `items` is empty; [`WalletError::Driver`] on spend-construction failure.
161#[allow(clippy::too_many_arguments)]
162pub fn build_bulk_mint(
163 minter_synthetic_key: PublicKey,
164 did: Did,
165 collection: &Collection,
166 items: &[ManifestItem],
167 recipient_puzzle_hash: Bytes32,
168) -> Result<BulkMintResponse, WalletError> {
169 let mut ctx = SpendContext::new();
170 let launcher_ids = build_bulk_mint_in(
171 &mut ctx,
172 minter_synthetic_key,
173 did,
174 collection,
175 items,
176 recipient_puzzle_hash,
177 )?;
178 Ok(BulkMintResponse {
179 coin_spends: ctx.take(),
180 launcher_ids,
181 })
182}
183
184/// Build a MULTI-item DID-attributed bulk mint FUNDED by a separate XCH coin (#221).
185///
186/// [`build_bulk_mint`] is structurally correct for any `items.len()` — but a REAL on-chain mint of
187/// N>1 items needs more value than the DID singleton carries. Each item's [`IntermediateLauncher`]
188/// uses the standard Chia bulk-mint idiom: a 0-value "intermediate" coin whose OWN spend creates a
189/// 1-mojo singleton launcher coin — i.e. it prints 1 mojo per item that must be donated from
190/// elsewhere in the SAME spend bundle (Chia's coin-value conservation is bundle-wide, not per-coin).
191/// The DID's `update` spend conserves its OWN coin value exactly (it recreates itself at the same
192/// amount), so it cannot supply that extra value for more than one item. `funding_coin` is that
193/// donor: it is spent through `funding_synthetic_key`'s standard puzzle to contribute exactly
194/// `items.len()` mojos to the bundle, with any excess returned as change to
195/// `funding_synthetic_key`'s own puzzle hash (a larger-than-needed coin is never silently burned as
196/// network fee).
197///
198/// `funding_synthetic_key` may be a different HD address than `minter_synthetic_key` (whichever
199/// address actually holds a sufficient XCH coin) — both keys are needed to sign the resulting bundle.
200///
201/// This is the wasm-facing twin of digstore's `build_collection_mint_funded`; the two builders' funding
202/// contract (1 mojo per item, change returned) is kept in lockstep. **Pure: does NOT sign or
203/// broadcast.**
204///
205/// # Errors
206/// [`WalletError::Parse`] if `items` is empty or if `funding_coin.amount < items.len()`;
207/// [`WalletError::Driver`] on spend-construction failure.
208#[allow(clippy::too_many_arguments)]
209pub fn build_bulk_mint_funded(
210 minter_synthetic_key: PublicKey,
211 did: Did,
212 collection: &Collection,
213 items: &[ManifestItem],
214 recipient_puzzle_hash: Bytes32,
215 funding_coin: Coin,
216 funding_synthetic_key: PublicKey,
217) -> Result<BulkMintResponse, WalletError> {
218 let needed = items.len() as u64;
219 if funding_coin.amount < needed {
220 return Err(WalletError::Parse(format!(
221 "funding coin has {} mojo but minting {} item(s) needs at least {needed} mojo (1 mojo \
222 per item — the DID's own value cannot fund the extra singleton launchers)",
223 funding_coin.amount,
224 items.len(),
225 )));
226 }
227
228 let mut ctx = SpendContext::new();
229 let launcher_ids = build_bulk_mint_in(
230 &mut ctx,
231 minter_synthetic_key,
232 did,
233 collection,
234 items,
235 recipient_puzzle_hash,
236 )?;
237
238 // Fund the `needed` mojos the intermediate-launcher trick prints per item (see docs above); any
239 // excess returns as change so a larger-than-needed coin is never silently burned as network fee.
240 let change = funding_coin.amount - needed;
241 let mut funding_conditions = Conditions::new();
242 if change > 0 {
243 let funding_puzzle_hash: Bytes32 =
244 StandardArgs::curry_tree_hash(funding_synthetic_key).into();
245 funding_conditions =
246 funding_conditions.create_coin(funding_puzzle_hash, change, Memos::None);
247 }
248 StandardLayer::new(funding_synthetic_key).spend(&mut ctx, funding_coin, funding_conditions)?;
249
250 Ok(BulkMintResponse {
251 coin_spends: ctx.take(),
252 launcher_ids,
253 })
254}
255
256/// Build a NON-DID MULTI-item (N>=1) bulk mint FUNDED by a COIN SET with a SINGLE network fee (#1132).
257///
258/// This is the DID-free twin of [`build_bulk_mint_funded`]: it mints every item in `items` into
259/// `collection` in ONE spend without any on-chain DID coin. Where the DID path roots each item's
260/// [`IntermediateLauncher`] at the DID coin and authorizes the mints with a `did.update` spend, this
261/// path roots them at the LEAD funding coin (`selected_coins[0]`) and emits the collected mint
262/// conditions from that coin's own standard-puzzle spend — so no DID authorization leg exists and no
263/// `TransferNft`/DID attribution is stamped onto the minted NFTs.
264///
265/// # Funding contract
266/// Every item's intermediate launcher prints 1 mojo (a 0-value intermediate coin's own spend creates a
267/// 1-mojo launcher coin) that must be donated from the SAME bundle (Chia coin-value conservation is
268/// bundle-wide). The mint therefore needs `items.len()` mojos for the launchers PLUS the single `fee`.
269/// All of `selected_coins` are spent through `minter_synthetic_key`'s standard puzzle (the lead coin
270/// carries the mint conditions; the rest assert concurrent spend with it so the bundle is atomic),
271/// contributing `items.len() + fee` mojos to the bundle. The `fee` is reserved as the bundle's network
272/// fee via a `ReserveFee` condition (NEVER silently burned), and any excess
273/// (`sum(selected) − items.len() − fee`) is returned as CHANGE to the minter's own puzzle hash.
274///
275/// This mirrors the funding shape digstore uses for its non-DID collection mint; the funding contract
276/// (1 mojo per item + a single fee, change returned) is kept in lockstep. **Pure: does NOT sign or
277/// broadcast.**
278///
279/// # Errors
280/// [`WalletError::Parse`] if `items` is empty, if `selected_coins` is empty, or if the selected coins'
281/// total is less than `items.len() + fee`; [`WalletError::Driver`] on spend-construction failure.
282pub fn build_bulk_mint_funded_no_did(
283 minter_synthetic_key: PublicKey,
284 collection: &Collection,
285 items: &[ManifestItem],
286 recipient_puzzle_hash: Bytes32,
287 selected_coins: Vec<Coin>,
288 fee: u64,
289) -> Result<BulkMintResponse, WalletError> {
290 if items.is_empty() {
291 return Err(WalletError::Parse("items is empty".to_string()));
292 }
293 if selected_coins.is_empty() {
294 return Err(WalletError::Parse("selected_coins is empty".to_string()));
295 }
296
297 let launcher_mojos = items.len() as u64; // 1 mojo per intermediate-launcher (see funding contract).
298 let needed = launcher_mojos
299 .checked_add(fee)
300 .ok_or_else(|| WalletError::Parse("launcher mojos + fee overflows u64".to_string()))?;
301 let total_amount: u64 = selected_coins.iter().try_fold(0u64, |acc, c| {
302 acc.checked_add(c.amount)
303 .ok_or_else(|| WalletError::Parse("selected-coin amount sum overflows u64".to_string()))
304 })?;
305 if total_amount < needed {
306 return Err(WalletError::Parse(format!(
307 "selected coins total {total_amount} mojo but minting {} item(s) with a {fee}-mojo fee \
308 needs at least {needed} mojo (1 mojo per item for the singleton launchers + the fee)",
309 items.len(),
310 )));
311 }
312
313 let mut ctx = SpendContext::new();
314 let p2 = StandardLayer::new(minter_synthetic_key);
315 let minter_puzzle_hash: Bytes32 = StandardArgs::curry_tree_hash(minter_synthetic_key).into();
316
317 // The lead coin roots every intermediate launcher and carries the collected mint conditions; the
318 // rest of the coin set only asserts concurrent spend so the whole set is consumed atomically.
319 let lead_coin = selected_coins[0];
320 let lead_coin_name = lead_coin.coin_id();
321
322 let (all_mint_conditions, launcher_ids) = build_bulk_mint_launchers(
323 &mut ctx,
324 lead_coin_name,
325 None,
326 collection,
327 items,
328 recipient_puzzle_hash,
329 )?;
330
331 for coin in selected_coins.into_iter().skip(1) {
332 p2.spend(
333 &mut ctx,
334 coin,
335 Conditions::new().assert_concurrent_spend(lead_coin_name),
336 )?;
337 }
338
339 // The lead coin emits the mint conditions, reserves the network fee, and returns the change so the
340 // excess is never silently burned as fee.
341 let mut lead_conditions = all_mint_conditions;
342 if fee > 0 {
343 lead_conditions = lead_conditions.reserve_fee(fee);
344 }
345 let change = total_amount
346 .checked_sub(needed)
347 .ok_or_else(|| WalletError::Parse("change calculation underflows u64".to_string()))?;
348 if change > 0 {
349 lead_conditions = lead_conditions.create_coin(minter_puzzle_hash, change, Memos::None);
350 }
351 p2.spend(&mut ctx, lead_coin, lead_conditions)?;
352
353 Ok(BulkMintResponse {
354 coin_spends: ctx.take(),
355 launcher_ids,
356 })
357}
358
359/// Build the per-item intermediate launchers + the single DID-authorizing spend into a caller-provided
360/// `ctx`, returning the minted launcher ids in manifest order. Shared by [`build_bulk_mint`] and
361/// [`build_bulk_mint_funded`]; the launcher loop is N-agnostic (the only thing an N>1 mint additionally
362/// needs is the funding-coin spend, which the funded wrapper adds).
363fn build_bulk_mint_in(
364 ctx: &mut SpendContext,
365 minter_synthetic_key: PublicKey,
366 did: Did,
367 collection: &Collection,
368 items: &[ManifestItem],
369 recipient_puzzle_hash: Bytes32,
370) -> Result<Vec<Bytes32>, WalletError> {
371 let p2 = StandardLayer::new(minter_synthetic_key);
372
373 let did_attr = DidAttribution {
374 launcher_id: did.info.launcher_id,
375 inner_puzzle_hash: did.info.inner_puzzle_hash().into(),
376 };
377
378 let (all_mint_conditions, launcher_ids) = build_bulk_mint_launchers(
379 ctx,
380 did.coin.coin_id(),
381 Some(&did_attr),
382 collection,
383 items,
384 recipient_puzzle_hash,
385 )?;
386
387 // Spend the DID once, authorizing all mints. `did.update` re-creates the DID under its own
388 // standard inner puzzle, emitting the collected mint conditions; the recreated DID is not needed
389 // here (the caller re-fetches it on-chain if it wants to chain further mints).
390 let _recreated_did = did.update(ctx, &p2, all_mint_conditions)?;
391
392 Ok(launcher_ids)
393}
394
395/// Build the per-item [`IntermediateLauncher`] mints rooted at `parent_coin_id`, returning the
396/// collected mint conditions (which the PARENT coin's spend must emit) and the launcher ids in
397/// manifest order. Shared by the DID path ([`build_bulk_mint_in`], parent = the DID coin, with a
398/// `TransferNft` attribution) and the non-DID path ([`build_bulk_mint_funded_no_did`], parent = the
399/// lead funding coin, no attribution). Passing `did_attr` = `None` mints without any DID attribution.
400fn build_bulk_mint_launchers(
401 ctx: &mut SpendContext,
402 parent_coin_id: Bytes32,
403 did_attr: Option<&DidAttribution>,
404 collection: &Collection,
405 items: &[ManifestItem],
406 recipient_puzzle_hash: Bytes32,
407) -> Result<(Conditions, Vec<Bytes32>), WalletError> {
408 if items.is_empty() {
409 return Err(WalletError::Parse("items is empty".to_string()));
410 }
411
412 let total = items.len();
413 let mut all_mint_conditions = Conditions::new();
414 let mut launcher_ids = Vec::with_capacity(total);
415
416 for (i, item) in items.iter().enumerate() {
417 let metadata_ptr =
418 ctx.alloc_hashed(&item_to_chain_metadata(item, i as u64 + 1, total as u64))?;
419 let transfer = did_attr.map(|did_attr| {
420 TransferNft::new(
421 Some(did_attr.launcher_id),
422 Vec::new(),
423 Some(did_attr.inner_puzzle_hash),
424 )
425 });
426 let mut nft_mint = NftMint::new(
427 metadata_ptr,
428 recipient_puzzle_hash,
429 collection.royalty_basis_points,
430 transfer,
431 );
432 nft_mint.royalty_puzzle_hash = collection.royalty_puzzle_hash;
433
434 let (mint_conditions, nft) = IntermediateLauncher::new(parent_coin_id, i, total)
435 .create(ctx)?
436 .mint_nft(ctx, &nft_mint)?;
437 all_mint_conditions = all_mint_conditions.extend(mint_conditions);
438 launcher_ids.push(nft.info.launcher_id);
439 }
440
441 Ok((all_mint_conditions, launcher_ids))
442}
443
444/// Convert a manifest item into the on-chain [`NftMetadata`] CLVM struct for one bulk-mint slot.
445fn item_to_chain_metadata(
446 item: &ManifestItem,
447 edition_number: u64,
448 edition_total: u64,
449) -> NftMetadata {
450 let media = item.media.to_media(edition_number, edition_total);
451 NftMetadata {
452 edition_number: media.edition_number,
453 edition_total: media.edition_total,
454 data_uris: media.data_uris,
455 data_hash: media.data_hash,
456 metadata_uris: media.metadata_uris,
457 metadata_hash: media.metadata_hash,
458 license_uris: media.license_uris,
459 license_hash: media.license_hash,
460 }
461}
462
463/// Compute the standard puzzle hash for a synthetic key — the convenience used to default the
464/// bulk-mint recipient to the minter's own wallet.
465pub fn synthetic_puzzle_hash(synthetic_key: PublicKey) -> Bytes32 {
466 StandardArgs::curry_tree_hash(synthetic_key).into()
467}