diff --git a/crates/aldabra-core/src/lib.rs b/crates/aldabra-core/src/lib.rs index 9ea432b..6ed077a 100644 --- a/crates/aldabra-core/src/lib.rs +++ b/crates/aldabra-core/src/lib.rs @@ -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; diff --git a/crates/aldabra-core/src/plutus.rs b/crates/aldabra-core/src/plutus.rs index f691f9f..52be470 100644 --- a/crates/aldabra-core/src/plutus.rs +++ b/crates/aldabra-core/src/plutus.rs @@ -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 = 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 = 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 { 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, diff --git a/crates/aldabra-core/src/plutus_cost_models.rs b/crates/aldabra-core/src/plutus_cost_models.rs new file mode 100644 index 0000000..18bdc4b --- /dev/null +++ b/crates/aldabra-core/src/plutus_cost_models.rs @@ -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, +]; diff --git a/crates/aldabra-core/src/tx.rs b/crates/aldabra-core/src/tx.rs index 6d6a237..68cc52d 100644 --- a/crates/aldabra-core/src/tx.rs +++ b/crates/aldabra-core/src/tx.rs @@ -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>, } 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 diff --git a/crates/aldabra-mcp/src/tools.rs b/crates/aldabra-mcp/src/tools.rs index a381964..8b90047 100644 --- a/crates/aldabra-mcp/src/tools.rs +++ b/crates/aldabra-mcp/src/tools.rs @@ -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(), + ¶ms, ) .map_err(|e| format!("build/sign plutus spend: {e}"))?;