feat(plutus): plutus_mint module — Plutus-policy mint with custom output

Adds aldabra-core::plutus_mint with build_signed_plutus_mint and
build_unsigned_plutus_mint. Designed for Agora-style DAO bringup
where every "mint a single ST token under a Plutus policy and
deposit it at a script address with inline datum" tx shares the
same shape (governor / stake / proposal bootstrap).

PlutusMintArgs takes:
- required_inputs: UTxOs that MUST be spent (e.g. gstOutRef the
  GST policy is parameterized on)
- policy_cbor + policy_version + redeemer + ex_units
- mint_assets: list of (asset_name_hex, qty) under this policy
- dest_address + dest_lovelace + dest_extra_assets to forward
  + optional inline datum

Same collateral/funding pattern as plutus.rs::build_signed_plutus_spend
(smallest ADA-only ≥ 5 ADA for collateral, separate funding picks
to cover dest + fee + min_change). PlutusV3 cost-model wired into
language_view per the existing PLUTUS-4 fix.

3 unit tests cover empty-policy / empty-mint / required-input-not-
in-available rejections + the governor-bootstrap shape produces
valid Conway CBOR.

This is Phase 2 of the Track B-fast preprod DAO bringup. Together
with the kayos/wallet-ref-script Phase 1 commits (b9124ee + a65ab78)
it gives aldabra everything needed to deploy 11 Agora script ref
UTxOs + bootstrap a governor + bootstrap stakes on preprod.
This commit is contained in:
Kayos 2026-05-07 06:32:59 -07:00
parent a65ab7803e
commit 86bc4e45cd
2 changed files with 919 additions and 0 deletions

View file

