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:
parent
30761039ea
commit
057f623312
1 changed files with 57 additions and 2 deletions
|
|
@ -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();
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue