chip35-dl-coin 0.11.0

Isolated CHIP-0035 Chia DataLayer store coin driver (offline spend-bundle construction).
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
//! Collection primitive + traits-manifest bulk mint (roadmap #34).
//!
//! Creators think in *collections*, not individual mints. This models a CHIP-0007 collection
//! (id/name/attributes/shared royalty + DID + license), generates per-item CHIP-0007 metadata from
//! a *parsed* traits manifest (JSON in — no file IO; the CLI/toolkit parses CSV/JSON into the
//! manifest), and bulk-mints all items in one bundle using intermediate launchers.

use chia_bls::PublicKey;
use chia_protocol::{Bytes32, Coin, CoinSpend};
use chia_puzzle_types::nft::NftMetadata;
use chia_puzzle_types::standard::StandardArgs;
use chia_puzzle_types::Memos;
use chia_sdk_driver::{
    Did, IntermediateLauncher, NftMint, SingletonInfo, SpendContext, StandardLayer,
};
use chia_sdk_types::{conditions::TransferNft, Conditions};
use serde::{Deserialize, Serialize};

use crate::error::WalletError;
use crate::metadata::{Attribute, Chip0007Metadata, CollectionAttribute, CollectionRef};
use crate::nft::{DidAttribution, NftMediaMetadata};

/// A CHIP-0007 collection definition: the shared identity + economics across every item.
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct Collection {
    /// Stable collection id (the toolkit derives it from the creator DID + name, or supplies it).
    pub id: String,
    /// Human-readable collection name.
    pub name: String,
    /// Collection-level attributes (icon/banner/website/twitter/etc) as CHIP-0007 `type`/`value`
    /// pairs ([`CollectionAttribute`] — NOT the NFT-item [`Attribute`]; see #189).
    #[serde(default)]
    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.
    pub royalty_basis_points: u16,
}

impl Collection {
    /// The [`CollectionRef`] block embedded into each item's CHIP-0007 metadata.
    pub fn as_ref_block(&self) -> CollectionRef {
        CollectionRef {
            id: self.id.clone(),
            name: self.name.clone(),
            attributes: self.attributes.clone(),
        }
    }
}

/// One item in a parsed traits manifest. The toolkit produces this from a CSV/JSON manifest +
/// the per-item capsule hashes; this crate consumes the parsed form only (no file IO).
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct ManifestItem {
    /// The item's name (e.g. `"DIG Punk #12"`).
    pub name: String,
    /// Optional per-item description.
    #[serde(default)]
    pub description: Option<String>,
    /// Per-item traits.
    #[serde(default)]
    pub attributes: Vec<Attribute>,
    /// On-chain media metadata + hashes for this item (URN + https fallback URIs).
    pub media: ManifestMedia,
}

/// The on-chain media fields for a manifest item (mirrors [`NftMediaMetadata`] in a serde-friendly,
/// hex-hash shape for the parsed manifest).
#[derive(Clone, Debug, Default, Serialize, Deserialize)]
pub struct ManifestMedia {
    /// Primary media URIs (URN first, https fallback second by convention).
    #[serde(default)]
    pub data_uris: Vec<String>,
    /// `sha256(media_bytes)`.
    #[serde(default)]
    pub data_hash: Option<Bytes32>,
    /// CHIP-0007 metadata JSON URIs.
    #[serde(default)]
    pub metadata_uris: Vec<String>,
    /// `sha256(metadata_json_bytes)`.
    #[serde(default)]
    pub metadata_hash: Option<Bytes32>,
    /// License document URIs.
    #[serde(default)]
    pub license_uris: Vec<String>,
    /// `sha256(license_bytes)`.
    #[serde(default)]
    pub license_hash: Option<Bytes32>,
}

impl ManifestMedia {
    fn to_media(&self, edition_number: u64, edition_total: u64) -> NftMediaMetadata {
        NftMediaMetadata {
            data_uris: self.data_uris.clone(),
            data_hash: self.data_hash,
            metadata_uris: self.metadata_uris.clone(),
            metadata_hash: self.metadata_hash,
            license_uris: self.license_uris.clone(),
            license_hash: self.license_hash,
            edition_number,
            edition_total,
        }
    }
}

/// Generate the per-item CHIP-0007 metadata documents for a collection from a parsed manifest.
///
/// Each item gets the collection block, its own traits, and `series_number`/`series_total` filled in
/// (1-based). This is the off-chain JSON side; the on-chain hashes come from [`ManifestMedia`]. The
/// toolkit hashes each generated document and writes it into the item's capsule.
pub fn generate_item_metadata(
    collection: &Collection,
    items: &[ManifestItem],
) -> Vec<Chip0007Metadata> {
    let total = items.len() as u64;
    items
        .iter()
        .enumerate()
        .map(|(i, item)| {
            let mut md = Chip0007Metadata::new(item.name.clone());
            md.description = item.description.clone();
            md.attributes = item.attributes.clone();
            md.collection = Some(collection.as_ref_block());
            md.series_number = Some(i as u64 + 1);
            md.series_total = Some(total);
            md.minting_tool = Some("DIG".to_string());
            md
        })
        .collect()
}

/// The result of a bulk mint: the coin spends + the launcher id of every minted NFT (in order).
#[derive(Clone, Debug)]
pub struct BulkMintResponse {
    /// Coin spends to be signed and broadcast.
    pub coin_spends: Vec<CoinSpend>,
    /// The minted NFTs' launcher ids, in manifest order.
    pub launcher_ids: Vec<Bytes32>,
}

/// Build the spend bundle that bulk-mints every item in `items` into `collection`, attributed to
/// `did`, authorized by a single DID spend.
///
/// Uses the canonical chia-sdk-driver bulk-mint pattern: one [`IntermediateLauncher`] per item
/// (`mint_number`, `mint_total`) → [`Launcher::mint_nft`], with the collection's shared royalty and
/// the DID `TransferNft` attribution, then spends the DID once with all the mint conditions
/// extended together. The DID coin is supplied by the caller (created via [`crate::did::create_did`]
/// or fetched on-chain) and must be spendable by `minter_synthetic_key`.
///
/// `recipient_puzzle_hash` owns every minted NFT. Returns the coin spends + the launcher ids.
///
/// This builds the launcher loop only — it is correct for N=1 (the DID singleton parents the one
/// launcher directly). A REAL on-chain mint of N>1 items ALSO needs a separate XCH coin to donate the
/// extra 1-mojo-per-launcher the intermediate-launcher trick prints; use [`build_bulk_mint_funded`]
/// for that (coin-value conservation is bundle-wide — see its docs).
///
/// [`Launcher::mint_nft`]: chia_sdk_driver::Launcher::mint_nft
///
/// # Errors
/// [`WalletError::Parse`] if `items` is empty; [`WalletError::Driver`] on spend-construction failure.
#[allow(clippy::too_many_arguments)]
pub fn build_bulk_mint(
    minter_synthetic_key: PublicKey,
    did: Did,
    collection: &Collection,
    items: &[ManifestItem],
    recipient_puzzle_hash: Bytes32,
) -> Result<BulkMintResponse, WalletError> {
    let mut ctx = SpendContext::new();
    let launcher_ids = build_bulk_mint_in(
        &mut ctx,
        minter_synthetic_key,
        did,
        collection,
        items,
        recipient_puzzle_hash,
    )?;
    Ok(BulkMintResponse {
        coin_spends: ctx.take(),
        launcher_ids,
    })
}

