Skip to main content

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}