@ -43,6 +43,7 @@ pub mod metadata;
pub mod mint;
pub mod plutus;
pub mod plutus_cost_models;
pub mod plutus_mint;
pub mod sign;
pub mod stake;
pub mod tx;
@ -62,6 +63,10 @@ pub use plutus::{
build_signed_plutus_spend, looks_like_script_address, PlutusExUnits, PlutusInput,
PlutusVersion, DEFAULT_EX_UNITS, MIN_COLLATERAL_LOVELACE,
};
pub use plutus_mint::{
build_signed_plutus_mint, build_unsigned_plutus_mint, ExtraDestAsset, PlutusMintArgs,
PlutusMintAsset,
};
pub use stake::{build_signed_stake_delegation, parse_pool_id, STAKE_KEY_DEPOSIT_LOVELACE};
pub use governance::{
build_signed_drep_deregistration, build_signed_drep_registration,

View file

@ -0,0 +1,914 @@
//! Plutus-policy mint with custom output construction.
//!
//! Distinct from `mint.rs` which only handles native-script policies.
//! Used for any DApp that ships Plutus-compiled minting policies —
//! Agora's GST/StakeST/ProposalST/GAT, Liqwid's lqADA/iAsset, etc.
//!
//! ## Tx shape
//!
//! - **Required inputs**: caller-supplied list of UTxOs that MUST be
//! spent (e.g. Agora's GST policy is parameterized on a specific
//! UTxO ref; the policy only authorizes a mint when that UTxO is
//! consumed in the same tx).
//! - **Funding inputs**: chosen automatically from `available_utxos`.
//! At least one ADA-only UTxO ≥ 5 ADA must remain available for
//! collateral — same constraint as `plutus.rs::build_signed_plutus_spend`.
//! - **Collateral input**: smallest ADA-only UTxO ≥ 5 ADA, distinct
//! from required + funding inputs. Only consumed if the script
//! fails on-chain.
//! - **Mint**: caller-supplied `(asset_name_hex, quantity)` list under
//! the supplied policy (Plutus V1/V2/V3).
//! - **Recipient output**: address + lovelace + minted assets +
//! any caller-supplied extra assets to forward (e.g. tTRP gov tokens
//! on a stake bootstrap) + optional inline datum.
//! - **Change output**: leftover ADA + leftover input assets (other
//! than what was forwarded to the recipient).
//!
//! ## Why a single tool covers governor + stake bootstrap
//!
//! Agora's deployment pattern is the same shape for every "first-time
//! mint of a single ST token under a Plutus policy" tx:
//! - Governor bootstrap: mint 1 GST → governor_addr + GovernorDatum
//! - Stake bootstrap: mint 1 StakeST → stakes_addr + tTRP + StakeDatum
//! - Proposal create: mint 1 ProposalST → proposal_addr + ProposalDatum
//!
//! All three share the structure; the only differences are the
//! particular policy CBOR + redeemer + datum + extra assets to forward.
use pallas_addresses::Address as PallasAddress;
use pallas_crypto::hash::Hash;
use pallas_crypto::key::ed25519::SecretKeyExtended;
use pallas_txbuilder::{
BuildConway, BuiltTransaction, Input, Output, ScriptKind, StagingTransaction,
};
use pallas_wallet::PrivateKey;
use crate::plutus::{PlutusVersion, MIN_COLLATERAL_LOVELACE};
use crate::tx::{hex_decode, AssetSpec, InputUtxo, PaymentSummary, ProtocolParams};
use crate::{Network, PaymentKey, WalletError};
/// One asset to mint under the Plutus policy. Quantity > 0 mints,
/// < 0 burns. Burning requires the wallet to hold the asset already
/// (it'll be drawn from input assets).
#[derive(Debug, Clone)]
pub struct PlutusMintAsset {
pub asset_name_hex: String,
pub quantity: i64,
}
/// Optional non-mint asset to attach to the recipient output.
/// Used for e.g. "send tTRP alongside the freshly-minted StakeST"
/// on a stake bootstrap. Sourced from wallet input UTxOs.
#[derive(Debug, Clone)]
pub struct ExtraDestAsset {
pub policy_id_hex: String,
pub asset_name_hex: String,
pub quantity: u64,
}
/// Full input spec for a Plutus mint. Caller fills out everything;
/// the builder chooses funding + collateral and computes fees.
#[derive(Debug, Clone)]
pub struct PlutusMintArgs<'a> {
/// Specific UTxOs that MUST appear as regular inputs. e.g. for
/// Agora's GST policy, this is the gstOutRef the policy was
/// parameterized on. May be empty.
pub required_inputs: &'a [InputUtxo],
/// Plutus minting policy script CBOR (the raw script, NOT a
/// `cborHex` wrapper — caller hex-decoded it).
pub policy_cbor: &'a [u8],
pub policy_version: PlutusVersion,
/// PlutusData CBOR redeemer for the mint redeemer entry.
pub redeemer_cbor: &'a [u8],
/// Generous default if `None`. Tune for known validators.
pub ex_units: crate::plutus::PlutusExUnits,
/// Assets to mint under this policy.
pub mint_assets: &'a [PlutusMintAsset],
/// Recipient address (script or wallet — both work; for DAO
/// flows this is governor_addr / stakes_addr / etc).
pub dest_address_bech32: &'a str,
pub dest_lovelace: u64,
/// Non-mint assets to include on the recipient output. Sourced
/// from wallet inputs. Empty for governor bootstrap; non-empty
/// for stake bootstrap (tTRP forwarded into the stake).
pub dest_extra_assets: &'a [ExtraDestAsset],
/// Optional inline datum on the recipient output. Required for
/// any send to a Plutus script address.
pub dest_inline_datum_cbor: Option<&'a [u8]>,
}
fn parse_address(bech32: &str) -> Result<PallasAddress, WalletError> {
PallasAddress::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_policy_id(hex_str: &str) -> Result<Hash<28>, WalletError> {
if hex_str.len() != 56 {
return Err(WalletError::Derivation(format!(
"expected 56-hex policy_id, 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 policy_id: {hex_str}")))?;
}
Ok(Hash::<28>::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()
)));
}
hex_decode(hex_str)
}
fn payment_key_to_private(payment: &PaymentKey) -> Result<PrivateKey, WalletError> {
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,
}
}
fn input_eq(a: &InputUtxo, b: &InputUtxo) -> bool {
a.tx_hash_hex == b.tx_hash_hex && a.output_index == b.output_index
}
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 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
}
const WITNESS_OVERHEAD_BYTES: u64 = 128;
/// Build + sign a Plutus-policy mint with a fully-specified output.
///
/// Selects collateral (smallest ADA-only ≥ 5 ADA) + funding (largest
/// remaining UTxOs sufficient to cover dest + fee + min_change) from
/// `available_utxos`. Required inputs are added as regular inputs.
/// The policy script witnesses inline; redeemer + ExUnits attached
/// via `add_mint_redeemer`. Mint assets land on the dest output;
/// extra assets are sourced from inputs and forwarded; leftover
/// assets go to change.
pub fn build_signed_plutus_mint(
payment_key: &PaymentKey,
network: Network,
available_utxos: &[InputUtxo],
change_address_bech32: &str,
args: &PlutusMintArgs,
params: &ProtocolParams,
) -> Result<Vec<u8>, WalletError> {
let private = payment_key_to_private(payment_key)?;
let (built, _summary) = prepare_plutus_mint(
network,
available_utxos,
change_address_bech32,
args,
params,
)?;
let signed = built
.sign(private)
.map_err(|e| WalletError::Derivation(format!("sign: {e}")))?;
Ok(signed.tx_bytes.0)
}
/// Build (no sign) a Plutus-policy mint. Returns the unsigned CBOR
/// + summary for review before pushing through `wallet_sign_partial`
/// + `wallet_submit_signed_tx`.
pub fn build_unsigned_plutus_mint(
network: Network,
available_utxos: &[InputUtxo],
change_address_bech32: &str,
args: &PlutusMintArgs,
params: &ProtocolParams,
) -> Result<crate::tx::UnsignedPayment, WalletError> {
let (built, summary) = prepare_plutus_mint(
network,
available_utxos,
change_address_bech32,
args,
params,
)?;
Ok(crate::tx::UnsignedPayment {
cbor_hex: built
.tx_bytes
.0
.iter()
.fold(String::with_capacity(built.tx_bytes.0.len() * 2), |mut s, b| {
s.push_str(&format!("{:02x}", b));
s
}),
summary,
})
}
#[allow(clippy::too_many_arguments)]
fn prepare_plutus_mint(
network: Network,
available_utxos: &[InputUtxo],
change_address_bech32: &str,
args: &PlutusMintArgs,
params: &ProtocolParams,
) -> Result<(BuiltTransaction, PaymentSummary), WalletError> {
let dest_addr = parse_address(args.dest_address_bech32)?;
let change_addr = parse_address(change_address_bech32)?;
let network_id = network_id_for(network);
if args.policy_cbor.is_empty() {
return Err(WalletError::Derivation(
"policy_cbor must be non-empty".into(),
));
}
if args.mint_assets.is_empty() {
return Err(WalletError::Derivation(
"mint_assets must be non-empty (caller must supply at least one asset to mint or burn)"
.into(),
));
}
// Required inputs MUST exist in available_utxos so we know their
// ADA value for fee math. Caller-supplied required_inputs entries
// already carry lovelace/assets, but we double-check existence.
for req in args.required_inputs {
if !available_utxos.iter().any(|u| input_eq(u, req)) {
return Err(WalletError::Derivation(format!(
"required_input {}#{} is not in available_utxos — caller must include it",
req.tx_hash_hex, req.output_index
)));
}
}
// Compute the policy hash for naming the mint asset.
let policy_hash: Hash<28> = {
// Pallas computes script hash as blake2b-224 of (tag || cbor).
// Tags: Native=0, PlutusV1=1, PlutusV2=2, PlutusV3=3.
let tag: u8 = match args.policy_version {
PlutusVersion::V1 => 1,
PlutusVersion::V2 => 2,
PlutusVersion::V3 => 3,
};
use pallas_crypto::hash::Hasher;
Hasher::<224>::hash_tagged(args.policy_cbor, tag)
};
let policy_id_hex = hash_to_hex(&policy_hash);
// Collateral: smallest ADA-only ≥ 5 ADA, NOT one of required_inputs.
let mut ada_only: Vec<&InputUtxo> = available_utxos
.iter()
.filter(|u| u.assets.is_empty())
.filter(|u| !args.required_inputs.iter().any(|r| input_eq(u, r)))
.collect();
ada_only.sort_by_key(|u| std::cmp::Reverse(u.lovelace));
let collateral = ada_only
.iter()
.rev()
.find(|u| u.lovelace >= MIN_COLLATERAL_LOVELACE)
.ok_or_else(|| {
WalletError::Derivation(format!(
"no ADA-only wallet UTXO ≥ {} lovelace available for collateral \
(excluding required_inputs)",
MIN_COLLATERAL_LOVELACE
))
})?
.to_owned()
.clone();
// Pre-compute the parsed mint-asset-name bytes for staging.
let parsed_mint_assets: Vec<(Vec<u8>, i64)> = args
.mint_assets
.iter()
.map(|a| -> Result<_, WalletError> {
Ok((parse_asset_name(&a.asset_name_hex)?, a.quantity))
})
.collect::<Result<_, _>>()?;
// Aggregate input assets (required + chosen funding) into one map.
// dest_extra_assets must be coverable from these. Mint contributes
// additional assets on top.
// Build canonical asset key: policy_id_hex || asset_name_hex.
let asset_key = |pol: &str, name: &str| format!("{pol}{name}");
// Required-by-extras assets — caller asked us to forward these to dest.
let mut needed_extras: std::collections::BTreeMap<String, u64> = Default::default();
for e in args.dest_extra_assets {
if e.policy_id_hex.len() != 56 {
return Err(WalletError::Derivation(format!(
"dest_extra_asset policy_id_hex must be 56 hex chars, got {}",
e.policy_id_hex.len()
)));
}
// Round-trip parse for hex sanity.
let _ = parse_policy_id(&e.policy_id_hex)?;
let _ = parse_asset_name(&e.asset_name_hex)?;
let key = asset_key(&e.policy_id_hex, &e.asset_name_hex);
*needed_extras.entry(key).or_insert(0) = needed_extras
.get(&asset_key(&e.policy_id_hex, &e.asset_name_hex))
.copied()
.unwrap_or(0)
.saturating_add(e.quantity);
}
// Funding selection: include all required_inputs first, then add
// ADA-only candidates (excluding collateral) until we cover
// (dest_lovelace + estimated fee + min_utxo_change). Also include
// any UTxOs we need to drain to cover needed_extras.
let mut funding: Vec<InputUtxo> = args.required_inputs.to_vec();
// First, scan for UTxOs that hold needed_extras assets and add
// them to funding. Track running totals of held assets.
let mut held: std::collections::BTreeMap<String, u64> = Default::default();
for u in &funding {
for (k, v) in &u.assets {
*held.entry(k.clone()).or_insert(0) = held
.get(k)
.copied()
.unwrap_or(0)
.saturating_add(*v);
}
}
// Pull in UTxOs that contribute the needed assets.
for u in available_utxos {
if input_eq(u, &collateral) {
continue;
}
if funding.iter().any(|f| input_eq(f, u)) {
continue;
}
// Does this UTxO contribute to a still-deficit extra asset?
let mut helps = false;
for (k, need) in &needed_extras {
let have = held.get(k).copied().unwrap_or(0);
if have < *need && u.assets.contains_key(k) {
helps = true;
break;
}
}
if helps {
for (k, v) in &u.assets {
*held.entry(k.clone()).or_insert(0) = held
.get(k)
.copied()
.unwrap_or(0)
.saturating_add(*v);
}
funding.push(u.clone());
}
}
// Verify all needed_extras are covered.
for (k, need) in &needed_extras {
let have = held.get(k).copied().unwrap_or(0);
if have < *need {
return Err(WalletError::Derivation(format!(
"wallet doesn't hold enough of {k} to forward to dest: need {need}, have {have}"
)));
}
}
// ExUnits fee estimate.
let ex_fee = params.ex_units_fee(args.ex_units.mem, args.ex_units.steps);
let fee_pass1: u64 = 1_000_000u64.saturating_add(ex_fee);
// Add ADA-only funding UTxOs until we have enough for dest + fee
// + min_change.
let need_total = args
.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 total_in_so_far: u64 = funding.iter().map(|u| u.lovelace).sum();
if total_in_so_far < need_total {
// Need more ADA — pull in additional ADA-only UTxOs.
for u in available_utxos {
if input_eq(u, &collateral) {
continue;
}
if funding.iter().any(|f| input_eq(f, u)) {
continue;
}
funding.push(u.clone());
let now: u64 = funding.iter().map(|x| x.lovelace).sum();
if now >= need_total {
break;
}
}
}
let total_in: u64 = funding.iter().map(|u| u.lovelace).sum();
if total_in < need_total {
return Err(WalletError::Derivation(format!(
"insufficient lovelace: need {need_total} (dest + est_fee + min_change), have {total_in}"
)));
}
// Aggregate input assets (after funding finalized).
let mut input_assets: std::collections::BTreeMap<String, u64> = Default::default();
for u in &funding {
for (k, v) in &u.assets {
*input_assets.entry(k.clone()).or_insert(0) = input_assets
.get(k)
.copied()
.unwrap_or(0)
.saturating_add(*v);
}
}
// Process burns (negative mint quantities) — subtract from input_assets
// so they don't leak to change.
for ma in args.mint_assets {
if ma.quantity < 0 {
let burn_qty = (-ma.quantity) as u64;
let key = asset_key(&policy_id_hex, &ma.asset_name_hex);
let have = input_assets.get(&key).copied().unwrap_or(0);
if have < burn_qty {
return Err(WalletError::Derivation(format!(
"insufficient {key} to burn: have {have}, need {burn_qty}"
)));
}
*input_assets.entry(key).or_insert(0) -= burn_qty;
}
}
// Build dest assets: minted (positive only) + extras forwarded.
let mut dest_assets: std::collections::BTreeMap<String, u64> = Default::default();
for ma in args.mint_assets {
if ma.quantity > 0 {
let key = asset_key(&policy_id_hex, &ma.asset_name_hex);
*dest_assets.entry(key).or_insert(0) = dest_assets
.get(&asset_key(&policy_id_hex, &ma.asset_name_hex))
.copied()
.unwrap_or(0)
.saturating_add(ma.quantity as u64);
}
}
for (k, q) in &needed_extras {
*dest_assets.entry(k.clone()).or_insert(0) = dest_assets
.get(k)
.copied()
.unwrap_or(0)
.saturating_add(*q);
}
// Change assets = input_assets minus dest extras (mint doesn't
// come from inputs).
let mut change_assets: std::collections::BTreeMap<String, u64> = input_assets.clone();
for (k, q) in &needed_extras {
let cur = change_assets.get(k).copied().unwrap_or(0);
if cur < *q {
return Err(WalletError::Derivation(format!(
"internal: dest extra exceeds available input asset for {k}"
)));
}
*change_assets.entry(k.clone()).or_insert(0) = cur - *q;
}
let collateral_input = Input::new(
parse_tx_hash(&collateral.tx_hash_hex)?,
collateral.output_index as u64,
);
let funding_inputs: Vec<Input> = funding
.iter()
.map(|u| -> Result<_, WalletError> {
Ok(Input::new(parse_tx_hash(&u.tx_hash_hex)?, u.output_index as u64))
})
.collect::<Result<_, _>>()?;
let build_with_fee = |fee: u64,
change_lovelace: u64|
-> Result<StagingTransaction, WalletError> {
let mut staging = StagingTransaction::new();
for inp in &funding_inputs {
staging = staging.input(inp.clone());
}
staging = staging.collateral_input(collateral_input.clone());
// Dest output.
let mut dest_out = Output::new(dest_addr.clone(), args.dest_lovelace);
for (k, q) in &dest_assets {
if *q == 0 {
continue;
}
let pol_hex = &k[..56];
let name_hex = &k[56..];
let p = parse_policy_id(pol_hex)?;
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}")))?;
}
if let Some(d) = args.dest_inline_datum_cbor {
dest_out = dest_out.set_inline_datum(d.to_vec());
}
staging = staging.output(dest_out);
// Change output (only if needed).
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 {
let pol_hex = &k[..56];
let name_hex = &k[56..];
let p = parse_policy_id(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);
}
// Mint each asset.
for (name_bytes, qty) in &parsed_mint_assets {
staging = staging
.mint_asset(policy_hash, name_bytes.clone(), *qty)
.map_err(|e| WalletError::Derivation(format!("mint_asset: {e}")))?;
}
// Inline policy script witness + redeemer.
let kind: ScriptKind = match args.policy_version {
PlutusVersion::V1 => ScriptKind::PlutusV1,
PlutusVersion::V2 => ScriptKind::PlutusV2,
PlutusVersion::V3 => ScriptKind::PlutusV3,
};
staging = staging
.script(kind, args.policy_cbor.to_vec())
.add_mint_redeemer(
policy_hash,
args.redeemer_cbor.to_vec(),
Some(args.ex_units.into()),
)
.fee(fee)
.network_id(network_id);
// PlutusV3 needs cost-model in script_data_hash. (Mirror of the
// PLUTUS-4 fix in plutus.rs::build_signed_plutus_spend.)
if let Some(cost_model) = params.plutus_v3_cost_model.as_deref() {
if matches!(args.policy_version, PlutusVersion::V3) {
staging = staging.language_view(kind, cost_model.to_vec());
}
}
Ok(staging)
};
// Pass 1.
let token_change = !change_assets.values().all(|v| *v == 0);
let need_change_min = if token_change { params.min_utxo_lovelace } else { 0 };
let change_pass1 = total_in
.checked_sub(args.dest_lovelace.saturating_add(fee_pass1))
.filter(|c| *c >= need_change_min)
.unwrap_or(need_change_min);
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 size_fee = params.min_fee_for_size(est_signed);
let real_fee = size_fee.saturating_add(ex_fee);
let outflow = args
.dest_lovelace
.checked_add(real_fee)
.ok_or_else(|| WalletError::Derivation("dest_lovelace + fee overflow".into()))?;
let (final_fee, final_change) = match total_in.checked_sub(outflow) {
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} lovelace, min={}",
params.min_utxo_lovelace
)));
}
(real_fee, c)
}
Some(c) => (
real_fee
.checked_add(c)
.ok_or_else(|| WalletError::Derivation("fee + change overflow".into()))?,
0,
),
None => {
return Err(WalletError::Derivation(format!(
"insufficient funds for fee: total_in={total_in} dest={} fee={real_fee} (size={size_fee} + ex={ex_fee})",
args.dest_lovelace
)))
}
};
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 summary = PaymentSummary {
tx_hash: hash_to_hex_32(&built.tx_hash.0),
network,
from_address: change_address_bech32.to_string(),
to_address: args.dest_address_bech32.to_string(),
send_lovelace: args.dest_lovelace,
fee_lovelace: final_fee,
change_lovelace: final_change,
num_inputs: funding.len(),
send_assets: dest_assets
.iter()
.map(|(k, v)| AssetSpec {
policy_id_hex: k[..56].to_string(),
asset_name_hex: k[56..].to_string(),
quantity: *v,
})
.collect(),
change_assets: change_assets
.iter()
.filter(|(_, v)| **v > 0)
.map(|(k, v)| AssetSpec {
policy_id_hex: k[..56].to_string(),
asset_name_hex: k[56..].to_string(),
quantity: *v,
})
.collect(),
};
Ok((built, summary))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::plutus::{PlutusExUnits, DEFAULT_EX_UNITS};
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()
}
/// Sample preprod governor address (the one Plutarch linker
/// produced for our preprod tTRP DAO). Used as the dest.
const SAMPLE_GOVERNOR_ADDR: &str =
"addr_test1wqlzsnytzs4qv0trmhvw5cuyxnxk0qjq68crn85jj4lhv7qn4wym4";
/// Trivial Plutus V3 minting policy (always succeeds). For tests
/// we don't need it to actually validate; we just need the
/// staging tx to accept it. 6 bytes minimal CBOR.
const ALWAYS_TRUE_PLUTUS_V3_CBOR: [u8; 6] = [0x46, 0x01, 0x00, 0x00, 0x32, 0x22];
const UNIT_REDEEMER_CBOR: [u8; 3] = [0xd8, 0x79, 0x80];
/// PlutusData CBOR for a minimal `Constr 0 []` (used as a stand-in
/// for any datum in tests).
const UNIT_DATUM_CBOR: [u8; 3] = [0xd8, 0x79, 0x80];
fn baseline_utxos() -> Vec<InputUtxo> {
vec![
// gstOutRef stand-in — small ADA-only UTxO.
InputUtxo {
tx_hash_hex: "deadbeef".repeat(8),
output_index: 0,
lovelace: 1_500_000,
assets: Default::default(),
},
// Funding utxo.
InputUtxo {
tx_hash_hex: "cafebabe".repeat(8),
output_index: 0,
lovelace: 100_000_000,
assets: Default::default(),
},
// Collateral candidate.
InputUtxo {
tx_hash_hex: "f00dface".repeat(8),
output_index: 0,
lovelace: 10_000_000,
assets: Default::default(),
},
]
}
#[test]
fn rejects_empty_policy() {
let payment = payment_from_canonical();
let utxos = baseline_utxos();
let mint = vec![PlutusMintAsset {
asset_name_hex: "".into(),
quantity: 1,
}];
let args = PlutusMintArgs {
required_inputs: &[utxos[0].clone()],
policy_cbor: &[],
policy_version: PlutusVersion::V3,
redeemer_cbor: &UNIT_REDEEMER_CBOR,
ex_units: DEFAULT_EX_UNITS,
mint_assets: &mint,
dest_address_bech32: SAMPLE_GOVERNOR_ADDR,
dest_lovelace: 3_000_000,
dest_extra_assets: &[],
dest_inline_datum_cbor: Some(&UNIT_DATUM_CBOR),
};
let err = build_signed_plutus_mint(
&payment,
Network::Preprod,
&utxos,
&change_address(Network::Preprod),
&args,
&ProtocolParams::default(),
)
.expect_err("expected empty-policy rejection");
match err {
WalletError::Derivation(m) => assert!(m.contains("policy_cbor")),
other => panic!("expected Derivation, got {other:?}"),
}
}
#[test]
fn rejects_empty_mint_assets() {
let payment = payment_from_canonical();
let utxos = baseline_utxos();
let args = PlutusMintArgs {
required_inputs: &[utxos[0].clone()],
policy_cbor: &ALWAYS_TRUE_PLUTUS_V3_CBOR,
policy_version: PlutusVersion::V3,
redeemer_cbor: &UNIT_REDEEMER_CBOR,
ex_units: DEFAULT_EX_UNITS,
mint_assets: &[],
dest_address_bech32: SAMPLE_GOVERNOR_ADDR,
dest_lovelace: 3_000_000,
dest_extra_assets: &[],
dest_inline_datum_cbor: Some(&UNIT_DATUM_CBOR),
};
let err = build_signed_plutus_mint(
&payment,
Network::Preprod,
&utxos,
&change_address(Network::Preprod),
&args,
&ProtocolParams::default(),
)
.expect_err("expected empty-mint rejection");
match err {
WalletError::Derivation(m) => assert!(m.contains("mint_assets")),
other => panic!("expected Derivation, got {other:?}"),
}
}
#[test]
fn rejects_required_input_not_in_available() {
let payment = payment_from_canonical();
let utxos = baseline_utxos();
let bogus_required = InputUtxo {
tx_hash_hex: "ababab".repeat(10) + "abab",
output_index: 7,
lovelace: 1_500_000,
assets: Default::default(),
};
let mint = vec![PlutusMintAsset {
asset_name_hex: "".into(),
quantity: 1,
}];
let args = PlutusMintArgs {
required_inputs: std::slice::from_ref(&bogus_required),
policy_cbor: &ALWAYS_TRUE_PLUTUS_V3_CBOR,
policy_version: PlutusVersion::V3,
redeemer_cbor: &UNIT_REDEEMER_CBOR,
ex_units: DEFAULT_EX_UNITS,
mint_assets: &mint,
dest_address_bech32: SAMPLE_GOVERNOR_ADDR,
dest_lovelace: 3_000_000,
dest_extra_assets: &[],
dest_inline_datum_cbor: Some(&UNIT_DATUM_CBOR),
};
let err = build_signed_plutus_mint(
&payment,
Network::Preprod,
&utxos,
&change_address(Network::Preprod),
&args,
&ProtocolParams::default(),
)
.expect_err("expected required-input-missing rejection");
match err {
WalletError::Derivation(m) => assert!(m.contains("required_input")),
other => panic!("expected Derivation, got {other:?}"),
}
}
#[test]
fn governor_bootstrap_shape_produces_cbor() {
let payment = payment_from_canonical();
let utxos = baseline_utxos();
let mint = vec![PlutusMintAsset {
asset_name_hex: "".into(), // GST asset name = empty
quantity: 1,
}];
let args = PlutusMintArgs {
required_inputs: &[utxos[0].clone()],
policy_cbor: &ALWAYS_TRUE_PLUTUS_V3_CBOR,
policy_version: PlutusVersion::V3,
redeemer_cbor: &UNIT_REDEEMER_CBOR,
ex_units: PlutusExUnits {
mem: 5_000_000,
steps: 5_000_000_000,
},
mint_assets: &mint,
dest_address_bech32: SAMPLE_GOVERNOR_ADDR,
dest_lovelace: 3_000_000,
dest_extra_assets: &[],
dest_inline_datum_cbor: Some(&UNIT_DATUM_CBOR),
};
// V3 cost model is required for staging language_view; test
// ProtocolParams::default() should provide one for preprod.
// If it doesn't, we accept an early build error here (caller
// would supply via params on a real call).
let res = build_signed_plutus_mint(
&payment,
Network::Preprod,
&utxos,
&change_address(Network::Preprod),
&args,
&ProtocolParams::default(),
);
match res {
Ok(cbor) => {
assert!(!cbor.is_empty());
// Conway tx CBOR starts with major-array tag 0x84 (4 elements).
assert_eq!(cbor[0], 0x84, "expected conway tx CBOR tag prefix");
}
Err(WalletError::Derivation(m)) => {
// Acceptable: cost-model-related error if default params
// don't include V3 cost model. Just confirm we got past
// arg validation.
assert!(
!m.contains("policy_cbor")
&& !m.contains("mint_assets")
&& !m.contains("required_input"),
"expected args to validate clean; got {m}"
);
}
Err(other) => panic!("unexpected error type: {other:?}"),
}
}
}