From 057f6233121f4c0800b0c8a7b4a2cf9e0694a77f Mon Sep 17 00:00:00 2001 From: Kayos Date: Tue, 5 May 2026 06:06:24 -0700 Subject: [PATCH] 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. --- crates/aldabra-core/src/tx.rs | 59 +++++++++++++++++++++++++++++++++-- 1 file changed, 57 insertions(+), 2 deletions(-) diff --git a/crates/aldabra-core/src/tx.rs b/crates/aldabra-core/src/tx.rs index acd9aae..2f9dec8 100644 --- a/crates/aldabra-core/src/tx.rs +++ b/crates/aldabra-core/src/tx.rs @@ -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();