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:
Kayos 2026-05-04 20:59:29 -07:00
parent 47b63f2024
commit e4914a14ba

View file

@ -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();