/// Build a MULTI-item DID-attributed bulk mint FUNDED by a separate XCH coin (#221).
///
/// [`build_bulk_mint`] is structurally correct for any `items.len()` — but a REAL on-chain mint of
/// N>1 items needs more value than the DID singleton carries. Each item's [`IntermediateLauncher`]
/// uses the standard Chia bulk-mint idiom: a 0-value "intermediate" coin whose OWN spend creates a
/// 1-mojo singleton launcher coin — i.e. it prints 1 mojo per item that must be donated from
/// elsewhere in the SAME spend bundle (Chia's coin-value conservation is bundle-wide, not per-coin).
/// The DID's `update` spend conserves its OWN coin value exactly (it recreates itself at the same
/// amount), so it cannot supply that extra value for more than one item. `funding_coin` is that
/// donor: it is spent through `funding_synthetic_key`'s standard puzzle to contribute exactly
/// `items.len()` mojos to the bundle, with any excess returned as change to
/// `funding_synthetic_key`'s own puzzle hash (a larger-than-needed coin is never silently burned as
/// network fee).
///
/// `funding_synthetic_key` may be a different HD address than `minter_synthetic_key` (whichever
/// address actually holds a sufficient XCH coin) — both keys are needed to sign the resulting bundle.
///
/// This is the wasm-facing twin of digstore's `build_collection_mint_funded`; the two builders' funding
/// contract (1 mojo per item, change returned) is kept in lockstep. **Pure: does NOT sign or
/// broadcast.**
///
/// # Errors
/// [`WalletError::Parse`] if `items` is empty or if `funding_coin.amount < items.len()`;
/// [`WalletError::Driver`] on spend-construction failure.
#[allow(clippy::too_many_arguments)]
pub fn build_bulk_mint_funded(
    minter_synthetic_key: PublicKey,
    did: Did,
    collection: &Collection,
    items: &[ManifestItem],
    recipient_puzzle_hash: Bytes32,
    funding_coin: Coin,
    funding_synthetic_key: PublicKey,
) -> Result<BulkMintResponse, WalletError> {
    let needed = items.len() as u64;
    if funding_coin.amount < needed {
        return Err(WalletError::Parse(format!(
            "funding coin has {} mojo but minting {} item(s) needs at least {needed} mojo (1 mojo \
             per item — the DID's own value cannot fund the extra singleton launchers)",
            funding_coin.amount,
            items.len(),
        )));
    }

    let mut ctx = SpendContext::new();
    let launcher_ids = build_bulk_mint_in(
        &mut ctx,
        minter_synthetic_key,
        did,
        collection,
        items,
        recipient_puzzle_hash,
    )?;

    // Fund the `needed` mojos the intermediate-launcher trick prints per item (see docs above); any
    // excess returns as change so a larger-than-needed coin is never silently burned as network fee.
    let change = funding_coin.amount - needed;
    let mut funding_conditions = Conditions::new();
    if change > 0 {
        let funding_puzzle_hash: Bytes32 =
            StandardArgs::curry_tree_hash(funding_synthetic_key).into();
        funding_conditions =
            funding_conditions.create_coin(funding_puzzle_hash, change, Memos::None);
    }
    StandardLayer::new(funding_synthetic_key).spend(&mut ctx, funding_coin, funding_conditions)?;

    Ok(BulkMintResponse {
        coin_spends: ctx.take(),
        launcher_ids,
    })
}

