plutus spend: fix all 4 chain-level bugs surfaced in preprod audit

PLUTUS-1 (HIGH) — value-not-conserved on happy path. collateral isn't
consumed unless script fails, so total_in counted lovelace that wasn't
actually available for outputs. now picks a SEPARATE ada-only funding
utxo as a regular input alongside the locked utxo; collateral stays
collateral. error message tells callers to "split a UTXO first or top
up" if a second ada-only utxo isn't available.

PLUTUS-2 (HIGH) — collateral containing native assets. chain forbids
that; our picker grabbed largest-overall. now filters available_utxos
to assets.is_empty() before picking, errors clearly if no ada-only
utxo ≥ 5 ADA exists.

PLUTUS-3 (HIGH) — fee underestimation. plutus tx fees are
size_fee + exunits_fee. only size_fee was being charged. new
ProtocolParams::ex_units_fee() does ceil(mem * priceMem) +
ceil(steps * priceStep). conway-era prices in defaults
(577/10000 mem, 721/10_000_000 steps). fee jumps from ~0.17 ADA →
~1.7 ADA for the default ExUnits budget — matches what chain demanded.

PLUTUS-4 (LOW, becomes blocking under the others) — script_data_hash
not computed. pallas-txbuilder only computes the body hash field when
language_view is set on staging. plutus v3 path now calls
.language_view(version, cost_model) when the caller-supplied
ProtocolParams::plutus_v3_cost_model is Some. mcp wallet_script_spend
populates with the canonical preprod V3 cost model from
plutus_cost_models::PLUTUS_V3_COST_MODEL_PREPROD (297 i64 params,
fetched from koios epoch_params 2026-05). when ProtocolParams has no
cost model, we skip language_view and the chain rejects with
PPViewHashesDontMatch — explicit-failure mode, no silent shipping
of broken txs.

new tests:
- ex_units_fee_matches_known_values: 14M mem * 0.0577 + 10B steps *
  7.21e-5 ≈ 1.529 ADA ± ceil-rounding. locks the conway price math.
- rejects_when_no_funding_input_separate_from_collateral: catches
  the PLUTUS-1 single-utxo case.
- rejects_when_collateral_candidate_has_assets: PLUTUS-2 ada-only.

