use chia_bls::PublicKey;
use chia_protocol::{Bytes, Bytes32, Coin, CoinSpend};
use chia_puzzle_types::nft::NftMetadata;
use chia_puzzle_types::standard::StandardArgs;
use chia_puzzle_types::Memos;
use chia_sdk_driver::{Launcher, NftMint, SpendContext, StandardLayer};
use chia_sdk_types::conditions::CreateCoinAnnouncement;
use chia_sdk_types::puzzles::{P2CurriedArgs, P2CurriedSolution};
use chia_sdk_types::{Conditions, Mod};
use clvm_traits::clvm_quote;
use clvmr::NodePtr;
use serde::{Deserialize, Serialize};
use crate::collection::Collection;
use crate::error::WalletError;
use crate::nft::NftMediaMetadata;
use crate::payment::PaymentAsset;
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub enum LazyMintPolicy {
DirectMint,
PaymentGated {
price: u64,
asset: PaymentAsset,
payee: Bytes32,
},
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct LazyMintItem {
pub metadata: NftMediaMetadata,
pub royalty_basis_points: u16,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct LazyMintTreeDescriptor {
pub did_coin_id: Bytes32,
pub collection: Collection,
pub items: Vec<LazyMintItem>,
pub policy: LazyMintPolicy,
pub allowlist_root: Option<Bytes32>,
pub commit_coins: Vec<Coin>,
pub launcher_ids: Vec<Bytes32>,
}
#[derive(Clone, Debug)]
pub struct LazyMintCommitResponse {
pub coin_spends: Vec<CoinSpend>,
pub root: Bytes32,
pub launcher_ids: Vec<Bytes32>,
pub commit_coins: Vec<Coin>,
descriptor: LazyMintTreeDescriptor,
}
impl LazyMintCommitResponse {
pub fn descriptor(&self) -> LazyMintTreeDescriptor {
self.descriptor.clone()
}
}
#[derive(Clone, Debug)]
pub struct LazyMintClaimResponse {
pub coin_spends: Vec<CoinSpend>,
pub launcher_id: Bytes32,
pub nft_coin: Coin,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct MerkleMembershipProof {
pub path: u32,
pub proof: Vec<Bytes32>,
}
pub fn merkle_membership_root(leaf: Bytes32, proof: &MerkleMembershipProof) -> Bytes32 {
fn h(parts: &[&[u8]]) -> Bytes32 {
let mut hasher = chia_sha2::Sha256::new();
for p in parts {
hasher.update(p);
}
Bytes32::from(hasher.finalize())
}
let mut current = h(&[&[1u8], leaf.as_ref()]);
let mut path = proof.path;
for sibling in &proof.proof {
current = if path & 1 == 1 {
h(&[&[2u8], sibling.as_ref(), current.as_ref()])
} else {
h(&[&[2u8], current.as_ref(), sibling.as_ref()])
};
path >>= 1;
}
current
}
pub fn verify_merkle_membership(
leaf: Bytes32,
proof: &MerkleMembershipProof,
root: Bytes32,
) -> bool {
merkle_membership_root(leaf, proof) == root
}
fn launcher_node_conditions(index: usize) -> Conditions {
use chia_puzzles::SINGLETON_LAUNCHER_HASH;
Conditions::new()
.create_coin(SINGLETON_LAUNCHER_HASH.into(), 1, Memos::None)
.with(CreateCoinAnnouncement::new(Bytes::from(
(index as u64).to_be_bytes().to_vec(),
)))
}
fn commit_puzzle_hash(ctx: &mut SpendContext, index: usize) -> Result<Bytes32, WalletError> {
let node = ctx.alloc(&clvm_quote!(launcher_node_conditions(index)))?;
let node_hash: Bytes32 = ctx.tree_hash(node).into();
Ok(P2CurriedArgs::new(node_hash).curry_tree_hash().into())
}
fn item_chain_metadata(item: &LazyMintItem) -> NftMetadata {
let m = &item.metadata;
NftMetadata {
edition_number: if m.edition_number == 0 {
1
} else {
m.edition_number
},
edition_total: if m.edition_total == 0 {
1
} else {
m.edition_total
},
data_uris: m.data_uris.clone(),
data_hash: m.data_hash,
metadata_uris: m.metadata_uris.clone(),
metadata_hash: m.metadata_hash,
license_uris: m.license_uris.clone(),
license_hash: m.license_hash,
}
}
fn claim_recipient(policy: &LazyMintPolicy, claimer_puzzle_hash: Bytes32) -> Bytes32 {
match policy {
LazyMintPolicy::DirectMint => claimer_puzzle_hash,
LazyMintPolicy::PaymentGated { .. } => claimer_puzzle_hash,
}
}
pub fn build_lazy_mint_commit(
minter_synthetic_key: PublicKey,
did: chia_sdk_driver::Did,
collection: &Collection,
items: &[LazyMintItem],
policy: LazyMintPolicy,
allowlist_root: Option<Bytes32>,
) -> Result<LazyMintCommitResponse, WalletError> {
if items.is_empty() {
return Err(WalletError::Parse("items is empty".to_string()));
}
let mut ctx = SpendContext::new();
let p2 = StandardLayer::new(minter_synthetic_key);
let did_coin_id = did.coin.coin_id();
let mut commit_conditions = Conditions::new();
let mut commit_coins = Vec::with_capacity(items.len());
let mut launcher_ids = Vec::with_capacity(items.len());
for index in 0..items.len() {
let commit_ph = commit_puzzle_hash(&mut ctx, index)?;
let commit_coin = Coin::new(did_coin_id, commit_ph, 0);
let launcher_id = Launcher::new(commit_coin.coin_id(), 1).coin().coin_id();
let hint = ctx.hint(launcher_id)?;
commit_conditions = commit_conditions.create_coin(commit_ph, 0, hint);
commit_coins.push(commit_coin);
launcher_ids.push(launcher_id);
}
let _recreated = did.update(&mut ctx, &p2, commit_conditions)?;
let descriptor = LazyMintTreeDescriptor {
did_coin_id,
collection: collection.clone(),
items: items.to_vec(),
policy,
allowlist_root,
commit_coins: commit_coins.clone(),
launcher_ids: launcher_ids.clone(),
};
Ok(LazyMintCommitResponse {
coin_spends: ctx.take(),
root: did_coin_id,
launcher_ids,
commit_coins,
descriptor,
})
}
#[allow(clippy::too_many_arguments)]
pub fn build_lazy_mint_claim(
claimer_synthetic_key: PublicKey,
claimer_coins: Vec<Coin>,
claimer_puzzle_hash: Bytes32,
commit: &LazyMintTreeDescriptor,
index: usize,
merkle_proof: Option<MerkleMembershipProof>,
fee: u64,
) -> Result<LazyMintClaimResponse, WalletError> {
if claimer_coins.is_empty() {
return Err(WalletError::Parse("claimer_coins is empty".to_string()));
}
if index >= commit.items.len() {
return Err(WalletError::Parse(format!(
"item index {index} out of range (collection has {} items)",
commit.items.len()
)));
}
if let Some(root) = commit.allowlist_root {
match &merkle_proof {
None => {
return Err(WalletError::AllowlistDenied(
"this drop is allowlist-gated; a merkle membership proof is required"
.to_string(),
));
}
Some(proof) => {
if !verify_merkle_membership(claimer_puzzle_hash, proof, root) {
return Err(WalletError::AllowlistDenied(format!(
"merkle proof does not prove the claimer's puzzle hash is in the allowlist root {root}"
)));
}
}
}
}
let item = &commit.items[index];
let commit_coin = commit.commit_coins[index];
let claimer_puzzle_hash_self: Bytes32 =
StandardArgs::curry_tree_hash(claimer_synthetic_key).into();
let total_amount: u64 = claimer_coins.iter().map(|c| c.amount).sum();
let reserved = fee + 1; if total_amount < reserved {
return Err(WalletError::Parse(format!(
"claimer coins ({total_amount}) cannot cover fee {fee} + 1 mojo launcher"
)));
}
let mut ctx = SpendContext::new();
let p2 = StandardLayer::new(claimer_synthetic_key);
let node = ctx.alloc(&clvm_quote!(launcher_node_conditions(index)))?;
let node_hash: Bytes32 = ctx.tree_hash(node).into();
let p2_curried_puzzle = {
let curried = ctx.curry(P2CurriedArgs::new(node_hash))?;
ctx.alloc(&curried)?
};
let p2_curried_solution = ctx.alloc(&P2CurriedSolution::new(node, NodePtr::NIL))?;
ctx.spend(
commit_coin,
chia_sdk_driver::Spend::new(p2_curried_puzzle, p2_curried_solution),
)?;
let recipient = claim_recipient(&commit.policy, claimer_puzzle_hash);
let metadata_ptr = ctx.alloc_hashed(&item_chain_metadata(item))?;
let mut nft_mint = NftMint::new(metadata_ptr, recipient, item.royalty_basis_points, None);
nft_mint.royalty_puzzle_hash = commit.collection.royalty_puzzle_hash;
let (_create_launcher, launcher) = Launcher::create_early(commit_coin.coin_id(), 1);
let launcher_id = launcher.coin().coin_id();
let (mint_conditions, nft) = launcher.mint_nft(&mut ctx, &nft_mint)?;
let lead_coin = claimer_coins[0];
let lead_coin_id = lead_coin.coin_id();
for c in claimer_coins.iter().skip(1) {
p2.spend(
&mut ctx,
*c,
Conditions::new().assert_concurrent_spend(lead_coin_id),
)?;
}
let mut lead_conditions = mint_conditions.assert_concurrent_spend(commit_coin.coin_id());
if fee > 0 {
lead_conditions = lead_conditions.reserve_fee(fee);
}
if total_amount > reserved {
let hint = ctx.hint(claimer_puzzle_hash_self)?;
lead_conditions =
lead_conditions.create_coin(claimer_puzzle_hash_self, total_amount - reserved, hint);
}
p2.spend(&mut ctx, lead_coin, lead_conditions)?;
Ok(LazyMintClaimResponse {
coin_spends: ctx.take(),
launcher_id,
nft_coin: nft.coin,
})
}