/// Build a NON-DID MULTI-item (N>=1) bulk mint FUNDED by a COIN SET with a SINGLE network fee (#1132).
///
/// This is the DID-free twin of [`build_bulk_mint_funded`]: it mints every item in `items` into
/// `collection` in ONE spend without any on-chain DID coin. Where the DID path roots each item's
/// [`IntermediateLauncher`] at the DID coin and authorizes the mints with a `did.update` spend, this
/// path roots them at the LEAD funding coin (`selected_coins[0]`) and emits the collected mint
/// conditions from that coin's own standard-puzzle spend — so no DID authorization leg exists and no
/// `TransferNft`/DID attribution is stamped onto the minted NFTs.
///
/// # Funding contract
/// Every item's intermediate launcher prints 1 mojo (a 0-value intermediate coin's own spend creates a
/// 1-mojo launcher coin) that must be donated from the SAME bundle (Chia coin-value conservation is
/// bundle-wide). The mint therefore needs `items.len()` mojos for the launchers PLUS the single `fee`.
/// All of `selected_coins` are spent through `minter_synthetic_key`'s standard puzzle (the lead coin
/// carries the mint conditions; the rest assert concurrent spend with it so the bundle is atomic),
/// contributing `items.len() + fee` mojos to the bundle. The `fee` is reserved as the bundle's network
/// fee via a `ReserveFee` condition (NEVER silently burned), and any excess
/// (`sum(selected) − items.len() − fee`) is returned as CHANGE to the minter's own puzzle hash.
///
/// This mirrors the funding shape digstore uses for its non-DID collection mint; the funding contract
/// (1 mojo per item + a single fee, change returned) is kept in lockstep. **Pure: does NOT sign or
/// broadcast.**
///
/// # Errors
/// [`WalletError::Parse`] if `items` is empty, if `selected_coins` is empty, or if the selected coins'
/// total is less than `items.len() + fee`; [`WalletError::Driver`] on spend-construction failure.
pub fn build_bulk_mint_funded_no_did(
    minter_synthetic_key: PublicKey,
    collection: &Collection,
    items: &[ManifestItem],
    recipient_puzzle_hash: Bytes32,
    selected_coins: Vec<Coin>,
    fee: u64,
) -> Result<BulkMintResponse, WalletError> {
    if items.is_empty() {
        return Err(WalletError::Parse("items is empty".to_string()));
    }
    if selected_coins.is_empty() {
        return Err(WalletError::Parse("selected_coins is empty".to_string()));
    }

    let launcher_mojos = items.len() as u64; // 1 mojo per intermediate-launcher (see funding contract).
    let needed = launcher_mojos
        .checked_add(fee)
        .ok_or_else(|| WalletError::Parse("launcher mojos + fee overflows u64".to_string()))?;
    let total_amount: u64 = selected_coins.iter().try_fold(0u64, |acc, c| {
        acc.checked_add(c.amount)
            .ok_or_else(|| WalletError::Parse("selected-coin amount sum overflows u64".to_string()))
    })?;
    if total_amount < needed {
        return Err(WalletError::Parse(format!(
            "selected coins total {total_amount} mojo but minting {} item(s) with a {fee}-mojo fee \
             needs at least {needed} mojo (1 mojo per item for the singleton launchers + the fee)",
            items.len(),
        )));
    }

    let mut ctx = SpendContext::new();
    let p2 = StandardLayer::new(minter_synthetic_key);
    let minter_puzzle_hash: Bytes32 = StandardArgs::curry_tree_hash(minter_synthetic_key).into();

    // The lead coin roots every intermediate launcher and carries the collected mint conditions; the
    // rest of the coin set only asserts concurrent spend so the whole set is consumed atomically.
    let lead_coin = selected_coins[0];
    let lead_coin_name = lead_coin.coin_id();

    let (all_mint_conditions, launcher_ids) = build_bulk_mint_launchers(
        &mut ctx,
        lead_coin_name,
        None,
        collection,
        items,
        recipient_puzzle_hash,
    )?;

    for coin in selected_coins.into_iter().skip(1) {
        p2.spend(
            &mut ctx,
            coin,
            Conditions::new().assert_concurrent_spend(lead_coin_name),
        )?;
    }

    // The lead coin emits the mint conditions, reserves the network fee, and returns the change so the
    // excess is never silently burned as fee.
    let mut lead_conditions = all_mint_conditions;
    if fee > 0 {
        lead_conditions = lead_conditions.reserve_fee(fee);
    }
    let change = total_amount
        .checked_sub(needed)
        .ok_or_else(|| WalletError::Parse("change calculation underflows u64".to_string()))?;
    if change > 0 {
        lead_conditions = lead_conditions.create_coin(minter_puzzle_hash, change, Memos::None);
    }
    p2.spend(&mut ctx, lead_coin, lead_conditions)?;

    Ok(BulkMintResponse {
        coin_spends: ctx.take(),
        launcher_ids,
    })
}

/// Build the per-item intermediate launchers + the single DID-authorizing spend into a caller-provided
/// `ctx`, returning the minted launcher ids in manifest order. Shared by [`build_bulk_mint`] and
/// [`build_bulk_mint_funded`]; the launcher loop is N-agnostic (the only thing an N>1 mint additionally
/// needs is the funding-coin spend, which the funded wrapper adds).
fn build_bulk_mint_in(
    ctx: &mut SpendContext,
    minter_synthetic_key: PublicKey,
    did: Did,
    collection: &Collection,
    items: &[ManifestItem],
    recipient_puzzle_hash: Bytes32,
) -> Result<Vec<Bytes32>, WalletError> {
    let p2 = StandardLayer::new(minter_synthetic_key);

    let did_attr = DidAttribution {
        launcher_id: did.info.launcher_id,
        inner_puzzle_hash: did.info.inner_puzzle_hash().into(),
    };

    let (all_mint_conditions, launcher_ids) = build_bulk_mint_launchers(
        ctx,
        did.coin.coin_id(),
        Some(&did_attr),
        collection,
        items,
        recipient_puzzle_hash,
    )?;

    // Spend the DID once, authorizing all mints. `did.update` re-creates the DID under its own
    // standard inner puzzle, emitting the collected mint conditions; the recreated DID is not needed
    // here (the caller re-fetches it on-chain if it wants to chain further mints).
    let _recreated_did = did.update(ctx, &p2, all_mint_conditions)?;

    Ok(launcher_ids)
}

/// Build the per-item [`IntermediateLauncher`] mints rooted at `parent_coin_id`, returning the
/// collected mint conditions (which the PARENT coin's spend must emit) and the launcher ids in
/// manifest order. Shared by the DID path ([`build_bulk_mint_in`], parent = the DID coin, with a
/// `TransferNft` attribution) and the non-DID path ([`build_bulk_mint_funded_no_did`], parent = the
/// lead funding coin, no attribution). Passing `did_attr` = `None` mints without any DID attribution.
fn build_bulk_mint_launchers(
    ctx: &mut SpendContext,
    parent_coin_id: Bytes32,
    did_attr: Option<&DidAttribution>,
    collection: &Collection,
    items: &[ManifestItem],
    recipient_puzzle_hash: Bytes32,
) -> Result<(Conditions, Vec<Bytes32>), WalletError> {
    if items.is_empty() {
        return Err(WalletError::Parse("items is empty".to_string()));
    }

    let total = items.len();
    let mut all_mint_conditions = Conditions::new();
    let mut launcher_ids = Vec::with_capacity(total);

    for (i, item) in items.iter().enumerate() {
        let metadata_ptr =
            ctx.alloc_hashed(&item_to_chain_metadata(item, i as u64 + 1, total as u64))?;
        let transfer = did_attr.map(|did_attr| {
            TransferNft::new(
                Some(did_attr.launcher_id),
                Vec::new(),
                Some(did_attr.inner_puzzle_hash),
            )
        });
        let mut nft_mint = NftMint::new(
            metadata_ptr,
            recipient_puzzle_hash,
            collection.royalty_basis_points,
            transfer,
        );
        nft_mint.royalty_puzzle_hash = collection.royalty_puzzle_hash;

        let (mint_conditions, nft) = IntermediateLauncher::new(parent_coin_id, i, total)
            .create(ctx)?
            .mint_nft(ctx, &nft_mint)?;
        all_mint_conditions = all_mint_conditions.extend(mint_conditions);
        launcher_ids.push(nft.info.launcher_id);
    }

    Ok((all_mint_conditions, launcher_ids))
}

/// Convert a manifest item into the on-chain [`NftMetadata`] CLVM struct for one bulk-mint slot.
fn item_to_chain_metadata(
    item: &ManifestItem,
    edition_number: u64,
    edition_total: u64,
) -> NftMetadata {
    let media = item.media.to_media(edition_number, edition_total);
    NftMetadata {
        edition_number: media.edition_number,
        edition_total: media.edition_total,
        data_uris: media.data_uris,
        data_hash: media.data_hash,
        metadata_uris: media.metadata_uris,
        metadata_hash: media.metadata_hash,
        license_uris: media.license_uris,
        license_hash: media.license_hash,
    }
}

/// Compute the standard puzzle hash for a synthetic key — the convenience used to default the
/// bulk-mint recipient to the minter's own wallet.
pub fn synthetic_puzzle_hash(synthetic_key: PublicKey) -> Bytes32 {
    StandardArgs::curry_tree_hash(synthetic_key).into()
}