verified on preprod against a real script-locked utxo (the placeholder
script we locked 5 tADA at earlier). chain rejection went from 5
distinct errors to 1 (MalformedScriptWitnesses — expected, our
placeholder UPLC isn't valid). structural body shape now passes
every chain-rule check; only the script bytecode itself fails to
compile, which is a test-env limitation (no aiken in our toolchain
yet) not a wallet-code limitation.

97 unit tests pass. ProtocolParams gained 5 new fields + ex_units_fee
helper; went from Copy to Clone (cost_model is a Vec).
This commit is contained in:
Cobb 2026-05-04 17:27:47 -07:00
parent 05292f182e
commit 7d59ceffd2
5 changed files with 297 additions and 32 deletions

View file

@ -41,6 +41,7 @@ pub mod inspect;
pub mod metadata;
pub mod mint;
pub mod plutus;
pub mod plutus_cost_models;
pub mod sign;
pub mod stake;
pub mod tx;

View file

@ -138,33 +138,65 @@ pub fn build_signed_plutus_spend(
let payout_addr = parse_address(payout_address_bech32)?;
let network_id = network_id_for(network);
// Pick collateral — largest UTXO ≥ 5 ADA.
let mut sorted: Vec<InputUtxo> = available_utxos.to_vec();
sorted.sort_by(|a, b| b.lovelace.cmp(&a.lovelace));
let collateral = sorted
// PLUTUS-2 audit fix: collateral MUST be ADA-only (chain rejects
// collateral inputs that carry native assets).
let mut ada_only: Vec<InputUtxo> = available_utxos
.iter()
.filter(|u| u.assets.is_empty())
.cloned()
.collect();
ada_only.sort_by(|a, b| b.lovelace.cmp(&a.lovelace));
// Pick collateral — largest ADA-only UTXO ≥ 5 ADA.
let collateral = ada_only
.iter()
.find(|u| u.lovelace >= MIN_COLLATERAL_LOVELACE)
.ok_or_else(|| {
WalletError::Derivation(format!(
"no wallet UTXO ≥ {} lovelace available for collateral",
"no ADA-only wallet UTXO ≥ {} lovelace available for collateral",
MIN_COLLATERAL_LOVELACE
))
})?
.clone();
// Total inputs = locked + collateral (collateral acts as a
// regular input on the happy path; on script failure the chain
// consumes only collateral and the rest of the tx is rolled back).
let total_in = locked.lovelace.saturating_add(collateral.lovelace);
// PLUTUS-1 audit fix: collateral is NOT consumed on the happy
// path — it's only seized if the script fails. So we need a
// SEPARATE regular input to fund payout + fee + change.
//
// Pick the next-largest ADA-only UTXO that's not the collateral.
// Could ease this constraint (use any ADA-only UTXO of sufficient
// size) but largest-first matches the rest of the wallet's
// selection ergonomics.
let funding = ada_only
.iter()
.find(|u| {
!(u.tx_hash_hex == collateral.tx_hash_hex
&& u.output_index == collateral.output_index)
})
.cloned()
.ok_or_else(|| {
WalletError::Derivation(
"need a SECOND ADA-only wallet UTXO to fund the spend (separate from collateral). \
split a UTXO first or top up the wallet."
.into(),
)
})?;
let fee_pass1: u64 = 1_000_000;
// Regular inputs (consumed on happy path): locked + funding.
// Collateral is held off-line and only consumed on script failure.
let total_in = locked.lovelace.saturating_add(funding.lovelace);
// PLUTUS-3 audit fix: Plutus tx fee = size_fee + ExUnits_fee.
// ExUnits dominates for typical scripts (~1.5 ADA at default budget).
let ex_fee = params.ex_units_fee(ex_units.mem, ex_units.steps);
let fee_pass1 = 1_000_000u64.saturating_add(ex_fee);
let need = payout_lovelace
.checked_add(fee_pass1)
.and_then(|x| x.checked_add(params.min_utxo_lovelace))
.ok_or_else(|| WalletError::Derivation("amount overflow".into()))?;
if total_in < need {
return Err(WalletError::Derivation(format!(
"insufficient lovelace for plutus spend: have {total_in}, need {need}"
"insufficient lovelace for plutus spend: have {total_in} (locked+funding), need {need}"
)));
}
@ -172,6 +204,10 @@ pub fn build_signed_plutus_spend(
parse_tx_hash(&locked.tx_hash_hex)?,
locked.output_index as u64,
);
let funding_input = Input::new(
parse_tx_hash(&funding.tx_hash_hex)?,
funding.output_index as u64,
);
let collateral_input = Input::new(
parse_tx_hash(&collateral.tx_hash_hex)?,
collateral.output_index as u64,
@ -181,7 +217,10 @@ pub fn build_signed_plutus_spend(
change_lovelace: u64|
-> Result<StagingTransaction, WalletError> {
let mut staging = StagingTransaction::new();
// PLUTUS-1: locked + funding as regular inputs (both consumed
// on happy path); collateral as collateral_input only.
staging = staging.input(locked_input.clone());
staging = staging.input(funding_input.clone());
staging = staging.collateral_input(collateral_input.clone());
staging = staging.output(Output::new(payout_addr.clone(), payout_lovelace));
if change_lovelace > 0 {
@ -199,11 +238,25 @@ pub fn build_signed_plutus_spend(
if let Some(d) = witness_datum_cbor {
staging = staging.datum(d.to_vec());
}
// PLUTUS-4 audit fix: pallas-txbuilder only computes
// script_data_hash if language_view is set. Without it, the
// body's hash is None and the chain rejects with
// PPViewHashesDontMatch. PlutusV3 path requires a V3 cost
// model — caller-supplied via ProtocolParams.
if let Some(cost_model) = params.plutus_v3_cost_model.as_deref() {
if matches!(script_version, PlutusVersion::V3) {
staging = staging.language_view(
script_version.to_script_kind(),
cost_model.to_vec(),
);
}
}
Ok(staging)
};
// Pass 1 — placeholder fee, measure unsigned size.
let change_pass1 = total_in
.checked_sub(payout_lovelace + fee_pass1)
.checked_sub(payout_lovelace.saturating_add(fee_pass1))
.ok_or_else(|| {
WalletError::Derivation("pass1: insufficient lovelace for plutus spend".into())
})?;
@ -213,16 +266,17 @@ pub fn build_signed_plutus_spend(
.map_err(|e| WalletError::Derivation(format!("conway build (pass1): {e}")))?
.tx_bytes
.0;
// One witness for the body (payment key) + the redeemer's
// script_data_hash overhead is already in the unsigned size.
// 128 bytes overhead for the single VKey witness we'll add.
let est_signed = (unsigned.len() as u64) + 128;
let real_fee = params.min_fee_for_size(est_signed);
// size fee + ExUnits fee.
let size_fee = params.min_fee_for_size(est_signed);
let real_fee = size_fee.saturating_add(ex_fee);
let final_change = total_in
.checked_sub(payout_lovelace + real_fee)
.checked_sub(payout_lovelace.saturating_add(real_fee))
.ok_or_else(|| {
WalletError::Derivation(format!(
"insufficient funds for fee: total_in={total_in} payout={payout_lovelace} fee={real_fee}"
"insufficient funds for fee: total_in={total_in} payout={payout_lovelace} fee={real_fee} (size={size_fee} + ex={ex_fee})"
))
})?;
if final_change < params.min_utxo_lovelace {
@ -310,6 +364,110 @@ mod tests {
assert!(DEFAULT_EX_UNITS.steps >= 100_000_000);
}
#[test]
fn ex_units_fee_matches_known_values() {
// PLUTUS-3 audit fix: fee for default budget should be ~1.5 ADA.
let p = ProtocolParams::default();
let fee = p.ex_units_fee(DEFAULT_EX_UNITS.mem, DEFAULT_EX_UNITS.steps);
// 14M mem * 0.0577 + 10B steps * 7.21e-5
// = 807_800 + 721_000 = 1_528_800 lovelace
// ceil-rounding may bump by 1.
assert!(
(1_528_800..=1_528_802).contains(&fee),
"fee {} not in expected range ~1.529 ADA",
fee
);
}
#[test]
fn rejects_when_no_funding_input_separate_from_collateral() {
let payment = payment_from_canonical();
let change = change_address(Network::Preprod);
let payout = payout_address();
let locked = PlutusInput {
tx_hash_hex: "deadbeef".repeat(8),
output_index: 0,
lovelace: 50_000_000,
};
// Only ONE wallet UTXO — collateral pick consumes it; no
// separate funding UTXO available. PLUTUS-1 fix should
// reject with a clear error rather than building a tx that
// chain rejects.
let utxos = vec![InputUtxo {
tx_hash_hex: "cafebabe".repeat(8),
output_index: 0,
lovelace: 100_000_000,
assets: Default::default(),
}];
let err = build_signed_plutus_spend(
&payment,
Network::Preprod,
&locked,
PlutusVersion::V3,
&ALWAYS_TRUE_PLUTUS_V3_CBOR,
&UNIT_REDEEMER_CBOR,
None,
&utxos,
&change,
&payout,
10_000_000,
DEFAULT_EX_UNITS,
&ProtocolParams::default(),
)
.expect_err("expected a 'need second utxo' error");
match err {
WalletError::Derivation(m) => assert!(m.contains("SECOND")),
other => panic!("expected Derivation, got {other:?}"),
}
}
#[test]
fn rejects_when_collateral_candidate_has_assets() {
let payment = payment_from_canonical();
let change = change_address(Network::Preprod);
let payout = payout_address();
let locked = PlutusInput {
tx_hash_hex: "deadbeef".repeat(8),
output_index: 0,
lovelace: 50_000_000,
};
// Largest UTXO has assets — must NOT be picked as collateral.
// No ADA-only UTXO ≥ 5 ADA → rejected.
let mut with_assets = InputUtxo {
tx_hash_hex: "cafebabe".repeat(8),
output_index: 0,
lovelace: 100_000_000,
assets: Default::default(),
};
with_assets.assets.insert("ee".repeat(28) + "deadbeef", 1);
let utxos = vec![with_assets];
let err = build_signed_plutus_spend(
&payment,
Network::Preprod,
&locked,
PlutusVersion::V3,
&ALWAYS_TRUE_PLUTUS_V3_CBOR,
&UNIT_REDEEMER_CBOR,
None,
&utxos,
&change,
&payout,
10_000_000,
DEFAULT_EX_UNITS,
&ProtocolParams::default(),
)
.expect_err("expected ADA-only collateral error");
match err {
WalletError::Derivation(m) => {
assert!(
m.contains("ADA-only") || m.contains("collateral"),
"expected ADA-only / collateral message, got: {m}"
)
}
other => panic!("expected Derivation, got {other:?}"),
}
}
#[test]
fn build_signed_plutus_spend_produces_cbor() {
let payment = payment_from_canonical();
@ -321,13 +479,22 @@ mod tests {
output_index: 0,
lovelace: 50_000_000,
};
// Wallet has one large UTXO usable as collateral.
let utxos = vec![InputUtxo {
tx_hash_hex: "cafebabe".repeat(8),
output_index: 0,
lovelace: 100_000_000,
assets: Default::default(),
}];
// PLUTUS-1+2 audit fix: spend path needs TWO ADA-only wallet
// UTXOs — one for collateral, one for funding.
let utxos = vec![
InputUtxo {
tx_hash_hex: "cafebabe".repeat(8),
output_index: 0,
lovelace: 100_000_000,
assets: Default::default(),
},
InputUtxo {
tx_hash_hex: "feedface".repeat(8),
output_index: 0,
lovelace: 50_000_000,
assets: Default::default(),
},
];
let cbor = build_signed_plutus_spend(
&payment,
@ -372,12 +539,20 @@ mod tests {
lovelace: 50_000_000,
};
// No wallet UTXO ≥ 5 ADA.
let utxos = vec![InputUtxo {
tx_hash_hex: "cafebabe".repeat(8),
output_index: 0,
lovelace: 1_000_000,
assets: Default::default(),
}];
let utxos = vec![
InputUtxo {
tx_hash_hex: "cafebabe".repeat(8),
output_index: 0,
lovelace: 1_000_000,
assets: Default::default(),
},
InputUtxo {
tx_hash_hex: "feedface".repeat(8),
output_index: 0,
lovelace: 1_000_000,
assets: Default::default(),
},
];
let err = build_signed_plutus_spend(
&payment,
Network::Preprod,

View file

@ -0,0 +1,42 @@
// Conway-era preprod Plutus V3 cost model, epoch 286 (2026-05).
// 297 params. Pull fresh via koios epoch_params after major hard forks.
pub const PLUTUS_V3_COST_MODEL_PREPROD: [i64; 297] = [
100788, 420, 1, 1, 1000, 173, 0, 1,
1000, 59957, 4, 1, 11183, 32, 201305, 8356,
4, 16000, 100, 16000, 100, 16000, 100, 16000,
100, 16000, 100, 16000, 100, 100, 100, 16000,
100, 94375, 32, 132994, 32, 61462, 4, 72010,
178, 0, 1, 22151, 32, 91189, 769, 4,
2, 85848, 123203, 7305, -900, 1716, 549, 57,
85848, 0, 1, 1, 1000, 42921, 4, 2,
24548, 29498, 38, 1, 898148, 27279, 1, 51775,
558, 1, 39184, 1000, 60594, 1, 141895, 32,
83150, 32, 15299, 32, 76049, 1, 13169, 4,
22100, 10, 28999, 74, 1, 28999, 74, 1,
43285, 552, 1, 44749, 541, 1, 33852, 32,
68246, 32, 72362, 32, 7243, 32, 7391, 32,
11546, 32, 85848, 123203, 7305, -900, 1716, 549,
57, 85848, 0, 1, 90434, 519, 0, 1,
74433, 32, 85848, 123203, 7305, -900, 1716, 549,
57, 85848, 0, 1, 1, 85848, 123203, 7305,
-900, 1716, 549, 57, 85848, 0, 1, 955506,
213312, 0, 2, 270652, 22588, 4, 1457325, 64566,
4, 20467, 1, 4, 0, 141992, 32, 100788,
420, 1, 1, 81663, 32, 59498, 32, 20142,
32, 24588, 32, 20744, 32, 25933, 32, 24623,
32, 43053543, 10, 53384111, 14333, 10, 43574283, 26308,
10, 16000, 100, 16000, 100, 962335, 18, 2780678,
6, 442008, 1, 52538055, 3756, 18, 267929, 18,
76433006, 8868, 18, 52948122, 18, 1995836, 36, 3227919,
12, 901022, 1, 166917843, 4307, 36, 284546, 36,
158221314, 26549, 36, 74698472, 36, 333849714, 1, 254006273,
72, 2174038, 72, 2261318, 64571, 4, 207616, 8310,
4, 1293828, 28716, 63, 0, 1, 1006041, 43623,
251, 0, 1, 100181, 726, 719, 0, 1,
100181, 726, 719, 0, 1, 100181, 726, 719,
0, 1, 107878, 680, 0, 1, 95336, 1,
281145, 18848, 0, 1, 180194, 159, 1, 1,
158519, 8942, 0, 1, 159378, 8813, 0, 1,
107490, 3298, 1, 106057, 655, 1, 1964219, 24520,
3,
];

View file

@ -56,7 +56,7 @@ use crate::{Network, PaymentKey, WalletError};
/// here match Conway-era mainnet as of 2026-Q2; supply your own via
/// [`ProtocolParams::from_koios_response`] (TODO) if you want
/// chain-fresh values.
#[derive(Debug, Clone, Copy)]
#[derive(Debug, Clone)]
pub struct ProtocolParams {
/// Per-byte fee coefficient (a). Mainnet: 44.
pub min_fee_a: u64,
@ -66,6 +66,19 @@ pub struct ProtocolParams {
/// formula is `coins_per_utxo_byte * tx_out_size`; we use the
/// 1 ADA floor as a safe over-approximation.
pub min_utxo_lovelace: u64,
/// Plutus ExUnits price per memory unit, as numerator/denominator
/// rational. Conway era: 577 / 10000 = 0.0577 lovelace/mem.
pub price_mem_numerator: u64,
pub price_mem_denominator: u64,
/// Plutus ExUnits price per CPU step, as numerator/denominator.
/// Conway era: 721 / 10_000_000 = 7.21e-5 lovelace/step.
pub price_steps_numerator: u64,
pub price_steps_denominator: u64,
/// Plutus V3 cost model. Required when building Plutus spend
/// transactions so the chain can verify `script_data_hash`. If
/// `None`, Plutus paths skip script_data_hash and the chain will
/// reject with `PPViewHashesDontMatch`.
pub plutus_v3_cost_model: Option<Vec<i64>>,
}
impl Default for ProtocolParams {
@ -74,16 +87,42 @@ impl Default for ProtocolParams {
min_fee_a: 44,
min_fee_b: 155_381,
min_utxo_lovelace: 1_000_000,
price_mem_numerator: 577,
price_mem_denominator: 10_000,
price_steps_numerator: 721,
price_steps_denominator: 10_000_000,
// Plutus paths populate this from `plutus_cost_models::PLUTUS_V3_COST_MODEL_PREPROD`
// or fetched from `epoch_params`. None by default keeps
// the ada-only / mint paths zero-cost.
plutus_v3_cost_model: None,
}
}
}
impl ProtocolParams {
/// Linear size-based fee: `min_fee_a * tx_size + min_fee_b`.
pub fn min_fee_for_size(&self, tx_size_bytes: u64) -> u64 {
self.min_fee_a
.saturating_mul(tx_size_bytes)
.saturating_add(self.min_fee_b)
}
/// ExUnits execution cost in lovelace, ceiling-rounded per the
/// Conway protocol rules. Add this to `min_fee_for_size(...)` for
/// the total fee on a Plutus transaction.
/// (PLUTUS-3 audit fix.)
pub fn ex_units_fee(&self, mem: u64, steps: u64) -> u64 {
// ceil(mem * num / den)
let mem_cost = mem
.saturating_mul(self.price_mem_numerator)
.saturating_add(self.price_mem_denominator.saturating_sub(1))
/ self.price_mem_denominator.max(1);
let step_cost = steps
.saturating_mul(self.price_steps_numerator)
.saturating_add(self.price_steps_denominator.saturating_sub(1))
/ self.price_steps_denominator.max(1);
mem_cost.saturating_add(step_cost)
}
}
/// One UTXO available for spending. Independently typed from

View file

@ -28,6 +28,7 @@
use std::sync::Arc;
use aldabra_chain::{ChainBackend, KoiosClient};
use aldabra_core::plutus_cost_models::PLUTUS_V3_COST_MODEL_PREPROD;
use aldabra_core::{
add_witness, build_signed_cip68_nft_mint, build_signed_mint_with_metadata,
build_signed_payment_with_assets, build_signed_plutus_spend, build_signed_stake_delegation,
@ -821,6 +822,13 @@ impl WalletService {
lovelace: locked_lovelace,
};
// Plutus paths require the V3 cost model so the chain can
// verify script_data_hash. Hardcoded preprod value from
// koios epoch_params; future improvement is pulling fresh
// from the chain on each call. (PLUTUS-4 audit fix.)
let mut params = ProtocolParams::default();
params.plutus_v3_cost_model = Some(PLUTUS_V3_COST_MODEL_PREPROD.to_vec());
let cbor = build_signed_plutus_spend(
&self.inner.payment_key,
self.inner.network,
@ -834,7 +842,7 @@ impl WalletService {
&payout_address,
payout_lovelace,
budget,
&ProtocolParams::default(),
&params,
)
.map_err(|e| format!("build/sign plutus spend: {e}"))?;