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:
parent
a65ab7803e
commit
86bc4e45cd
2 changed files with 919 additions and 0 deletions
|
|
@ -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,
|
||||
|
|
|
|||
914
crates/aldabra-core/src/plutus_mint.rs
Normal file
914
crates/aldabra-core/src/plutus_mint.rs
Normal 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:?}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue