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:
Sulkta 2026-05-04 17:27:47 -07:00
parent 67b86707e8
commit 3bf2be28a7
5 changed files with 297 additions and 32 deletions

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