diff --git a/crates/aldabra-core/src/plutus.rs b/crates/aldabra-core/src/plutus.rs index d698e0d..3b46743 100644 --- a/crates/aldabra-core/src/plutus.rs +++ b/crates/aldabra-core/src/plutus.rs @@ -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();