AUDIT4-2 fix: invert plutus collateral/funding utxo picker
build_signed_plutus_spend was picking the LARGEST ada-only utxo for collateral and the next-largest for funding. Wallets with one big change utxo + a small leftover (the typical shape after any send) hit this with funding=tiny, collateral=huge — funding+locked couldn't cover payout + script-execution fee + change min_utxo even with billions of lovelace sitting unused in collateral. Fix: pick the SMALLEST ada-only utxo that still qualifies (≥5 ADA) for collateral, and the LARGEST for funding. Collateral never gets consumed on the happy path, so its size beyond the 5-ADA floor is wasted budget; funding has to cover real spend. Surfaced 2026-05-04 audit-4 phase F2 on the deployed Lucy container against the always-succeeds Aiken validator. New regression test picks_smallest_qualifying_collateral_largest_funding covers the mixed-size-utxo scenario the prior tests missed (both old utxos were 50-100M ada, so the inversion didn't show).
This commit is contained in:
parent
47b63f2024
commit
e4914a14ba
1 changed files with 72 additions and 5 deletions
|
|
@ -147,9 +147,22 @@ pub fn build_signed_plutus_spend(
|
|||
.collect();
|
||||
ada_only.sort_by_key(|u| std::cmp::Reverse(u.lovelace));
|
||||
|
||||
// Pick collateral — largest ADA-only UTXO ≥ 5 ADA.
|
||||
// AUDIT4-2 fix: pick the SMALLEST ADA-only UTXO that still
|
||||
// qualifies for collateral (≥ 5 ADA), so the LARGEST stays
|
||||
// available for funding the spend. Previously we did the
|
||||
// inverse — collateral got the biggest utxo, funding got
|
||||
// whatever scrap was next, and a typical wallet (one big
|
||||
// change utxo + a tiny self-send leftover) couldn't cover
|
||||
// payout + fee + min_utxo even with billions of lovelace
|
||||
// sitting in the change. Surfaced 2026-05-04 audit-4 phase F2.
|
||||
//
|
||||
// Collateral is NEVER consumed on the happy path — it's only
|
||||
// seized if the script fails — so its size beyond the 5-ADA
|
||||
// floor is wasted budget. Funding, by contrast, must cover
|
||||
// (payout + fee + change_min_utxo), so we want it big.
|
||||
let collateral = ada_only
|
||||
.iter()
|
||||
.rev()
|
||||
.find(|u| u.lovelace >= MIN_COLLATERAL_LOVELACE)
|
||||
.ok_or_else(|| {
|
||||
WalletError::Derivation(format!(
|
||||
|
|
@ -163,10 +176,9 @@ pub fn build_signed_plutus_spend(
|
|||
// 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.
|
||||
// Pick the LARGEST ADA-only UTXO that's not the collateral —
|
||||
// funding has to cover payout + script-execution fee + change
|
||||
// min_utxo, which can run several ADA at default budgets.
|
||||
let funding = ada_only
|
||||
.iter()
|
||||
.find(|u| {
|
||||
|
|
@ -532,6 +544,61 @@ mod tests {
|
|||
assert!(witness.redeemer.is_some(), "redeemer witness present");
|
||||
}
|
||||
|
||||
/// AUDIT4-2 regression. A wallet with one tiny qualifying UTXO
|
||||
/// alongside one huge UTXO must pick the tiny one for collateral
|
||||
/// and the huge one for funding (not the inverse). Pre-fix, the
|
||||
/// huge UTXO became collateral and funding fell back to the
|
||||
/// tiny 5-ADA scrap, too small to cover payout, script-exec
|
||||
/// fee, and change min_utxo. Surfaced 2026-05-04 audit-4 phase F2.
|
||||
#[test]
|
||||
fn picks_smallest_qualifying_collateral_largest_funding() {
|
||||
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: 6_000_000,
|
||||
};
|
||||
// Two utxos: one barely-qualifying-for-collateral (5 ADA),
|
||||
// one huge (1000 ADA). Pre-fix would put the 1000 ADA into
|
||||
// collateral and the 5 ADA into funding — too small to
|
||||
// cover a 4 ADA payout + ~3 ADA fee+min_utxo. Post-fix,
|
||||
// collateral=5 ADA, funding=1000 ADA, builds cleanly.
|
||||
let utxos = vec![
|
||||
InputUtxo {
|
||||
tx_hash_hex: "aaaa".repeat(16),
|
||||
output_index: 0,
|
||||
lovelace: 1_000_000_000, // 1000 ADA
|
||||
assets: Default::default(),
|
||||
},
|
||||
InputUtxo {
|
||||
tx_hash_hex: "bbbb".repeat(16),
|
||||
output_index: 0,
|
||||
lovelace: 5_000_000, // exactly the floor
|
||||
assets: Default::default(),
|
||||
},
|
||||
];
|
||||
|
||||
let cbor = build_signed_plutus_spend(
|
||||
&payment,
|
||||
Network::Preprod,
|
||||
&locked,
|
||||
PlutusVersion::V3,
|
||||
&ALWAYS_TRUE_PLUTUS_V3_CBOR,
|
||||
&UNIT_REDEEMER_CBOR,
|
||||
None,
|
||||
&utxos,
|
||||
&change,
|
||||
&payout,
|
||||
4_000_000,
|
||||
DEFAULT_EX_UNITS,
|
||||
&ProtocolParams::default(),
|
||||
)
|
||||
.expect("post-fix plutus spend builds with mixed-size utxos");
|
||||
assert!(cbor.len() > 200);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_signed_plutus_spend_rejects_no_collateral() {
|
||||
let payment = payment_from_canonical();
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue