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:
parent
67b86707e8
commit
3bf2be28a7
5 changed files with 297 additions and 32 deletions
|
|
@ -41,6 +41,7 @@ pub mod inspect;
|
||||||
pub mod metadata;
|
pub mod metadata;
|
||||||
pub mod mint;
|
pub mod mint;
|
||||||
pub mod plutus;
|
pub mod plutus;
|
||||||
|
pub mod plutus_cost_models;
|
||||||
pub mod sign;
|
pub mod sign;
|
||||||
pub mod stake;
|
pub mod stake;
|
||||||
pub mod tx;
|
pub mod tx;
|
||||||
|
|
|
||||||
|
|
@ -138,33 +138,65 @@ pub fn build_signed_plutus_spend(
|
||||||
let payout_addr = parse_address(payout_address_bech32)?;
|
let payout_addr = parse_address(payout_address_bech32)?;
|
||||||
let network_id = network_id_for(network);
|
let network_id = network_id_for(network);
|
||||||
|
|
||||||
// Pick collateral — largest UTXO ≥ 5 ADA.
|
// PLUTUS-2 audit fix: collateral MUST be ADA-only (chain rejects
|
||||||
let mut sorted: Vec<InputUtxo> = available_utxos.to_vec();
|
// collateral inputs that carry native assets).
|
||||||
sorted.sort_by(|a, b| b.lovelace.cmp(&a.lovelace));
|
let mut ada_only: Vec<InputUtxo> = available_utxos
|
||||||
let collateral = sorted
|
.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()
|
.iter()
|
||||||
.find(|u| u.lovelace >= MIN_COLLATERAL_LOVELACE)
|
.find(|u| u.lovelace >= MIN_COLLATERAL_LOVELACE)
|
||||||
.ok_or_else(|| {
|
.ok_or_else(|| {
|
||||||
WalletError::Derivation(format!(
|
WalletError::Derivation(format!(
|
||||||
"no wallet UTXO ≥ {} lovelace available for collateral",
|
"no ADA-only wallet UTXO ≥ {} lovelace available for collateral",
|
||||||
MIN_COLLATERAL_LOVELACE
|
MIN_COLLATERAL_LOVELACE
|
||||||
))
|
))
|
||||||
})?
|
})?
|
||||||
.clone();
|
.clone();
|
||||||
|
|
||||||
// Total inputs = locked + collateral (collateral acts as a
|
// PLUTUS-1 audit fix: collateral is NOT consumed on the happy
|
||||||
// regular input on the happy path; on script failure the chain
|
// path — it's only seized if the script fails. So we need a
|
||||||
// consumes only collateral and the rest of the tx is rolled back).
|
// SEPARATE regular input to fund payout + fee + change.
|
||||||
let total_in = locked.lovelace.saturating_add(collateral.lovelace);
|
//
|
||||||
|
// 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
|
let need = payout_lovelace
|
||||||
.checked_add(fee_pass1)
|
.checked_add(fee_pass1)
|
||||||
.and_then(|x| x.checked_add(params.min_utxo_lovelace))
|
.and_then(|x| x.checked_add(params.min_utxo_lovelace))
|
||||||
.ok_or_else(|| WalletError::Derivation("amount overflow".into()))?;
|
.ok_or_else(|| WalletError::Derivation("amount overflow".into()))?;
|
||||||
if total_in < need {
|
if total_in < need {
|
||||||
return Err(WalletError::Derivation(format!(
|
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)?,
|
parse_tx_hash(&locked.tx_hash_hex)?,
|
||||||
locked.output_index as u64,
|
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(
|
let collateral_input = Input::new(
|
||||||
parse_tx_hash(&collateral.tx_hash_hex)?,
|
parse_tx_hash(&collateral.tx_hash_hex)?,
|
||||||
collateral.output_index as u64,
|
collateral.output_index as u64,
|
||||||
|
|
@ -181,7 +217,10 @@ pub fn build_signed_plutus_spend(
|
||||||
change_lovelace: u64|
|
change_lovelace: u64|
|
||||||
-> Result<StagingTransaction, WalletError> {
|
-> Result<StagingTransaction, WalletError> {
|
||||||
let mut staging = StagingTransaction::new();
|
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(locked_input.clone());
|
||||||
|
staging = staging.input(funding_input.clone());
|
||||||
staging = staging.collateral_input(collateral_input.clone());
|
staging = staging.collateral_input(collateral_input.clone());
|
||||||
staging = staging.output(Output::new(payout_addr.clone(), payout_lovelace));
|
staging = staging.output(Output::new(payout_addr.clone(), payout_lovelace));
|
||||||
if change_lovelace > 0 {
|
if change_lovelace > 0 {
|
||||||
|
|
@ -199,11 +238,25 @@ pub fn build_signed_plutus_spend(
|
||||||
if let Some(d) = witness_datum_cbor {
|
if let Some(d) = witness_datum_cbor {
|
||||||
staging = staging.datum(d.to_vec());
|
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)
|
Ok(staging)
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Pass 1 — placeholder fee, measure unsigned size.
|
||||||
let change_pass1 = total_in
|
let change_pass1 = total_in
|
||||||
.checked_sub(payout_lovelace + fee_pass1)
|
.checked_sub(payout_lovelace.saturating_add(fee_pass1))
|
||||||
.ok_or_else(|| {
|
.ok_or_else(|| {
|
||||||
WalletError::Derivation("pass1: insufficient lovelace for plutus spend".into())
|
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}")))?
|
.map_err(|e| WalletError::Derivation(format!("conway build (pass1): {e}")))?
|
||||||
.tx_bytes
|
.tx_bytes
|
||||||
.0;
|
.0;
|
||||||
// One witness for the body (payment key) + the redeemer's
|
// 128 bytes overhead for the single VKey witness we'll add.
|
||||||
// script_data_hash overhead is already in the unsigned size.
|
|
||||||
let est_signed = (unsigned.len() as u64) + 128;
|
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
|
let final_change = total_in
|
||||||
.checked_sub(payout_lovelace + real_fee)
|
.checked_sub(payout_lovelace.saturating_add(real_fee))
|
||||||
.ok_or_else(|| {
|
.ok_or_else(|| {
|
||||||
WalletError::Derivation(format!(
|
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 {
|
if final_change < params.min_utxo_lovelace {
|
||||||
|
|
@ -310,6 +364,110 @@ mod tests {
|
||||||
assert!(DEFAULT_EX_UNITS.steps >= 100_000_000);
|
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]
|
#[test]
|
||||||
fn build_signed_plutus_spend_produces_cbor() {
|
fn build_signed_plutus_spend_produces_cbor() {
|
||||||
let payment = payment_from_canonical();
|
let payment = payment_from_canonical();
|
||||||
|
|
@ -321,13 +479,22 @@ mod tests {
|
||||||
output_index: 0,
|
output_index: 0,
|
||||||
lovelace: 50_000_000,
|
lovelace: 50_000_000,
|
||||||
};
|
};
|
||||||
// Wallet has one large UTXO usable as collateral.
|
// PLUTUS-1+2 audit fix: spend path needs TWO ADA-only wallet
|
||||||
let utxos = vec![InputUtxo {
|
// UTXOs — one for collateral, one for funding.
|
||||||
tx_hash_hex: "cafebabe".repeat(8),
|
let utxos = vec![
|
||||||
output_index: 0,
|
InputUtxo {
|
||||||
lovelace: 100_000_000,
|
tx_hash_hex: "cafebabe".repeat(8),
|
||||||
assets: Default::default(),
|
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(
|
let cbor = build_signed_plutus_spend(
|
||||||
&payment,
|
&payment,
|
||||||
|
|
@ -372,12 +539,20 @@ mod tests {
|
||||||
lovelace: 50_000_000,
|
lovelace: 50_000_000,
|
||||||
};
|
};
|
||||||
// No wallet UTXO ≥ 5 ADA.
|
// No wallet UTXO ≥ 5 ADA.
|
||||||
let utxos = vec![InputUtxo {
|
let utxos = vec![
|
||||||
tx_hash_hex: "cafebabe".repeat(8),
|
InputUtxo {
|
||||||
output_index: 0,
|
tx_hash_hex: "cafebabe".repeat(8),
|
||||||
lovelace: 1_000_000,
|
output_index: 0,
|
||||||
assets: Default::default(),
|
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(
|
let err = build_signed_plutus_spend(
|
||||||
&payment,
|
&payment,
|
||||||
Network::Preprod,
|
Network::Preprod,
|
||||||
|
|
|
||||||
42
crates/aldabra-core/src/plutus_cost_models.rs
Normal file
42
crates/aldabra-core/src/plutus_cost_models.rs
Normal 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,
|
||||||
|
];
|
||||||
|
|
@ -56,7 +56,7 @@ use crate::{Network, PaymentKey, WalletError};
|
||||||
/// here match Conway-era mainnet as of 2026-Q2; supply your own via
|
/// here match Conway-era mainnet as of 2026-Q2; supply your own via
|
||||||
/// [`ProtocolParams::from_koios_response`] (TODO) if you want
|
/// [`ProtocolParams::from_koios_response`] (TODO) if you want
|
||||||
/// chain-fresh values.
|
/// chain-fresh values.
|
||||||
#[derive(Debug, Clone, Copy)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct ProtocolParams {
|
pub struct ProtocolParams {
|
||||||
/// Per-byte fee coefficient (a). Mainnet: 44.
|
/// Per-byte fee coefficient (a). Mainnet: 44.
|
||||||
pub min_fee_a: u64,
|
pub min_fee_a: u64,
|
||||||
|
|
@ -66,6 +66,19 @@ pub struct ProtocolParams {
|
||||||
/// formula is `coins_per_utxo_byte * tx_out_size`; we use the
|
/// formula is `coins_per_utxo_byte * tx_out_size`; we use the
|
||||||
/// 1 ADA floor as a safe over-approximation.
|
/// 1 ADA floor as a safe over-approximation.
|
||||||
pub min_utxo_lovelace: u64,
|
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 {
|
impl Default for ProtocolParams {
|
||||||
|
|
@ -74,16 +87,42 @@ impl Default for ProtocolParams {
|
||||||
min_fee_a: 44,
|
min_fee_a: 44,
|
||||||
min_fee_b: 155_381,
|
min_fee_b: 155_381,
|
||||||
min_utxo_lovelace: 1_000_000,
|
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 {
|
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 {
|
pub fn min_fee_for_size(&self, tx_size_bytes: u64) -> u64 {
|
||||||
self.min_fee_a
|
self.min_fee_a
|
||||||
.saturating_mul(tx_size_bytes)
|
.saturating_mul(tx_size_bytes)
|
||||||
.saturating_add(self.min_fee_b)
|
.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
|
/// One UTXO available for spending. Independently typed from
|
||||||
|
|
|
||||||
|
|
@ -28,6 +28,7 @@
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
use aldabra_chain::{ChainBackend, KoiosClient};
|
use aldabra_chain::{ChainBackend, KoiosClient};
|
||||||
|
use aldabra_core::plutus_cost_models::PLUTUS_V3_COST_MODEL_PREPROD;
|
||||||
use aldabra_core::{
|
use aldabra_core::{
|
||||||
add_witness, build_signed_cip68_nft_mint, build_signed_mint_with_metadata,
|
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,
|
build_signed_payment_with_assets, build_signed_plutus_spend, build_signed_stake_delegation,
|
||||||
|
|
@ -821,6 +822,13 @@ impl WalletService {
|
||||||
lovelace: locked_lovelace,
|
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(
|
let cbor = build_signed_plutus_spend(
|
||||||
&self.inner.payment_key,
|
&self.inner.payment_key,
|
||||||
self.inner.network,
|
self.inner.network,
|
||||||
|
|
@ -834,7 +842,7 @@ impl WalletService {
|
||||||
&payout_address,
|
&payout_address,
|
||||||
payout_lovelace,
|
payout_lovelace,
|
||||||
budget,
|
budget,
|
||||||
&ProtocolParams::default(),
|
¶ms,
|
||||||
)
|
)
|
||||||
.map_err(|e| format!("build/sign plutus spend: {e}"))?;
|
.map_err(|e| format!("build/sign plutus spend: {e}"))?;
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue