aldabra/crates/aldabra-core/src/mint.rs
Sulkta 6ed378a486 audit-3 (code cleanup): zero clippy warnings, zero build warnings
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.
2026-05-04 18:40:35 -07:00

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:?}"),
}
}
}