AUDIT5-1: relax coin selector for ada-only drain-to-fee

Two coupled fixes for the same root cause: the coin selector was too
conservative for "send most of what I have" cases.

1. min_change_required now drops to 0 for ada-only sends (kept at
   min_utxo_lovelace for asset-bearing sends where change has to
   carry leftover policy IDs). Downstream pass2 already folds
   sub-min change into fee on the ada-only happy path; the selector
   was reserving slack the chain doesn't actually need.

2. fee_pass1 dropped from 500_000 to 200_000. Real fees:
     1-in 1-out ada-only send : ~166 k
     1-in 2-out (with change) : ~178 k
     CIP-25 mint w/ metadata  : ~210 k
   500_000 was overgenerous safety budget. 200_000 is enough headroom
   for the basic-send case (which is the one that needed to drain to
   fee) without crowding mint paths (which typically have plenty of
   lovelace headroom anyway).

Surfaced 2026-05-05 zeroing out the mainnet test wallet:
2 ADA balance, 1.8 ADA send refused upstream as
"need 3300000 (target+fee+min_change), have 2000000"
even though the chain math was fine. New regression
ada_only_send_can_drain_to_fee covers the case.
This commit is contained in:
Kayos 2026-05-05 06:06:24 -07:00
parent 30761039ea
commit 057f623312

View file

@ -477,13 +477,41 @@ fn prepare_payment(
// Pass 1: pick inputs assuming a generous placeholder fee, then
// build *unsigned* to measure size. We add WITNESS_OVERHEAD_BYTES
// to account for the witness this tx will carry once signed.
let fee_pass1: u64 = 500_000;
// fee_pass1 is the selector's safety floor — must be ≥ the real
// fee that pass 2 computes from actual signed-tx size, otherwise
// "insufficient funds for fee" downstream. Real fees on this
// wallet's typical txs:
// 1-in 1-out ada-only send : ~166 k lovelace
// 1-in 2-out (with change) : ~178 k
// CIP-25 mint w/ metadata : ~210 k
// Setting 200_000 covers basic-send cases (which need to drain
// to fee on small balances — see ada_only_send_can_drain_to_fee).
// Mint paths typically have more lovelace headroom and won't
// hit the pass1 floor; if a mint does run tight, the downstream
// "insufficient funds for fee" error is informative.
// Was 500_000 — surfaced 2026-05-05 zeroing out the mainnet
// test wallet (1.8 ADA out of 2 ADA refused upstream).
let fee_pass1: u64 = 200_000;
// AUDIT5-1: ada-only sends fold sub-min change into fee on the
// happy path (see line ~552 below — the `Some(c)` ADA-only arm),
// so the selector shouldn't insist on having `min_utxo_lovelace`
// worth of room for change. Pass 0 when there are no asset
// leftovers; assets-bearing sends still need real change to
// route the leftover policy IDs, so keep min_utxo_lovelace there.
// Surfaced 2026-05-05 trying to zero out the mainnet test wallet:
// 2 ADA balance, 1.8 ADA send refused as "need 3.3M, have 2M"
// even though the chain math was fine.
let min_change_required = if target_assets.is_empty() {
0
} else {
params.min_utxo_lovelace
};
let inputs = select_utxos(
available_utxos,
lovelace,
&target_assets,
fee_pass1,
params.min_utxo_lovelace,
min_change_required,
)?;
let total_in_lovelace: u64 = inputs.iter().map(|u| u.lovelace).sum();
@ -1101,6 +1129,33 @@ mod tests {
assert_eq!(result.summary.change_assets[0].policy_id_hex, policy);
}
/// AUDIT5-1 regression: ada-only sends should be allowed to drain
/// a wallet down to "all of input - fee" without the selector
/// reserving min_utxo for a change output that ends up folded
/// into the fee anyway. Pre-fix this returned "need 3300000
/// (target+fee+min_change), have 2000000" even though the chain
/// math is fine. Caught 2026-05-05 zeroing out the mainnet test
/// wallet during Phase 5 real-funds testing.
#[test]
fn ada_only_send_can_drain_to_fee() {
let payment = payment_from_canonical();
let change = change_address(Network::Preprod);
let utxos = vec![single_ada_utxo(2_000_000)];
// 1.8 ADA out of 2 ADA total. Change after fee will be sub-
// min-utxo, but the ada-only fold-into-fee path absorbs it.
let cbor = build_signed_payment(
&payment,
Network::Preprod,
&utxos,
&change,
&to_address_preprod(),
1_800_000,
&ProtocolParams::default(),
)
.expect("ada-only drain builds when change folds into fee");
assert!(cbor.len() > 100);
}
#[test]
fn build_signed_payment_fails_without_funds() {
let payment = payment_from_canonical();