prep for deployment. cargo clippy --workspace --all-targets now passes clean. cargo audit unchanged (same 2 unmaintained-warning macro-support transitives; no cves). cleanup applied: - ProtocolParams construction in tools.rs uses struct-update syntax (clippy::field_reassign_with_default). - main.rs collapsed two else-if branches with identical bodies (clippy::if_same_then_else). - mint/plutus/stake sort_by(|a,b| b.cmp(&a)) → sort_by_key(Reverse(_)) (clippy::manual_sort_by). 4 sites. - metadata/mint/tx odd-length hex check uses .is_multiple_of(2) (clippy::manual_is_multiple_of). 3 sites. - stake.rs witness_overhead conditional removed — both branches produced TWO_WITNESS_OVERHEAD_BYTES (left over from when registration was thought to add a third witness; it doesn't). WITNESS_OVERHEAD_BYTES const removed (only the two-witness one is used). - Public spend/mint/stake build_signed_*_with_assets fns get #[allow(clippy::too_many_arguments)] — they ARE the API surface. - ex_units_default_is_generous test gets explicit allow for the tautological-on-const assertion (kept the intent comment). 97 unit tests still pass. release build clean.
1099 lines
38 KiB
Rust
1099 lines
38 KiB
Rust
//! Native-asset minting (Phase 3).
|
|
//!
|
|
//! ## What
|
|
//!
|
|
//! - Construct a Cardano native script (CIP-1854): single-sig,
|
|
//! single-sig-with-timelock, n-of-k multisig.
|
|
//! - Compute the script's policy ID (`blake2b-224(0x00 || cbor)`).
|
|
//! - Build a signed Conway-era mint transaction that issues N of an
|
|
//! asset under that policy and sends them to a destination
|
|
//! address.
|
|
//!
|
|
//! ## Phase 3 scope today
|
|
//!
|
|
//! - **Single-sig** + **single-sig with `invalid_after`** policy
|
|
//! shapes — the bread-and-butter wallet-controlled mint.
|
|
//! - **n-of-k multisig** policy shape (untested at the build-tx
|
|
//! layer; the script construction is solid but the mint flow
|
|
//! below assumes the wallet's payment key alone is sufficient
|
|
//! to satisfy the script).
|
|
//! - **No metadata.** pallas-txbuilder 0.32/0.35 doesn't surface
|
|
//! `auxiliary_data` through its public API yet (it's a hardcoded
|
|
//! `None` in the conway builder), so CIP-25 / CIP-68 metadata
|
|
//! needs either a post-build CBOR injection pass or a direct
|
|
//! pallas-primitives tx assembly. Both are follow-up work.
|
|
//!
|
|
//! ## Burn vs mint
|
|
//!
|
|
//! `quantity > 0` mints, `quantity < 0` burns. Burns require the
|
|
//! caller to actually hold the assets at one of the input UTXOs.
|
|
|
|
use pallas_codec::minicbor;
|
|
use pallas_crypto::hash::Hash;
|
|
use pallas_primitives::alonzo::NativeScript;
|
|
use pallas_traverse::ComputeHash;
|
|
use pallas_txbuilder::{BuildConway, BuiltTransaction, Input, Output, ScriptKind, StagingTransaction};
|
|
use pallas_wallet::PrivateKey;
|
|
use serde::{Deserialize, Serialize};
|
|
|
|
use crate::cip68::{build_cip68_datum_cbor, ref_nft_asset_name, user_nft_asset_name};
|
|
use crate::tx::{InputUtxo, PaymentSummary, ProtocolParams, UnsignedPayment};
|
|
use crate::{Network, PaymentKey, WalletError};
|
|
|
|
/// High-level policy descriptor — abstracts the
|
|
/// `pallas_primitives::alonzo::NativeScript` enum so callers don't
|
|
/// need to touch pallas types directly.
|
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
|
#[serde(tag = "type", rename_all = "snake_case")]
|
|
pub enum PolicySpec {
|
|
/// Pure single-signature policy — anyone with the payment key
|
|
/// can mint or burn.
|
|
SingleSig {
|
|
/// 28-byte hex of the required signer's blake2b-224
|
|
/// payment-key hash.
|
|
signer_pkh_hex: String,
|
|
},
|
|
/// Single-sig that becomes invalid after a specific slot.
|
|
/// Stops minting / locks supply. Cardano's idiomatic "minted
|
|
/// supply is permanent" pattern.
|
|
SingleSigTimelock {
|
|
signer_pkh_hex: String,
|
|
invalid_after_slot: u64,
|
|
},
|
|
/// n-of-k multisig — k signers, any n satisfy the script.
|
|
/// Smaller `n` = looser script. Multisig treasury minting is
|
|
/// the canonical use case (2-of-2).
|
|
NofK {
|
|
/// Required signature count.
|
|
n: u32,
|
|
/// Hex-encoded payment-key hashes of the k signers.
|
|
signer_pkhs_hex: Vec<String>,
|
|
},
|
|
}
|
|
|
|
fn parse_pkh(hex_str: &str) -> Result<Hash<28>, WalletError> {
|
|
if hex_str.len() != 56 {
|
|
return Err(WalletError::Derivation(format!(
|
|
"expected 56-char hex pubkey hash, got {}",
|
|
hex_str.len()
|
|
)));
|
|
}
|
|
let mut out = [0u8; 28];
|
|
for i in 0..28 {
|
|
out[i] = u8::from_str_radix(&hex_str[i * 2..i * 2 + 2], 16).map_err(|_| {
|
|
WalletError::Derivation(format!("invalid hex in pubkey hash: {hex_str}"))
|
|
})?;
|
|
}
|
|
Ok(Hash::<28>::new(out))
|
|
}
|
|
|
|
impl PolicySpec {
|
|
/// Convenience constructor — derive a single-sig policy from a
|
|
/// `PaymentKey` directly.
|
|
pub fn single_sig(payment: &PaymentKey) -> Self {
|
|
let h = payment.public_key_hash();
|
|
Self::SingleSig {
|
|
signer_pkh_hex: hash_to_hex(&h),
|
|
}
|
|
}
|
|
|
|
/// Convenience constructor — derive a single-sig-with-timelock
|
|
/// policy from a `PaymentKey` directly.
|
|
pub fn single_sig_timelock(payment: &PaymentKey, invalid_after_slot: u64) -> Self {
|
|
let h = payment.public_key_hash();
|
|
Self::SingleSigTimelock {
|
|
signer_pkh_hex: hash_to_hex(&h),
|
|
invalid_after_slot,
|
|
}
|
|
}
|
|
|
|
/// Lower the spec into a `pallas_primitives::alonzo::NativeScript`.
|
|
pub fn to_native_script(&self) -> Result<NativeScript, WalletError> {
|
|
match self {
|
|
Self::SingleSig { signer_pkh_hex } => {
|
|
let h = parse_pkh(signer_pkh_hex)?;
|
|
Ok(NativeScript::ScriptPubkey(h))
|
|
}
|
|
Self::SingleSigTimelock {
|
|
signer_pkh_hex,
|
|
invalid_after_slot,
|
|
} => {
|
|
let h = parse_pkh(signer_pkh_hex)?;
|
|
// ScriptAll = require BOTH the signature AND the
|
|
// not-yet-expired condition.
|
|
Ok(NativeScript::ScriptAll(vec![
|
|
NativeScript::ScriptPubkey(h),
|
|
NativeScript::InvalidHereafter(*invalid_after_slot),
|
|
]))
|
|
}
|
|
Self::NofK { n, signer_pkhs_hex } => {
|
|
let mut subs: Vec<NativeScript> = Vec::with_capacity(signer_pkhs_hex.len());
|
|
for s in signer_pkhs_hex {
|
|
let h = parse_pkh(s)?;
|
|
subs.push(NativeScript::ScriptPubkey(h));
|
|
}
|
|
Ok(NativeScript::ScriptNOfK(*n, subs))
|
|
}
|
|
}
|
|
}
|
|
|
|
/// CBOR encoding of the underlying native script.
|
|
pub fn to_cbor(&self) -> Result<Vec<u8>, WalletError> {
|
|
let script = self.to_native_script()?;
|
|
minicbor::to_vec(&script)
|
|
.map_err(|e| WalletError::Derivation(format!("encode native script: {e}")))
|
|
}
|
|
|
|
/// blake2b-224 of `0x00 || cbor` — the asset's policy ID.
|
|
pub fn policy_id(&self) -> Result<Hash<28>, WalletError> {
|
|
let script = self.to_native_script()?;
|
|
Ok(script.compute_hash())
|
|
}
|
|
}
|
|
|
|
fn hash_to_hex(h: &Hash<28>) -> String {
|
|
let bytes: &[u8] = h.as_ref();
|
|
let mut s = String::with_capacity(56);
|
|
for b in bytes {
|
|
s.push_str(&format!("{:02x}", b));
|
|
}
|
|
s
|
|
}
|
|
|
|
fn parse_address(bech32: &str) -> Result<pallas_addresses::Address, WalletError> {
|
|
pallas_addresses::Address::from_bech32(bech32)
|
|
.map_err(|e| WalletError::Address(e.to_string()))
|
|
}
|
|
|
|
fn parse_tx_hash(hex_str: &str) -> Result<Hash<32>, WalletError> {
|
|
if hex_str.len() != 64 {
|
|
return Err(WalletError::Derivation(format!(
|
|
"expected 64-char hex tx_hash, got {}",
|
|
hex_str.len()
|
|
)));
|
|
}
|
|
let mut out = [0u8; 32];
|
|
for i in 0..32 {
|
|
out[i] = u8::from_str_radix(&hex_str[i * 2..i * 2 + 2], 16)
|
|
.map_err(|_| WalletError::Derivation(format!("invalid hex in tx_hash: {hex_str}")))?;
|
|
}
|
|
Ok(Hash::<32>::new(out))
|
|
}
|
|
|
|
fn parse_asset_name(hex_str: &str) -> Result<Vec<u8>, WalletError> {
|
|
if !hex_str.len().is_multiple_of(2) {
|
|
return Err(WalletError::Derivation(
|
|
"asset_name hex must have even length".into(),
|
|
));
|
|
}
|
|
if hex_str.len() > 64 {
|
|
return Err(WalletError::Derivation(format!(
|
|
"asset_name too long: {} hex chars (>64)",
|
|
hex_str.len()
|
|
)));
|
|
}
|
|
let mut out = Vec::with_capacity(hex_str.len() / 2);
|
|
for i in (0..hex_str.len()).step_by(2) {
|
|
out.push(u8::from_str_radix(&hex_str[i..i + 2], 16).map_err(|_| {
|
|
WalletError::Derivation(format!("invalid hex in asset_name: {hex_str}"))
|
|
})?);
|
|
}
|
|
Ok(out)
|
|
}
|
|
|
|
fn payment_key_to_private(payment: &PaymentKey) -> Result<PrivateKey, WalletError> {
|
|
use pallas_crypto::key::ed25519::SecretKeyExtended;
|
|
let extended: [u8; 64] = payment.xprv().extended_secret_key();
|
|
let secret = SecretKeyExtended::from_bytes(extended)
|
|
.map_err(|e| WalletError::Derivation(format!("invalid extended secret: {e}")))?;
|
|
Ok(PrivateKey::Extended(secret))
|
|
}
|
|
|
|
fn network_id_for(network: Network) -> u8 {
|
|
match network {
|
|
Network::Mainnet => 1,
|
|
Network::Preview | Network::Preprod => 0,
|
|
}
|
|
}
|
|
|
|
const WITNESS_OVERHEAD_BYTES: u64 = 128;
|
|
|
|
/// Internal — runs the two-pass fee refinement and returns the
|
|
/// final BuiltTransaction + summary. Mint version of
|
|
/// `tx::prepare_payment`. Adds: mint-asset entry, native-script
|
|
/// witness, disclosed signer.
|
|
#[allow(clippy::too_many_arguments)]
|
|
fn prepare_mint(
|
|
network: Network,
|
|
available_utxos: &[InputUtxo],
|
|
change_address_bech32: &str,
|
|
dest_address_bech32: &str,
|
|
dest_lovelace: u64,
|
|
policy: &PolicySpec,
|
|
asset_name_hex: &str,
|
|
mint_quantity: i64,
|
|
payment_pkh: Hash<28>,
|
|
aux_data_cbor: Option<&[u8]>,
|
|
params: &ProtocolParams,
|
|
) -> Result<(BuiltTransaction, PaymentSummary), WalletError> {
|
|
let dest_addr = parse_address(dest_address_bech32)?;
|
|
let change_addr = parse_address(change_address_bech32)?;
|
|
let network_id = network_id_for(network);
|
|
|
|
let policy_id = policy.policy_id()?;
|
|
let asset_name_bytes = parse_asset_name(asset_name_hex)?;
|
|
let script_cbor = policy.to_cbor()?;
|
|
|
|
// The destination output holds dest_lovelace + the freshly-minted
|
|
// asset (positive quantity). Burns subtract assets from change
|
|
// instead, so for a burn there's no asset on the dest output.
|
|
let mint_is_positive = mint_quantity > 0;
|
|
|
|
// Pre-flight UTXO selection — we need enough lovelace for
|
|
// dest_lovelace + fee + min_change. No required input assets
|
|
// here (mint creates the supply).
|
|
let fee_pass1: u64 = 800_000;
|
|
let need = dest_lovelace
|
|
.checked_add(fee_pass1)
|
|
.and_then(|x| x.checked_add(params.min_utxo_lovelace))
|
|
.ok_or_else(|| WalletError::Derivation("amount overflow".into()))?;
|
|
|
|
let mut sorted: Vec<InputUtxo> = available_utxos.to_vec();
|
|
sorted.sort_by_key(|u| std::cmp::Reverse(u.lovelace));
|
|
let mut acc: u64 = 0;
|
|
let mut selected: Vec<InputUtxo> = Vec::new();
|
|
for u in sorted {
|
|
acc = acc.saturating_add(u.lovelace);
|
|
selected.push(u);
|
|
if acc >= need {
|
|
break;
|
|
}
|
|
}
|
|
if acc < need {
|
|
return Err(WalletError::Derivation(format!(
|
|
"insufficient funds: need {need} lovelace, have {acc}"
|
|
)));
|
|
}
|
|
let total_in: u64 = selected.iter().map(|u| u.lovelace).sum();
|
|
|
|
// Aggregate input assets (other than the one being minted) so
|
|
// we can preserve them on the change output.
|
|
let mut input_assets: std::collections::BTreeMap<String, u64> = Default::default();
|
|
for u in &selected {
|
|
for (k, v) in &u.assets {
|
|
let entry = input_assets.entry(k.clone()).or_insert(0);
|
|
*entry = entry.saturating_add(*v);
|
|
}
|
|
}
|
|
|
|
let policy_id_hex = hash_to_hex(&policy_id);
|
|
let mint_asset_key = format!("{policy_id_hex}{asset_name_hex}");
|
|
|
|
// Adjust input_assets for burns (negative mint).
|
|
if mint_quantity < 0 {
|
|
let burn_qty = (-mint_quantity) as u64;
|
|
let entry = input_assets.entry(mint_asset_key.clone()).or_insert(0);
|
|
if *entry < burn_qty {
|
|
return Err(WalletError::Derivation(format!(
|
|
"insufficient {mint_asset_key} to burn: have {entry}, need {burn_qty}"
|
|
)));
|
|
}
|
|
*entry -= burn_qty;
|
|
}
|
|
|
|
// Build pass 1 with placeholder fee.
|
|
let dest_assets: std::collections::BTreeMap<String, u64> = if mint_is_positive {
|
|
let mut m = std::collections::BTreeMap::new();
|
|
m.insert(mint_asset_key.clone(), mint_quantity as u64);
|
|
m
|
|
} else {
|
|
Default::default()
|
|
};
|
|
|
|
let change_pass1 = total_in
|
|
.checked_sub(dest_lovelace)
|
|
.and_then(|x| x.checked_sub(fee_pass1))
|
|
.ok_or_else(|| WalletError::Derivation("pass1: insufficient lovelace".into()))?;
|
|
|
|
let staging1 = build_mint_staging(
|
|
&selected,
|
|
&dest_addr,
|
|
dest_lovelace,
|
|
&dest_assets,
|
|
&change_addr,
|
|
change_pass1,
|
|
&input_assets,
|
|
fee_pass1,
|
|
network_id,
|
|
policy_id,
|
|
&asset_name_bytes,
|
|
mint_quantity,
|
|
&script_cbor,
|
|
payment_pkh,
|
|
aux_data_cbor,
|
|
)?;
|
|
let unsigned = staging1
|
|
.build_conway_raw()
|
|
.map_err(|e| WalletError::Derivation(format!("conway build: {e}")))?
|
|
.tx_bytes
|
|
.0;
|
|
let est_signed = (unsigned.len() as u64) + WITNESS_OVERHEAD_BYTES;
|
|
let real_fee = params.min_fee_for_size(est_signed);
|
|
|
|
// Token-bearing change output must hold ≥ min_utxo lovelace.
|
|
let token_change = !input_assets.is_empty();
|
|
let (final_fee, final_change) = match total_in.checked_sub(dest_lovelace + real_fee) {
|
|
Some(c) if c >= params.min_utxo_lovelace || token_change => {
|
|
if token_change && c < params.min_utxo_lovelace {
|
|
return Err(WalletError::Derivation(format!(
|
|
"insufficient ADA for token-bearing change: change={c}, min={}",
|
|
params.min_utxo_lovelace
|
|
)));
|
|
}
|
|
(real_fee, c)
|
|
}
|
|
Some(c) => (real_fee + c, 0),
|
|
None => {
|
|
return Err(WalletError::Derivation(format!(
|
|
"insufficient funds for fee: total_in={total_in} dest={dest_lovelace} fee={real_fee}"
|
|
)))
|
|
}
|
|
};
|
|
|
|
let staging2 = build_mint_staging(
|
|
&selected,
|
|
&dest_addr,
|
|
dest_lovelace,
|
|
&dest_assets,
|
|
&change_addr,
|
|
final_change,
|
|
&input_assets,
|
|
final_fee,
|
|
network_id,
|
|
policy_id,
|
|
&asset_name_bytes,
|
|
mint_quantity,
|
|
&script_cbor,
|
|
payment_pkh,
|
|
aux_data_cbor,
|
|
)?;
|
|
let built = staging2
|
|
.build_conway_raw()
|
|
.map_err(|e| WalletError::Derivation(format!("conway build: {e}")))?;
|
|
|
|
let summary = PaymentSummary {
|
|
tx_hash: hash_to_hex_32(&built.tx_hash.0),
|
|
network,
|
|
from_address: change_address_bech32.to_string(),
|
|
to_address: dest_address_bech32.to_string(),
|
|
send_lovelace: dest_lovelace,
|
|
fee_lovelace: final_fee,
|
|
change_lovelace: final_change,
|
|
num_inputs: selected.len(),
|
|
send_assets: vec![crate::tx::AssetSpec {
|
|
policy_id_hex: policy_id_hex.clone(),
|
|
asset_name_hex: asset_name_hex.to_string(),
|
|
quantity: mint_quantity.unsigned_abs(),
|
|
}],
|
|
change_assets: input_assets
|
|
.iter()
|
|
.map(|(k, v)| crate::tx::AssetSpec {
|
|
policy_id_hex: k[..56].to_string(),
|
|
asset_name_hex: k[56..].to_string(),
|
|
quantity: *v,
|
|
})
|
|
.collect(),
|
|
};
|
|
|
|
Ok((built, summary))
|
|
}
|
|
|
|
fn hash_to_hex_32(h: &[u8; 32]) -> String {
|
|
let mut s = String::with_capacity(64);
|
|
for b in h {
|
|
s.push_str(&format!("{:02x}", b));
|
|
}
|
|
s
|
|
}
|
|
|
|
#[allow(clippy::too_many_arguments)]
|
|
fn build_mint_staging(
|
|
inputs: &[InputUtxo],
|
|
dest_addr: &pallas_addresses::Address,
|
|
dest_lovelace: u64,
|
|
dest_assets: &std::collections::BTreeMap<String, u64>,
|
|
change_addr: &pallas_addresses::Address,
|
|
change_lovelace: u64,
|
|
change_assets: &std::collections::BTreeMap<String, u64>,
|
|
fee: u64,
|
|
network_id: u8,
|
|
policy_id: Hash<28>,
|
|
asset_name_bytes: &[u8],
|
|
mint_quantity: i64,
|
|
script_cbor: &[u8],
|
|
payment_pkh: Hash<28>,
|
|
aux_data_cbor: Option<&[u8]>,
|
|
) -> Result<StagingTransaction, WalletError> {
|
|
let mut staging = StagingTransaction::new();
|
|
for u in inputs {
|
|
let h = parse_tx_hash(&u.tx_hash_hex)?;
|
|
staging = staging.input(Input::new(h, u.output_index as u64));
|
|
}
|
|
|
|
// Destination output (with mint asset if positive).
|
|
let mut dest_out = Output::new(dest_addr.clone(), dest_lovelace);
|
|
for (k, q) in dest_assets {
|
|
if *q == 0 {
|
|
continue;
|
|
}
|
|
// dest_assets keys are policy||name hex; decode parts.
|
|
if k.len() < 56 {
|
|
return Err(WalletError::Derivation(
|
|
"dest asset key shorter than 56 chars".into(),
|
|
));
|
|
}
|
|
let pol_hex = &k[..56];
|
|
let name_hex = &k[56..];
|
|
let p = parse_pkh(pol_hex)?; // 28-byte hash, same parser works
|
|
let n = parse_asset_name(name_hex)?;
|
|
dest_out = dest_out
|
|
.add_asset(p, n, *q)
|
|
.map_err(|e| WalletError::Derivation(format!("dest add_asset: {e}")))?;
|
|
}
|
|
staging = staging.output(dest_out);
|
|
|
|
// Change output (with leftover input assets, if any).
|
|
let nonzero_change: std::collections::BTreeMap<String, u64> = change_assets
|
|
.iter()
|
|
.filter(|(_, q)| **q > 0)
|
|
.map(|(k, v)| (k.clone(), *v))
|
|
.collect();
|
|
if change_lovelace > 0 || !nonzero_change.is_empty() {
|
|
let mut change_out = Output::new(change_addr.clone(), change_lovelace);
|
|
for (k, q) in &nonzero_change {
|
|
if k.len() < 56 {
|
|
return Err(WalletError::Derivation(
|
|
"change asset key shorter than 56 chars".into(),
|
|
));
|
|
}
|
|
let pol_hex = &k[..56];
|
|
let name_hex = &k[56..];
|
|
let p = parse_pkh(pol_hex)?;
|
|
let n = parse_asset_name(name_hex)?;
|
|
change_out = change_out
|
|
.add_asset(p, n, *q)
|
|
.map_err(|e| WalletError::Derivation(format!("change add_asset: {e}")))?;
|
|
}
|
|
staging = staging.output(change_out);
|
|
}
|
|
|
|
staging = staging
|
|
.mint_asset(policy_id, asset_name_bytes.to_vec(), mint_quantity)
|
|
.map_err(|e| WalletError::Derivation(format!("mint_asset: {e}")))?
|
|
.script(ScriptKind::Native, script_cbor.to_vec())
|
|
.disclosed_signer(payment_pkh)
|
|
.fee(fee)
|
|
.network_id(network_id);
|
|
|
|
if let Some(aux) = aux_data_cbor {
|
|
staging = staging.auxiliary_data(aux.to_vec());
|
|
}
|
|
|
|
Ok(staging)
|
|
}
|
|
|
|
/// Build + sign a mint TX (no metadata).
|
|
///
|
|
/// `mint_quantity > 0` mints, `mint_quantity < 0` burns. Burning
|
|
/// requires the wallet's UTXOs hold ≥ |quantity| of the asset.
|
|
///
|
|
/// The destination output gets `dest_lovelace` + the freshly-minted
|
|
/// supply (or just `dest_lovelace` for a burn). Change output picks
|
|
/// up any leftover ADA + non-burn assets from the wallet.
|
|
#[allow(clippy::too_many_arguments)]
|
|
pub fn build_signed_mint(
|
|
payment_key: &PaymentKey,
|
|
network: Network,
|
|
available_utxos: &[InputUtxo],
|
|
change_address_bech32: &str,
|
|
dest_address_bech32: &str,
|
|
dest_lovelace: u64,
|
|
policy: &PolicySpec,
|
|
asset_name_hex: &str,
|
|
mint_quantity: i64,
|
|
params: &ProtocolParams,
|
|
) -> Result<Vec<u8>, WalletError> {
|
|
build_signed_mint_with_metadata(
|
|
payment_key,
|
|
network,
|
|
available_utxos,
|
|
change_address_bech32,
|
|
dest_address_bech32,
|
|
dest_lovelace,
|
|
policy,
|
|
asset_name_hex,
|
|
mint_quantity,
|
|
None,
|
|
params,
|
|
)
|
|
}
|
|
|
|
/// Build + sign a mint TX with optional CIP-25 metadata.
|
|
///
|
|
/// If `cip25_metadata` is `Some(json_value)`, the JSON object's
|
|
/// fields become the asset's CIP-25 v2 attributes (`name`, `image`,
|
|
/// `description`, etc.) and ride along in the tx's auxiliary data.
|
|
/// Wallets and explorers will then show the asset with its name +
|
|
/// image instead of as `<asset1xyz...>`.
|
|
#[allow(clippy::too_many_arguments)]
|
|
pub fn build_signed_mint_with_metadata(
|
|
payment_key: &PaymentKey,
|
|
network: Network,
|
|
available_utxos: &[InputUtxo],
|
|
change_address_bech32: &str,
|
|
dest_address_bech32: &str,
|
|
dest_lovelace: u64,
|
|
policy: &PolicySpec,
|
|
asset_name_hex: &str,
|
|
mint_quantity: i64,
|
|
cip25_metadata: Option<&serde_json::Value>,
|
|
params: &ProtocolParams,
|
|
) -> Result<Vec<u8>, WalletError> {
|
|
let private = payment_key_to_private(payment_key)?;
|
|
let payment_pkh = payment_key.public_key_hash();
|
|
let aux_bytes = match cip25_metadata {
|
|
Some(meta) => {
|
|
let policy_id = policy.policy_id()?;
|
|
let pol_hex = hash_to_hex(&policy_id);
|
|
Some(crate::metadata::build_cip25_aux_data(
|
|
&pol_hex,
|
|
asset_name_hex,
|
|
meta,
|
|
)?)
|
|
}
|
|
None => None,
|
|
};
|
|
let (built, _summary) = prepare_mint(
|
|
network,
|
|
available_utxos,
|
|
change_address_bech32,
|
|
dest_address_bech32,
|
|
dest_lovelace,
|
|
policy,
|
|
asset_name_hex,
|
|
mint_quantity,
|
|
payment_pkh,
|
|
aux_bytes.as_deref(),
|
|
params,
|
|
)?;
|
|
let signed = built
|
|
.sign(private)
|
|
.map_err(|e| WalletError::Derivation(format!("sign: {e}")))?;
|
|
Ok(signed.tx_bytes.0)
|
|
}
|
|
|
|
/// Build a mint TX without signing — for cold-sign flows. Returns
|
|
/// the unsigned CBOR + summary.
|
|
#[allow(clippy::too_many_arguments)]
|
|
pub fn build_unsigned_mint(
|
|
network: Network,
|
|
payment_pkh_hex: &str,
|
|
available_utxos: &[InputUtxo],
|
|
change_address_bech32: &str,
|
|
dest_address_bech32: &str,
|
|
dest_lovelace: u64,
|
|
policy: &PolicySpec,
|
|
asset_name_hex: &str,
|
|
mint_quantity: i64,
|
|
cip25_metadata: Option<&serde_json::Value>,
|
|
params: &ProtocolParams,
|
|
) -> Result<UnsignedPayment, WalletError> {
|
|
let payment_pkh = parse_pkh(payment_pkh_hex)?;
|
|
let aux_bytes = match cip25_metadata {
|
|
Some(meta) => {
|
|
let policy_id = policy.policy_id()?;
|
|
let pol_hex = hash_to_hex(&policy_id);
|
|
Some(crate::metadata::build_cip25_aux_data(
|
|
&pol_hex,
|
|
asset_name_hex,
|
|
meta,
|
|
)?)
|
|
}
|
|
None => None,
|
|
};
|
|
let (built, summary) = prepare_mint(
|
|
network,
|
|
available_utxos,
|
|
change_address_bech32,
|
|
dest_address_bech32,
|
|
dest_lovelace,
|
|
policy,
|
|
asset_name_hex,
|
|
mint_quantity,
|
|
payment_pkh,
|
|
aux_bytes.as_deref(),
|
|
params,
|
|
)?;
|
|
let mut cbor_hex = String::with_capacity(built.tx_bytes.0.len() * 2);
|
|
for b in &built.tx_bytes.0 {
|
|
cbor_hex.push_str(&format!("{:02x}", b));
|
|
}
|
|
Ok(UnsignedPayment { cbor_hex, summary })
|
|
}
|
|
|
|
/// Build + sign a CIP-68 NFT mint.
|
|
///
|
|
/// Issues two assets simultaneously under a single policy:
|
|
/// - **Reference NFT** (label `100`, prefix `0x000643b0`) — quantity 1,
|
|
/// lands at `ref_address` with the metadata in its inline datum.
|
|
/// - **User NFT** (label `222`, prefix `0x000de140`) — quantity 1, lands
|
|
/// at `user_address` for the end recipient.
|
|
///
|
|
/// `name_body` is the raw asset-name body (NOT hex). The function
|
|
/// prefixes both 100 and 222 onto it. Combined name length must be
|
|
/// ≤32 bytes (so `name_body.len() ≤ 28`).
|
|
///
|
|
/// `metadata` is the JSON object of CIP-68 attributes (`name`,
|
|
/// `image`, `description`, etc.). It's wrapped in the canonical
|
|
/// `Constr 0 [meta_map, version=2, Constr 0 []]` Plutus Data shape.
|
|
///
|
|
/// For mutable NFTs, set `ref_address == change_address` (the
|
|
/// wallet's own address); the wallet's payment key can later spend
|
|
/// the ref NFT UTXO and re-create it with updated metadata.
|
|
///
|
|
/// For immutable NFTs, lock `ref_address` at an always-fails script
|
|
/// — that's a Phase 4 concern; today this function trusts whatever
|
|
/// address you pass.
|
|
#[allow(clippy::too_many_arguments)]
|
|
pub fn build_signed_cip68_nft_mint(
|
|
payment_key: &PaymentKey,
|
|
network: Network,
|
|
available_utxos: &[InputUtxo],
|
|
change_address_bech32: &str,
|
|
user_address_bech32: &str,
|
|
user_lovelace: u64,
|
|
ref_address_bech32: &str,
|
|
ref_lovelace: u64,
|
|
name_body: &[u8],
|
|
metadata: &serde_json::Value,
|
|
policy: &PolicySpec,
|
|
params: &ProtocolParams,
|
|
) -> Result<Vec<u8>, WalletError> {
|
|
let private = payment_key_to_private(payment_key)?;
|
|
let payment_pkh = payment_key.public_key_hash();
|
|
let user_addr = parse_address(user_address_bech32)?;
|
|
let ref_addr = parse_address(ref_address_bech32)?;
|
|
let change_addr = parse_address(change_address_bech32)?;
|
|
let network_id = network_id_for(network);
|
|
|
|
let policy_id = policy.policy_id()?;
|
|
let script_cbor = policy.to_cbor()?;
|
|
|
|
let ref_name = ref_nft_asset_name(name_body)?;
|
|
let user_name = user_nft_asset_name(name_body)?;
|
|
|
|
let datum_cbor = build_cip68_datum_cbor(metadata)?;
|
|
|
|
// Lovelace need: user output + ref output + fee + min_change.
|
|
let fee_pass1: u64 = 1_000_000;
|
|
let need = user_lovelace
|
|
.checked_add(ref_lovelace)
|
|
.and_then(|x| x.checked_add(fee_pass1))
|
|
.and_then(|x| x.checked_add(params.min_utxo_lovelace))
|
|
.ok_or_else(|| WalletError::Derivation("amount overflow".into()))?;
|
|
|
|
let mut sorted: Vec<InputUtxo> = available_utxos.to_vec();
|
|
sorted.sort_by_key(|u| std::cmp::Reverse(u.lovelace));
|
|
let mut acc: u64 = 0;
|
|
let mut selected: Vec<InputUtxo> = Vec::new();
|
|
for u in sorted {
|
|
acc = acc.saturating_add(u.lovelace);
|
|
selected.push(u);
|
|
if acc >= need {
|
|
break;
|
|
}
|
|
}
|
|
if acc < need {
|
|
return Err(WalletError::Derivation(format!(
|
|
"insufficient funds for cip68 mint: need {need} lovelace, have {acc}"
|
|
)));
|
|
}
|
|
let total_in: u64 = selected.iter().map(|u| u.lovelace).sum();
|
|
|
|
// Aggregate input assets to preserve them on change.
|
|
let mut input_assets: std::collections::BTreeMap<String, u64> = Default::default();
|
|
for u in &selected {
|
|
for (k, v) in &u.assets {
|
|
let entry = input_assets.entry(k.clone()).or_insert(0);
|
|
*entry = entry.saturating_add(*v);
|
|
}
|
|
}
|
|
|
|
let build_with_fee = |fee: u64,
|
|
change_lovelace: u64|
|
|
-> Result<StagingTransaction, WalletError> {
|
|
let mut staging = StagingTransaction::new();
|
|
for u in &selected {
|
|
let h = parse_tx_hash(&u.tx_hash_hex)?;
|
|
staging = staging.input(Input::new(h, u.output_index as u64));
|
|
}
|
|
|
|
// Output 1: ref NFT @ ref_address, with inline datum.
|
|
let ref_out = Output::new(ref_addr.clone(), ref_lovelace)
|
|
.add_asset(policy_id, ref_name.clone(), 1)
|
|
.map_err(|e| WalletError::Derivation(format!("ref add_asset: {e}")))?
|
|
.set_inline_datum(datum_cbor.clone());
|
|
staging = staging.output(ref_out);
|
|
|
|
// Output 2: user NFT @ user_address.
|
|
let user_out = Output::new(user_addr.clone(), user_lovelace)
|
|
.add_asset(policy_id, user_name.clone(), 1)
|
|
.map_err(|e| WalletError::Derivation(format!("user add_asset: {e}")))?;
|
|
staging = staging.output(user_out);
|
|
|
|
// Output 3 (optional): change @ wallet, with leftover input assets.
|
|
let nonzero_change_assets: std::collections::BTreeMap<String, u64> = input_assets
|
|
.iter()
|
|
.filter(|(_, q)| **q > 0)
|
|
.map(|(k, v)| (k.clone(), *v))
|
|
.collect();
|
|
if change_lovelace > 0 || !nonzero_change_assets.is_empty() {
|
|
let mut change_out = Output::new(change_addr.clone(), change_lovelace);
|
|
for (k, q) in &nonzero_change_assets {
|
|
if k.len() < 56 {
|
|
return Err(WalletError::Derivation(
|
|
"change asset key shorter than 56 chars".into(),
|
|
));
|
|
}
|
|
let p = parse_pkh(&k[..56])?;
|
|
let n = parse_asset_name(&k[56..])?;
|
|
change_out = change_out
|
|
.add_asset(p, n, *q)
|
|
.map_err(|e| WalletError::Derivation(format!("change add_asset: {e}")))?;
|
|
}
|
|
staging = staging.output(change_out);
|
|
}
|
|
|
|
staging = staging
|
|
.mint_asset(policy_id, ref_name.clone(), 1)
|
|
.map_err(|e| WalletError::Derivation(format!("mint ref: {e}")))?
|
|
.mint_asset(policy_id, user_name.clone(), 1)
|
|
.map_err(|e| WalletError::Derivation(format!("mint user: {e}")))?
|
|
.script(ScriptKind::Native, script_cbor.clone())
|
|
.disclosed_signer(payment_pkh)
|
|
.fee(fee)
|
|
.network_id(network_id);
|
|
|
|
Ok(staging)
|
|
};
|
|
|
|
// Pass 1 — placeholder fee, measure unsigned size, recompute.
|
|
let change_pass1 = total_in
|
|
.checked_sub(user_lovelace + ref_lovelace + fee_pass1)
|
|
.ok_or_else(|| {
|
|
WalletError::Derivation("pass1: insufficient lovelace for cip68 outputs".into())
|
|
})?;
|
|
let staging1 = build_with_fee(fee_pass1, change_pass1)?;
|
|
let unsigned = staging1
|
|
.build_conway_raw()
|
|
.map_err(|e| WalletError::Derivation(format!("conway build (pass1): {e}")))?
|
|
.tx_bytes
|
|
.0;
|
|
let est_signed = (unsigned.len() as u64) + WITNESS_OVERHEAD_BYTES;
|
|
let real_fee = params.min_fee_for_size(est_signed);
|
|
|
|
let token_change = !input_assets.is_empty();
|
|
let (final_fee, final_change) = match total_in
|
|
.checked_sub(user_lovelace + ref_lovelace + real_fee)
|
|
{
|
|
Some(c) if c >= params.min_utxo_lovelace || token_change => {
|
|
if token_change && c < params.min_utxo_lovelace {
|
|
return Err(WalletError::Derivation(format!(
|
|
"insufficient ADA for token-bearing change: change={c}, min={}",
|
|
params.min_utxo_lovelace
|
|
)));
|
|
}
|
|
(real_fee, c)
|
|
}
|
|
Some(c) => (real_fee + c, 0),
|
|
None => {
|
|
return Err(WalletError::Derivation(format!(
|
|
"insufficient funds for fee: total_in={total_in} fee={real_fee}"
|
|
)))
|
|
}
|
|
};
|
|
|
|
let staging2 = build_with_fee(final_fee, final_change)?;
|
|
let built = staging2
|
|
.build_conway_raw()
|
|
.map_err(|e| WalletError::Derivation(format!("conway build (final): {e}")))?;
|
|
let signed = built
|
|
.sign(private)
|
|
.map_err(|e| WalletError::Derivation(format!("sign: {e}")))?;
|
|
Ok(signed.tx_bytes.0)
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use crate::Mnemonic;
|
|
|
|
const ABANDON_ART: &str = concat!(
|
|
"abandon abandon abandon abandon abandon abandon ",
|
|
"abandon abandon abandon abandon abandon abandon ",
|
|
"abandon abandon abandon abandon abandon abandon ",
|
|
"abandon abandon abandon abandon abandon art",
|
|
);
|
|
|
|
fn payment_from_canonical() -> PaymentKey {
|
|
let root = Mnemonic::from_phrase(ABANDON_ART)
|
|
.unwrap()
|
|
.into_root_key()
|
|
.unwrap();
|
|
crate::derive::derive_payment_key(&root, 0, 0)
|
|
}
|
|
|
|
fn change_address(network: Network) -> String {
|
|
let root = Mnemonic::from_phrase(ABANDON_ART)
|
|
.unwrap()
|
|
.into_root_key()
|
|
.unwrap();
|
|
crate::derive_base_address(&root, network, 0, 0).unwrap()
|
|
}
|
|
|
|
fn to_address_preprod() -> String {
|
|
let root = Mnemonic::from_phrase(ABANDON_ART)
|
|
.unwrap()
|
|
.into_root_key()
|
|
.unwrap();
|
|
crate::derive_base_address(&root, Network::Preprod, 0, 1).unwrap()
|
|
}
|
|
|
|
#[test]
|
|
fn single_sig_policy_round_trips() {
|
|
let payment = payment_from_canonical();
|
|
let policy = PolicySpec::single_sig(&payment);
|
|
let script = policy.to_native_script().unwrap();
|
|
match script {
|
|
NativeScript::ScriptPubkey(_) => {}
|
|
other => panic!("expected ScriptPubkey, got {other:?}"),
|
|
}
|
|
let id = policy.policy_id().unwrap();
|
|
assert_eq!(id.as_ref().len(), 28);
|
|
}
|
|
|
|
#[test]
|
|
fn timelock_policy_uses_script_all() {
|
|
let payment = payment_from_canonical();
|
|
let policy = PolicySpec::single_sig_timelock(&payment, 100_000_000);
|
|
let script = policy.to_native_script().unwrap();
|
|
match script {
|
|
NativeScript::ScriptAll(subs) => {
|
|
assert_eq!(subs.len(), 2);
|
|
assert!(matches!(subs[0], NativeScript::ScriptPubkey(_)));
|
|
assert!(matches!(subs[1], NativeScript::InvalidHereafter(_)));
|
|
}
|
|
other => panic!("expected ScriptAll, got {other:?}"),
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn nofk_policy_serializes() {
|
|
let payment = payment_from_canonical();
|
|
let pkh1 = hash_to_hex(&payment.public_key_hash());
|
|
let policy = PolicySpec::NofK {
|
|
n: 2,
|
|
signer_pkhs_hex: vec![pkh1.clone(), pkh1.clone(), pkh1],
|
|
};
|
|
let id = policy.policy_id().unwrap();
|
|
assert_eq!(id.as_ref().len(), 28);
|
|
}
|
|
|
|
#[test]
|
|
fn policy_id_is_deterministic() {
|
|
let payment = payment_from_canonical();
|
|
let p1 = PolicySpec::single_sig(&payment);
|
|
let p2 = PolicySpec::single_sig(&payment);
|
|
assert_eq!(p1.policy_id().unwrap(), p2.policy_id().unwrap());
|
|
}
|
|
|
|
#[test]
|
|
fn timelock_policy_id_changes_with_slot() {
|
|
let payment = payment_from_canonical();
|
|
let p1 = PolicySpec::single_sig_timelock(&payment, 100);
|
|
let p2 = PolicySpec::single_sig_timelock(&payment, 200);
|
|
assert_ne!(p1.policy_id().unwrap(), p2.policy_id().unwrap());
|
|
}
|
|
|
|
#[test]
|
|
fn build_signed_mint_produces_cbor() {
|
|
let payment = payment_from_canonical();
|
|
let change = change_address(Network::Preprod);
|
|
let policy = PolicySpec::single_sig(&payment);
|
|
let utxos = vec![InputUtxo {
|
|
tx_hash_hex: "deadbeef".repeat(8),
|
|
output_index: 0,
|
|
lovelace: 100_000_000,
|
|
assets: Default::default(),
|
|
}];
|
|
let cbor = build_signed_mint(
|
|
&payment,
|
|
Network::Preprod,
|
|
&utxos,
|
|
&change,
|
|
&to_address_preprod(),
|
|
2_000_000,
|
|
&policy,
|
|
"414c44414252415f54455354", // "ALDABRA_TEST" hex
|
|
1,
|
|
&ProtocolParams::default(),
|
|
)
|
|
.expect("mint builds + signs");
|
|
assert!(cbor.len() > 200, "mint cbor too short: {} bytes", cbor.len());
|
|
}
|
|
|
|
#[test]
|
|
fn build_signed_mint_rejects_burn_without_holdings() {
|
|
let payment = payment_from_canonical();
|
|
let change = change_address(Network::Preprod);
|
|
let policy = PolicySpec::single_sig(&payment);
|
|
let utxos = vec![InputUtxo {
|
|
tx_hash_hex: "deadbeef".repeat(8),
|
|
output_index: 0,
|
|
lovelace: 100_000_000,
|
|
assets: Default::default(), // no holdings of the burn asset
|
|
}];
|
|
let err = build_signed_mint(
|
|
&payment,
|
|
Network::Preprod,
|
|
&utxos,
|
|
&change,
|
|
&to_address_preprod(),
|
|
2_000_000,
|
|
&policy,
|
|
"414c44414252415f54455354",
|
|
-1,
|
|
&ProtocolParams::default(),
|
|
)
|
|
.expect_err("burn without holdings should fail");
|
|
match err {
|
|
WalletError::Derivation(m) => assert!(m.contains("insufficient")),
|
|
other => panic!("expected Derivation, got {other:?}"),
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn parse_pkh_validates_length() {
|
|
assert!(parse_pkh("ab").is_err());
|
|
assert!(parse_pkh(&"ee".repeat(28)).is_ok());
|
|
}
|
|
|
|
#[test]
|
|
fn build_signed_cip68_nft_mint_produces_two_assets_and_inline_datum() {
|
|
use pallas_primitives::Fragment;
|
|
let payment = payment_from_canonical();
|
|
let change = change_address(Network::Preprod);
|
|
let policy = PolicySpec::single_sig(&payment);
|
|
let utxos = vec![InputUtxo {
|
|
tx_hash_hex: "deadbeef".repeat(8),
|
|
output_index: 0,
|
|
lovelace: 200_000_000,
|
|
assets: Default::default(),
|
|
}];
|
|
let metadata = serde_json::json!({
|
|
"name": "ALDABRA",
|
|
"image": "ipfs://QmAldabraTortoise",
|
|
"description": "First Sulkta CIP-68 NFT",
|
|
});
|
|
let cbor = build_signed_cip68_nft_mint(
|
|
&payment,
|
|
Network::Preprod,
|
|
&utxos,
|
|
&change,
|
|
&to_address_preprod(),
|
|
2_000_000,
|
|
&change, // ref NFT lives at wallet's own addr (mutable)
|
|
2_000_000,
|
|
b"ALDABRA",
|
|
&metadata,
|
|
&policy,
|
|
&ProtocolParams::default(),
|
|
)
|
|
.expect("cip68 mint builds + signs");
|
|
|
|
let tx = pallas_primitives::conway::Tx::decode_fragment(&cbor)
|
|
.expect("decode signed cip68 mint cbor");
|
|
|
|
// Mint field should hold ONE policy with TWO asset entries (100 + 222).
|
|
let mint = tx.transaction_body.mint.expect("mint set");
|
|
let mint_pairs: Vec<_> = mint.to_vec();
|
|
assert_eq!(mint_pairs.len(), 1, "one policy");
|
|
let (_pid, assets) = &mint_pairs[0];
|
|
let asset_pairs: Vec<_> = assets.clone().to_vec();
|
|
assert_eq!(asset_pairs.len(), 2, "ref + user assets minted");
|
|
|
|
// Outputs must include the ref NFT output WITH inline_datum.
|
|
let outputs: Vec<_> = tx.transaction_body.outputs.to_vec();
|
|
// 3 outputs: ref, user, change.
|
|
assert_eq!(outputs.len(), 3, "expected ref + user + change outputs");
|
|
|
|
let mut found_inline_datum = false;
|
|
for o in outputs {
|
|
let pallas_primitives::conway::PseudoTransactionOutput::PostAlonzo(po) = o else {
|
|
continue;
|
|
};
|
|
if let Some(pallas_primitives::conway::PseudoDatumOption::Data(_)) = po.datum_option {
|
|
found_inline_datum = true;
|
|
break;
|
|
}
|
|
}
|
|
assert!(found_inline_datum, "ref NFT output must carry an inline datum");
|
|
}
|
|
|
|
#[test]
|
|
fn build_signed_mint_with_metadata_produces_aux_hash() {
|
|
use pallas_primitives::Fragment;
|
|
let payment = payment_from_canonical();
|
|
let change = change_address(Network::Preprod);
|
|
let policy = PolicySpec::single_sig(&payment);
|
|
let utxos = vec![InputUtxo {
|
|
tx_hash_hex: "deadbeef".repeat(8),
|
|
output_index: 0,
|
|
lovelace: 100_000_000,
|
|
assets: Default::default(),
|
|
}];
|
|
let metadata = serde_json::json!({
|
|
"name": "ALDABRA_TEST",
|
|
"image": "ipfs://QmTestHashGoesHere",
|
|
"description": "Sulkta test mint",
|
|
});
|
|
let cbor = build_signed_mint_with_metadata(
|
|
&payment,
|
|
Network::Preprod,
|
|
&utxos,
|
|
&change,
|
|
&to_address_preprod(),
|
|
2_000_000,
|
|
&policy,
|
|
"414c44414252415f54455354", // "ALDABRA_TEST"
|
|
1,
|
|
Some(&metadata),
|
|
&ProtocolParams::default(),
|
|
)
|
|
.expect("metadata mint builds + signs");
|
|
|
|
// Decode the resulting tx and confirm:
|
|
// 1. aux_data is present
|
|
// 2. body.auxiliary_data_hash is populated
|
|
let tx = pallas_primitives::conway::Tx::decode_fragment(&cbor)
|
|
.expect("decode signed mint cbor");
|
|
assert!(
|
|
tx.transaction_body.auxiliary_data_hash.is_some(),
|
|
"aux_data_hash must be set when metadata is attached"
|
|
);
|
|
match tx.auxiliary_data {
|
|
pallas_codec::utils::Nullable::Some(_) => {}
|
|
other => panic!("expected aux data, got {other:?}"),
|
|
}
|
|
}
|
|
}
|