diff --git a/key-wallet/src/test_utils/utxo.rs b/key-wallet/src/test_utils/utxo.rs index 5233fe08f..938a72f41 100644 --- a/key-wallet/src/test_utils/utxo.rs +++ b/key-wallet/src/test_utils/utxo.rs @@ -1,6 +1,6 @@ use std::ops::Range; -use dashcore::{Address, Network, OutPoint, ScriptBuf, TxOut, Txid}; +use dashcore::{Address, Network, OutPoint, TxOut, Txid}; use crate::Utxo; @@ -21,18 +21,15 @@ impl Utxo { .map(|(i, id)| { let outpoint = OutPoint::new(Txid::from([id; 32]), i as u32); + let address = Address::dummy(Network::Testnet, id as usize); + // The script the address pays to, so a dummy is a real P2PKH + // output (asset-lock builds refuse anything else). let txout = TxOut { value, - script_pubkey: ScriptBuf::new(), + script_pubkey: address.script_pubkey(), }; - let mut utxo = Utxo::new( - outpoint, - txout, - Address::dummy(Network::Testnet, id as usize), - height, - coinbase, - ); + let mut utxo = Utxo::new(outpoint, txout, address, height, coinbase); utxo.is_confirmed = confirmed; utxo }) diff --git a/key-wallet/src/wallet/managed_wallet_info/asset_lock_builder.rs b/key-wallet/src/wallet/managed_wallet_info/asset_lock_builder.rs index 322098b3c..e70825e9f 100644 --- a/key-wallet/src/wallet/managed_wallet_info/asset_lock_builder.rs +++ b/key-wallet/src/wallet/managed_wallet_info/asset_lock_builder.rs @@ -17,7 +17,12 @@ use crate::signer::Signer; use crate::wallet::managed_wallet_info::coin_selection::SelectionStrategy; use crate::wallet::managed_wallet_info::fee::FeeRate; use crate::wallet::managed_wallet_info::transaction_builder::{ - BuilderError, TransactionBuilder, TransactionSigner, + BuilderError, TransactionBuilder, TransactionSigner, Unselected, +}; +/// Asset-lock standardness limits, defined beside the transaction builder that +/// enforces them and re-exported here, where asset-lock callers look for them. +pub use crate::wallet::managed_wallet_info::transaction_builder::{ + MAX_ASSET_LOCK_INPUTS, MAX_ASSET_LOCK_TX_SIZE, }; use crate::wallet::managed_wallet_info::transaction_building::{ AccountTypePreference, PooledFunding, @@ -46,7 +51,8 @@ pub enum AssetLockFundingType { } /// A single wallet account that supplies the funding UTXOs (and signs the -/// inputs) of a whole-balance **drain** asset lock. +/// inputs) of a **drain** asset lock (the whole balance, or its +/// [`MAX_ASSET_LOCK_INPUTS`] largest UTXOs when the account holds more). /// /// The asset-lock builders take a *list* of [`AccountTypePreference`] sources /// and pool them; this is the narrower vocabulary of the drain flows, which @@ -166,6 +172,19 @@ pub struct AssetLockResult { /// bookkeeping scale with the address book while claiming contributions /// that never happened. pub funding_accounts: Vec, + /// How many of a **drain**'s candidates it left unspent — dropped by the + /// [`MAX_ASSET_LOCK_INPUTS`] cap or by the shrink to + /// [`MAX_ASSET_LOCK_TX_SIZE`]. Always `0` for an exact-amount build. + /// + /// Candidates are the funding accounts' final (confirmed or + /// InstantSend-locked), unreserved, spendable P2PKH UTXOs. `0` means the + /// drain spent all of *those*, not that the accounts are empty: + /// unconfirmed, reserved, immature or non-P2PKH coins are never counted. + /// See [`ManagedWalletInfo::build_asset_lock`]. + pub remaining_utxo_count: usize, + /// Total value, in duffs, of the UTXOs counted by + /// [`Self::remaining_utxo_count`]. + pub remaining_duffs: u64, } /// Errors specific to asset lock transaction building. @@ -362,6 +381,9 @@ struct SignedAssetLock { /// order; the first supplied the change address. offered: Vec, reservations: BuildReservations, + /// What a bounded drain left behind; zero otherwise. See + /// [`AssetLockResult::remaining_utxo_count`]. + left_behind: Unselected, } impl ManagedWalletInfo { @@ -394,9 +416,13 @@ impl ManagedWalletInfo { .set_special_payload(TransactionPayload::AssetLockPayloadType(AssetLockPayload::new( credit_outputs, ))) + // The asset-lock payload bounds selection at MAX_ASSET_LOCK_INPUTS + // and the signed size at MAX_ASSET_LOCK_TX_SIZE inside the builder. .require_final_inputs(); if drain { - builder = builder.set_selection_strategy(SelectionStrategy::All); + // This is the one path that reports what a bounded drain leaves + // behind, so it is the one that may do one. + builder = builder.set_selection_strategy(SelectionStrategy::All).bounded_drain(); } let PooledFunding { builder, @@ -414,12 +440,14 @@ impl ManagedWalletInfo { .map(|account| account.reservations().clone()) .collect(); - let (transaction, fee, token) = - builder.build_signed_reserved(signer, move |addr| paths.get(&addr).cloned()).await?; + let (transaction, fee, token, unselected) = builder + .build_signed_reserved_reporting(signer, move |addr| paths.get(&addr).cloned()) + .await?; let reserved: Vec = transaction.input.iter().map(|input| input.previous_output).collect(); - + // The builder already refused (and released) an oversized signed lock; + // `unselected` is non-zero only for the bounded drain set above. Ok(SignedAssetLock { transaction, fee, @@ -429,6 +457,7 @@ impl ManagedWalletInfo { reserved, token, }, + left_behind: unselected, }) } @@ -450,12 +479,55 @@ impl ManagedWalletInfo { /// [`ManagedWalletInfo::build_and_sign_transaction`] for the shared /// source-list semantics. /// - /// `drain` locks the sourced accounts' whole spendable balance: every final - /// UTXO is consumed and the single credit output's value is rewritten to + /// `drain` locks the sourced accounts' spendable balance: every final UTXO + /// is consumed (up to the limits below; the rest stays for a later lock) + /// and the single credit output's value is rewritten to /// `Σ inputs − fee` (the caller's credit-output value is ignored; exactly /// one credit output is required). CoinJoin funding is drain-only and must /// be the sole source: pooling mixed outputs with transparent ones in one /// transaction links them and undoes the mixing. + /// + /// # Asset-lock limits and the bounded drain + /// + /// This is the canonical description; the constants, the builder flag and + /// [`AssetLockResult`] link here. + /// + /// * **Input cap.** Dash Core relays an asset lock with at most + /// [`MAX_ASSET_LOCK_INPUTS`] inputs, and Platform accepts no more. Over + /// it, peers drop the transaction silently, so it is never built. + /// * **Size limit.** Core also refuses an asset lock larger than + /// [`MAX_ASSET_LOCK_TX_SIZE`]. It is checked on an upper bound of the + /// signed size (P2PKH inputs with compressed keys and low-S signatures) + /// before anything is reserved or signed, and again on the signed bytes; + /// either failure is [`BuilderError::AssetLockTooLarge`]. Only P2PKH + /// UTXOs are offered as inputs. + /// * **Bounded drain.** Over either limit, a drain spends the largest + /// candidates that fit and leaves the smallest. A drain may therefore be + /// partial, and only [`AssetLockResult::remaining_utxo_count`] / + /// [`AssetLockResult::remaining_duffs`] say so: callers must read them. + /// A follow-up drain of what remains may fall below the target + /// transition's minimum lock amount. This and + /// [`Self::build_asset_lock_with_signer`] are the only bounded-drain + /// paths; a drain through the generic [`TransactionBuilder`] fails with + /// [`BuilderError::TooManyInputs`] over the cap instead. + /// * **Exact amount.** A non-drain build stays within the cap and, if + /// needed, retries with fewer, larger inputs to fit the size limit. + /// It never drops a change output to fit: change is only emitted above + /// the dust threshold, so dropping it would burn that value as fee. A + /// lock that exceeds the limit only by its change output (34 bytes) + /// fails with [`BuilderError::AssetLockTooLarge`]; lower the amount so + /// no change is needed, or use a drain. + /// * **Transition size.** The limits make the lock relayable by Core and + /// within Platform's input cap; they do not bound a state transition that + /// embeds it. An InstantSend asset-lock proof carries the whole + /// transaction and an IS lock listing every spent outpoint, so for a lock + /// near the cap the transition can exceed Platform's size limit. Use a + /// ChainLock proof then (cf. `dashpay/platform#5460`). + /// + /// A non-drain build spends at most [`MAX_ASSET_LOCK_INPUTS`] inputs; when + /// the amount cannot be funded within that many it fails with + /// [`BuilderError::TooManyInputs`] (`count` = the fewest inputs it needs) + /// and reserves nothing. pub async fn build_asset_lock( &mut self, wallet: &Wallet, @@ -480,6 +552,7 @@ impl ManagedWalletInfo { fee, offered, reservations, + left_behind, } = self .build_signed_asset_lock( wallet, @@ -523,6 +596,8 @@ impl ManagedWalletInfo { keys: AssetLockCreditKeys::Private(keys), reservation_token: reservations.token, funding_accounts, + remaining_utxo_count: left_behind.utxo_count, + remaining_duffs: left_behind.duffs, }) } @@ -542,6 +617,9 @@ impl ManagedWalletInfo { /// request signatures from the same signer when later consuming the /// credits on Platform. /// + /// The asset-lock limits and the bounded drain apply exactly as described + /// there. + /// /// `funding_sources` / `source_index` / `drain` — see /// [`Self::build_asset_lock`]. #[allow(clippy::too_many_arguments)] @@ -563,6 +641,7 @@ impl ManagedWalletInfo { fee, offered, reservations, + left_behind, } = self .build_signed_asset_lock( wallet, @@ -641,6 +720,8 @@ impl ManagedWalletInfo { keys: AssetLockCreditKeys::Public(credit_output_keys), reservation_token: reservations.token, funding_accounts, + remaining_utxo_count: left_behind.utxo_count, + remaining_duffs: left_behind.duffs, }) } } @@ -690,13 +771,25 @@ mod tests { value: u64, is_confirmed: bool, ) -> OutPoint { - let account_xpub = wallet.get_bip44_account(0).unwrap().account_xpub; - let account = info.accounts.standard_bip44_accounts.get_mut(&0).unwrap(); - let funding_address = account.next_receive_address(Some(&account_xpub), true).unwrap(); let outpoint = OutPoint { txid: Txid::from_byte_array([txid_byte; 32]), vout: 0, }; + insert_funded_utxo_at(info, wallet, outpoint, value, is_confirmed) + } + + /// [`insert_funded_utxo`] at a caller-chosen outpoint, for fixtures that + /// need more UTXOs than a one-byte txid can tell apart. + fn insert_funded_utxo_at( + info: &mut ManagedWalletInfo, + wallet: &Wallet, + outpoint: OutPoint, + value: u64, + is_confirmed: bool, + ) -> OutPoint { + let account_xpub = wallet.get_bip44_account(0).unwrap().account_xpub; + let account = info.accounts.standard_bip44_accounts.get_mut(&0).unwrap(); + let funding_address = account.next_receive_address(Some(&account_xpub), true).unwrap(); let utxo = Utxo { outpoint, txout: TxOut { @@ -723,6 +816,21 @@ mod tests { txid_byte: u8, value: u64, is_confirmed: bool, + ) -> OutPoint { + let outpoint = OutPoint { + txid: Txid::from_byte_array([txid_byte; 32]), + vout: 0, + }; + insert_funded_coinjoin_utxo_at(info, wallet, outpoint, value, is_confirmed) + } + + /// [`insert_funded_coinjoin_utxo`] at a caller-chosen outpoint. + fn insert_funded_coinjoin_utxo_at( + info: &mut ManagedWalletInfo, + wallet: &Wallet, + outpoint: OutPoint, + value: u64, + is_confirmed: bool, ) -> OutPoint { let account_xpub = wallet.get_coinjoin_account(0).unwrap().account_xpub; let account = info.accounts.coinjoin_accounts.get_mut(&0).unwrap(); @@ -737,10 +845,6 @@ mod tests { .unwrap(), _ => unreachable!("coinjoin account must carry the CoinJoin managed type"), }; - let outpoint = OutPoint { - txid: Txid::from_byte_array([txid_byte; 32]), - vout: 0, - }; let utxo = Utxo { outpoint, txout: TxOut { @@ -759,7 +863,7 @@ mod tests { outpoint } - /// Drain from the CoinJoin account: every CoinJoin UTXO is consumed, the + /// Drain from the CoinJoin account (under the input cap): every CoinJoin UTXO is consumed, the /// single credit output's value is rewritten to `Σ inputs − fee`, the /// on-chain burn mirrors it exactly, and there is no change output. #[tokio::test] @@ -804,6 +908,403 @@ mod tests { } } + // -- Input cap (MAX_ASSET_LOCK_INPUTS) --------------------------------- + // + // Dash Core drops an asset lock with more than 100 inputs as non-standard + // without a reject, so an over-cap build only ever surfaces as a broadcast + // that never confirms. Every asset-lock build must stay within the cap. + + /// Outpoint `i` of a synthetic funding transaction (`vout = i`). + fn nth_outpoint(i: u32) -> OutPoint { + OutPoint { + txid: Txid::from_byte_array([0xC5; 32]), + vout: i, + } + } + + /// Fund CoinJoin account 0 with `count` confirmed UTXOs valued + /// `base + i * step`, returning `(outpoint, value)` pairs. + fn fund_coinjoin_ladder( + info: &mut ManagedWalletInfo, + wallet: &Wallet, + count: u32, + base: u64, + step: u64, + ) -> Vec<(OutPoint, u64)> { + (0..count) + .map(|i| { + let value = base + u64::from(i) * step; + (insert_funded_coinjoin_utxo_at(info, wallet, nth_outpoint(i), value, true), value) + }) + .collect() + } + + fn coinjoin_reserved(info: &ManagedWalletInfo) -> HashSet { + info.accounts.coinjoin_accounts.get(&0).unwrap().reservations().reserved(1100) + } + + /// The ticket-32500 shape: more mixed UTXOs than Core accepts in one asset + /// lock. The drain takes exactly the 100 largest, locks their sum minus the + /// fee, reserves only them, and leaves the rest spendable for a later lock. + #[tokio::test] + async fn drain_over_the_input_cap_takes_the_largest() { + let (wallet, mut info) = test_wallet_and_info(); + let funded = fund_coinjoin_ladder(&mut info, &wallet, 104, 100_000, 1_000); + // Largest of all but unconfirmed: neither spent nor counted as left behind. + insert_funded_coinjoin_utxo(&mut info, &wallet, 0x77, 5_000_000, false); + info.update_last_processed_height(1100); + + let result = info + .build_asset_lock( + &wallet, + &[AccountTypePreference::CoinJoin], + 0, + test_credit_outputs(&[0]), + 1000, + true, + ) + .await + .expect("a bounded drain over 104 UTXOs must build"); + + let mut by_value = funded.clone(); + by_value.sort_by_key(|(_, value)| std::cmp::Reverse(*value)); + let largest: HashSet = + by_value.iter().take(MAX_ASSET_LOCK_INPUTS).map(|(op, _)| *op).collect(); + let left_out: HashSet = + by_value.iter().skip(MAX_ASSET_LOCK_INPUTS).map(|(op, _)| *op).collect(); + let largest_sum: u64 = by_value.iter().take(MAX_ASSET_LOCK_INPUTS).map(|(_, v)| v).sum(); + + let spent: HashSet = + result.transaction.input.iter().map(|txin| txin.previous_output).collect(); + assert_eq!(result.transaction.input.len(), MAX_ASSET_LOCK_INPUTS); + assert_eq!(spent, largest, "the drain must spend exactly the 100 largest UTXOs"); + + let burn = &result.transaction.output[0]; + assert_eq!(result.transaction.output.len(), 1, "drain emits no change output"); + assert_eq!(burn.value, largest_sum - result.fee, "burn must carry Σ(100 largest) − fee"); + match &result.transaction.special_transaction_payload { + Some(TransactionPayload::AssetLockPayloadType(p)) => { + assert_eq!(p.credit_outputs[0].value, burn.value) + } + other => panic!("expected asset-lock payload, got {:?}", other), + } + assert!(result.transaction.size() <= MAX_ASSET_LOCK_TX_SIZE); + for (i, txin) in result.transaction.input.iter().enumerate() { + assert!(!txin.script_sig.is_empty(), "input {i} not signed"); + } + + // Only the spent inputs are reserved; the rest stay free and in the account. + assert_eq!(coinjoin_reserved(&info), largest); + let left_out_sum: u64 = by_value.iter().skip(MAX_ASSET_LOCK_INPUTS).map(|(_, v)| v).sum(); + assert_eq!(result.remaining_utxo_count, left_out.len()); + assert_eq!(result.remaining_duffs, left_out_sum); + let account = info.accounts.coinjoin_accounts.get(&0).unwrap(); + assert!(left_out.iter().all(|op| account.utxos.contains_key(op))); + } + + /// A bounded drain whose 100 largest would exceed Core's size limit (here a + /// large credit-output script) drops its smallest inputs until it fits, + /// and reports them as left behind. + #[tokio::test] + async fn drain_shrinks_to_the_size_limit_and_reports_the_rest() { + let (wallet, mut info) = test_wallet_and_info(); + let funded = fund_coinjoin_ladder(&mut info, &wallet, 100, 100_000, 1_000); + info.update_last_processed_height(1100); + let mut fundings = test_credit_outputs(&[0]); + fundings[0].output.script_pubkey = ScriptBuf::from(vec![0x51; 8_000]); + + let result = info + .build_asset_lock(&wallet, &[AccountTypePreference::CoinJoin], 0, fundings, 1000, true) + .await + .expect("a bounded drain shrinks rather than failing on size"); + + let spent = result.transaction.input.len(); + assert!(spent < MAX_ASSET_LOCK_INPUTS); + assert!(result.transaction.size() <= MAX_ASSET_LOCK_TX_SIZE); + // Ladder index = value rank, so the dropped ones are the lowest vouts. + let dropped = MAX_ASSET_LOCK_INPUTS - spent; + assert!(result + .transaction + .input + .iter() + .all(|txin| txin.previous_output.vout as usize >= dropped)); + assert_eq!(result.remaining_utxo_count, dropped); + assert_eq!( + result.remaining_duffs, + funded.iter().take(dropped).map(|(_, v)| v).sum::() + ); + } + + /// At exactly the cap a drain is unchanged: every UTXO is spent. + #[tokio::test] + async fn drain_at_the_input_cap_spends_everything() { + let (wallet, mut info) = test_wallet_and_info(); + let funded = + fund_coinjoin_ladder(&mut info, &wallet, MAX_ASSET_LOCK_INPUTS as u32, 100_000, 1_000); + info.update_last_processed_height(1100); + + let result = info + .build_asset_lock( + &wallet, + &[AccountTypePreference::CoinJoin], + 0, + test_credit_outputs(&[0]), + 1000, + true, + ) + .await + .expect("a drain of exactly 100 UTXOs must build"); + + let total: u64 = funded.iter().map(|(_, v)| v).sum(); + assert_eq!(result.transaction.input.len(), MAX_ASSET_LOCK_INPUTS); + assert_eq!(result.transaction.output[0].value, total - result.fee); + assert_eq!((result.remaining_utxo_count, result.remaining_duffs), (0, 0)); + } + + /// The signer-driven builder applies the same bound. + #[tokio::test] + async fn signer_drain_over_the_input_cap_takes_the_largest() { + let (wallet, mut info) = test_wallet_and_info(); + fund_coinjoin_ladder(&mut info, &wallet, 103, 100_000, 1_000); + info.update_last_processed_height(1100); + let root = match &wallet.wallet_type { + crate::wallet::WalletType::Mnemonic { + root_extended_private_key, + .. + } => root_extended_private_key.clone(), + _ => unreachable!("test_wallet_and_info produces a mnemonic wallet"), + }; + let signer = InMemorySigner { + root, + network: Network::Testnet, + }; + + let result = info + .build_asset_lock_with_signer( + &wallet, + &[AccountTypePreference::CoinJoin], + 0, + test_credit_outputs(&[0]), + 1000, + true, + &signer, + ) + .await + .expect("a bounded drain via the signer must build"); + assert_eq!(result.transaction.input.len(), MAX_ASSET_LOCK_INPUTS); + // Ladder values start at 100_000 and step 1_000: the three smallest stay. + assert!(result.transaction.input.iter().all(|txin| txin.previous_output.vout >= 3)); + assert_eq!(result.remaining_utxo_count, 3); + assert_eq!(result.remaining_duffs, 100_000 + 101_000 + 102_000); + } + + /// An exact-amount lock that cannot be funded within 100 inputs is refused + /// with a typed error carrying how many it would need — and reserves nothing. + #[tokio::test] + async fn exact_amount_needing_more_than_the_cap_is_refused() { + let (wallet, mut info) = test_wallet_and_info(); + for i in 0..150 { + insert_funded_utxo_at(&mut info, &wallet, nth_outpoint(i), 10_000, true); + } + info.update_last_processed_height(1100); + + let result = info + .build_asset_lock( + &wallet, + &[AccountTypePreference::BIP44], + 0, + test_credit_outputs(&[1_200_000]), + 1000, + false, + ) + .await; + + match result { + Err(AssetLockError::Builder(BuilderError::TooManyInputs { + count, + max, + })) => { + assert_eq!(max, MAX_ASSET_LOCK_INPUTS); + assert!(count > MAX_ASSET_LOCK_INPUTS, "count {count} must report the need"); + } + other => panic!("expected TooManyInputs, got {:?}", other.err()), + } + assert!(reserved_outpoints(&info).is_empty(), "a refused build must reserve nothing"); + } + + /// An exact-amount lock that fits within the cap still builds, with change. + #[tokio::test] + async fn exact_amount_within_the_cap_builds() { + let (wallet, mut info) = test_wallet_and_info(); + for i in 0..150 { + insert_funded_utxo_at(&mut info, &wallet, nth_outpoint(i), 10_000, true); + } + info.update_last_processed_height(1100); + + let result = info + .build_asset_lock( + &wallet, + &[AccountTypePreference::BIP44], + 0, + test_credit_outputs(&[500_000]), + 1000, + false, + ) + .await + .expect("a 500k lock from 10k UTXOs needs ~51 inputs"); + assert!(result.transaction.input.len() <= MAX_ASSET_LOCK_INPUTS); + assert_eq!(result.transaction.output[0].value, 500_000); + assert_eq!(reserved_outpoints(&info).len(), result.transaction.input.len()); + } + + /// An asset lock whose estimated signed size is over Core's standard limit + /// is refused before anything is reserved or signed. + #[tokio::test] + async fn oversized_asset_lock_is_refused_before_signing() { + let (wallet, mut info) = test_wallet_and_info(); + insert_funded_utxo(&mut info, &wallet, 0x11, 10_000_000, true); + info.update_last_processed_height(1100); + + // One credit output whose script alone is over the limit. + let mut fundings = test_credit_outputs(&[200_000]); + fundings[0].output.script_pubkey = ScriptBuf::from(vec![0x51; MAX_ASSET_LOCK_TX_SIZE]); + + let result = info + .build_asset_lock(&wallet, &[AccountTypePreference::BIP44], 0, fundings, 1000, false) + .await; + match result { + Err(AssetLockError::Builder(BuilderError::AssetLockTooLarge { + size, + max, + })) => { + assert_eq!(max, MAX_ASSET_LOCK_TX_SIZE); + assert!(size > MAX_ASSET_LOCK_TX_SIZE); + } + other => panic!("expected AssetLockTooLarge, got {:?}", other.err()), + } + assert!(reserved_outpoints(&info).is_empty(), "a refused build must reserve nothing"); + } + + /// A signer whose scriptSigs are larger than the pre-sign bound assumes + /// (e.g. uncompressed keys) can push a signed asset lock over Core's size + /// limit. The signed size is re-checked, and the inputs are given back. + #[tokio::test] + async fn oversized_signatures_are_caught_after_signing_and_released() { + struct BloatedSigner; + + #[async_trait::async_trait] + impl TransactionSigner for BloatedSigner { + async fn sign_tx( + &self, + mut tx: Transaction, + _inputs: Vec, + _path_resolver: impl Fn(dashcore::Address) -> Option + Send, + ) -> Result { + for input in &mut tx.input { + input.script_sig = ScriptBuf::from(vec![0x51; 300]); + } + Ok(tx) + } + + async fn sig_and_pubkey( + &self, + _sighash: dashcore::sighash::LegacySighash, + _path: DerivationPath, + ) -> Result<(secp256k1::ecdsa::Signature, PublicKey), BuilderError> { + unreachable!("sign_tx is overridden") + } + } + + let (wallet, mut info) = test_wallet_and_info(); + fund_coinjoin_ladder(&mut info, &wallet, 100, 100_000, 1_000); + info.update_last_processed_height(1100); + + // 100 inputs pass the pre-sign bound (~15 KB) but sign to ~34 KB. + let result = info + .build_signed_asset_lock( + &wallet, + &[AccountTypePreference::CoinJoin], + 0, + test_credit_outputs(&[0]).into_iter().map(|f| f.output).collect(), + 1000, + true, + &BloatedSigner, + ) + .await; + match result { + Err(AssetLockError::Builder(BuilderError::AssetLockTooLarge { + size, + max, + })) => { + assert_eq!(max, MAX_ASSET_LOCK_TX_SIZE); + assert!(size > MAX_ASSET_LOCK_TX_SIZE); + } + Err(other) => panic!("expected AssetLockTooLarge, got {other:?}"), + Ok(_) => panic!("an oversized signed asset lock must be refused"), + } + assert!(coinjoin_reserved(&info).is_empty(), "the reservation must be released"); + } + + /// A non-P2PKH funding UTXO is simply not offered to an asset lock (the + /// size bound prices every input as P2PKH): one odd coin must not block + /// asset locks from the whole account, nor count as left behind. + #[tokio::test] + async fn non_p2pkh_funding_is_not_offered() { + let (wallet, mut info) = test_wallet_and_info(); + let good = insert_funded_utxo(&mut info, &wallet, 0x11, 1_000_000, true); + let odd = insert_funded_utxo(&mut info, &wallet, 0x22, 5_000_000, true); + info.accounts + .standard_bip44_accounts + .get_mut(&0) + .unwrap() + .utxos + .get_mut(&odd) + .unwrap() + .txout + .script_pubkey = ScriptBuf::from(vec![0x51]); + info.update_last_processed_height(1100); + + let result = info + .build_asset_lock( + &wallet, + &[AccountTypePreference::BIP44], + 0, + test_credit_outputs(&[0]), + 1000, + true, + ) + .await + .expect("the P2PKH coin alone funds the drain"); + let spent: Vec = + result.transaction.input.iter().map(|txin| txin.previous_output).collect(); + assert_eq!(spent, vec![good]); + assert_eq!((result.remaining_utxo_count, result.remaining_duffs), (0, 0)); + assert_eq!(reserved_outpoints(&info), HashSet::from([good])); + } + + /// An exact-amount build never reports anything left behind, even when the + /// account holds UTXOs it did not need. + #[tokio::test] + async fn exact_amount_reports_nothing_left_behind() { + let (wallet, mut info) = test_wallet_and_info(); + insert_funded_utxo(&mut info, &wallet, 0x11, 1_000_000, true); + insert_funded_utxo(&mut info, &wallet, 0x22, 1_000_000, true); + info.update_last_processed_height(1100); + + let result = info + .build_asset_lock( + &wallet, + &[AccountTypePreference::BIP44], + 0, + test_credit_outputs(&[200_000]), + 1000, + false, + ) + .await + .expect("funded exact-amount lock"); + assert_eq!((result.remaining_utxo_count, result.remaining_duffs), (0, 0)); + } + /// A drain build requires exactly one credit output — anything else is /// rejected before any wallet state is touched. #[tokio::test] diff --git a/key-wallet/src/wallet/managed_wallet_info/coin_selection.rs b/key-wallet/src/wallet/managed_wallet_info/coin_selection.rs index e30477116..918458176 100644 --- a/key-wallet/src/wallet/managed_wallet_info/coin_selection.rs +++ b/key-wallet/src/wallet/managed_wallet_info/coin_selection.rs @@ -111,7 +111,10 @@ pub enum SelectionStrategy { /// Random selection for privacy Random, /// Select EVERY spendable UTXO (drain / sweep an account empty). There is no change: the - /// single deliverable output is worth the total minus the fee to spend them all + /// single deliverable output is worth the total minus the fee to spend them all. + /// + /// Under [`CoinSelector::with_max_inputs`] this is a *bounded* drain: only the `max` + /// largest spendable UTXOs are selected and the rest stay unspent. All, } @@ -157,6 +160,9 @@ pub struct SelectionResult { pub struct CoinSelector { strategy: SelectionStrategy, dust_threshold: u64, + /// Upper bound on the number of inputs a selection may use. See + /// [`Self::with_max_inputs`]. + max_inputs: Option, } impl CoinSelector { @@ -164,9 +170,34 @@ impl CoinSelector { Self { strategy, dust_threshold: 546, // Standard dust threshold + max_inputs: None, } } + /// Bound the number of inputs a selection may use. + /// + /// * [`SelectionStrategy::All`] becomes a *bounded drain*: when more than + /// `max` spendable UTXOs are offered it keeps the `max` largest by value + /// (ties broken by outpoint, so the pick is deterministic) and drains + /// only those; the rest are left unspent. With `max` or fewer it drains + /// everything, exactly as without a bound. + /// * Every other strategy runs unchanged first. If its pick exceeds `max`, + /// or it reports insufficient funds (a strategy that spends small UTXOs + /// first can pay more in fees than they add, where largest-first would + /// fund the target), selection falls back to largest-first accumulation, + /// which reaches any reachable target with the fewest inputs possible. + /// When even that needs more than `max` inputs, the minimum-count + /// selection is returned as is — its length is how many inputs the + /// target really needs — and the caller must reject it (the transaction + /// builder does, with `BuilderError::TooManyInputs`). + /// + /// A `max` of `0` is treated as `1`: no strategy can fund anything with + /// zero inputs, and a drain must not report "no UTXOs" while some exist. + pub fn with_max_inputs(mut self, max: usize) -> Self { + self.max_inputs = Some(max.max(1)); + self + } + /// Set dust threshold pub fn with_dust_threshold(mut self, threshold: u64) -> Self { self.dust_threshold = threshold; @@ -210,12 +241,117 @@ impl CoinSelector { base_size: usize, change_output_size: usize, ) -> Result + where + I: IntoIterator, + { + let Some(max_inputs) = self.max_inputs else { + return self.select_with_strategy( + self.strategy, + utxos, + target_amount, + fee_rate, + current_height, + base_size, + change_output_size, + ); + }; + + match self.strategy { + SelectionStrategy::All => { + let mut spendable: Vec<&'a Utxo> = + utxos.into_iter().filter(|u| u.is_spendable(current_height)).collect(); + if spendable.len() > max_inputs { + // Bounded drain: the `max_inputs` largest carry the most + // value for the same input count, hence the same fee. + // Partition rather than sort; the outpoint tie-break makes + // the order total, so the kept set does not depend on input + // order. The order within it does not matter: the builder + // re-sorts inputs (BIP-69). + // `with_max_inputs` keeps `max_inputs >= 1`. + spendable.select_nth_unstable_by(max_inputs - 1, |a, b| { + b.value().cmp(&a.value()).then_with(|| a.outpoint.cmp(&b.outpoint)) + }); + spendable.truncate(max_inputs); + } + self.select_with_strategy( + SelectionStrategy::All, + spendable, + target_amount, + fee_rate, + current_height, + base_size, + change_output_size, + ) + } + strategy => { + // References only, collected once so the fallback can reuse + // them; no UTXO is cloned unless selected. + let pool: Vec<&'a Utxo> = utxos.into_iter().collect(); + let first = self.select_with_strategy( + strategy, + pool.iter().copied(), + target_amount, + fee_rate, + current_height, + base_size, + change_output_size, + ); + if strategy == SelectionStrategy::LargestFirst { + // Already the fewest-inputs pick; nothing to fall back to. + return first; + } + // The cheapest any selection can be: one input, no change. + let floor = + target_amount.saturating_add(fee_rate.calculate_fee( + base_size.saturating_sub(change_output_size) + TX_INPUT_SIZE, + )); + match first { + Ok(result) if result.selected.len() <= max_inputs => return Ok(result), + // Over the bound: largest-first may fit within it. + Ok(_) => {} + // Short because small inputs cost more than they add, where + // largest-first would fund it. Not worth retrying when the + // whole spendable pool is below even a one-input spend. + Err(SelectionError::InsufficientFunds { + available, + .. + }) if available >= floor => {} + Err(other) => return Err(other), + } + // The k largest UTXOs out-value every other k-subset, and the + // fee depends only on k, so largest-first funds the target with + // the fewest inputs any selection can. + self.select_with_strategy( + SelectionStrategy::LargestFirst, + pool, + target_amount, + fee_rate, + current_height, + base_size, + change_output_size, + ) + } + } + } + + /// [`Self::select_coins_with_size`] for one strategy, ignoring `max_inputs`. + #[allow(clippy::too_many_arguments)] + fn select_with_strategy<'a, I>( + &self, + strategy: SelectionStrategy, + utxos: I, + target_amount: u64, + fee_rate: FeeRate, + current_height: u32, + base_size: usize, + change_output_size: usize, + ) -> Result where I: IntoIterator, { // For strategies that need sorting, we must collect // For others, we can work with iterators directly - match self.strategy { + match strategy { SelectionStrategy::SmallestFirst | SelectionStrategy::LargestFirst | SelectionStrategy::SmallestFirstTill(_) @@ -238,7 +374,7 @@ impl CoinSelector { }); } - match self.strategy { + match strategy { SelectionStrategy::SmallestFirst => { available.sort_by_key(|u| u.value()); self.accumulate_coins_with_size( @@ -800,8 +936,171 @@ impl std::error::Error for SelectionError {} #[cfg(test)] mod tests { use super::*; + use dashcore::OutPoint; use test_case::test_case; + /// `n` confirmed UTXOs with distinct outpoints, valued `base + i * step`. + fn ladder(n: u8, base: u64, step: u64) -> Vec { + (0..n).map(|i| Utxo::dummy(i, base + u64::from(i) * step, 100, false, true)).collect() + } + + /// A bounded drain over more UTXOs than the bound keeps exactly the `max` + /// largest and prices the fee for those alone. + #[test] + fn test_select_all_bounded_keeps_the_largest() { + let utxos = ladder(8, 10_000, 1_000); + let selector = CoinSelector::new(SelectionStrategy::All).with_max_inputs(5); + let result = selector.select_coins(&utxos, 0, FeeRate::new(1000), 200).unwrap(); + + let mut values: Vec = result.selected.iter().map(|u| u.value()).collect(); + values.sort_unstable(); + assert_eq!(values, vec![13_000, 14_000, 15_000, 16_000, 17_000]); + assert_eq!(result.total_value, 75_000); + let default_base_size = 10 + TX_OUTPUT_SIZE + CHANGE_OUTPUT_SIZE; + assert_eq!(result.estimated_size, default_base_size + 5 * TX_INPUT_SIZE); + assert_eq!(result.target_amount, 75_000 - result.estimated_fee); + } + + /// At or under the bound a bounded drain is an ordinary drain. + #[test] + fn test_select_all_bounded_under_the_bound_drains_everything() { + let utxos = ladder(5, 10_000, 1_000); + let bounded = CoinSelector::new(SelectionStrategy::All) + .with_max_inputs(5) + .select_coins(&utxos, 0, FeeRate::new(1000), 200) + .unwrap(); + let unbounded = CoinSelector::new(SelectionStrategy::All) + .select_coins(&utxos, 0, FeeRate::new(1000), 200) + .unwrap(); + assert_eq!(bounded.selected.len(), 5); + assert_eq!(bounded.estimated_fee, unbounded.estimated_fee); + assert_eq!(bounded.target_amount, unbounded.target_amount); + } + + /// Equal values must not make the bounded pick depend on input order. + #[test] + fn test_select_all_bounded_is_deterministic_on_ties() { + let utxos = ladder(6, 10_000, 0); + let mut reversed = utxos.clone(); + reversed.reverse(); + let selector = CoinSelector::new(SelectionStrategy::All).with_max_inputs(3); + let pick = |set: &[Utxo]| -> Vec { + let mut outpoints: Vec = selector + .select_coins(set, 0, FeeRate::new(1000), 200) + .unwrap() + .selected + .iter() + .map(|u| u.outpoint) + .collect(); + outpoints.sort(); + outpoints + }; + assert_eq!(pick(&utxos), pick(&reversed)); + } + + /// A bounded drain only counts spendable UTXOs against the bound: an + /// immature coinbase must not displace a spendable coin. + #[test] + fn test_select_all_bounded_ignores_unspendable() { + let mut utxos = ladder(3, 10_000, 1_000); + // Immature coinbase, far larger than anything spendable. + utxos.push(Utxo::dummy(200, 1_000_000, 190, true, true)); + let result = CoinSelector::new(SelectionStrategy::All) + .with_max_inputs(3) + .select_coins(&utxos, 0, FeeRate::new(1000), 200) + .unwrap(); + assert_eq!(result.total_value, 33_000); + } + + /// A strategy whose pick exceeds the bound falls back to largest-first, + /// which funds the target with the fewest inputs. + #[test] + fn test_bounded_selection_falls_back_to_largest_first() { + let utxos = ladder(20, 10_000, 1_000); + let selector = CoinSelector::new(SelectionStrategy::SmallestFirst).with_max_inputs(4); + let result = selector.select_coins(&utxos, 80_000, FeeRate::new(1000), 200).unwrap(); + assert!(result.selected.len() <= 4, "picked {} inputs", result.selected.len()); + assert!(result.total_value >= 80_000 + result.estimated_fee); + + // Unbounded, the same strategy really does need more than the bound. + let unbounded = CoinSelector::new(SelectionStrategy::SmallestFirst) + .select_coins(&utxos, 80_000, FeeRate::new(1000), 200) + .unwrap(); + assert!(unbounded.selected.len() > 4); + } + + /// A strategy that fails outright — smallest-first burns more in fees on + /// dust inputs than they add — still gets the largest-first fallback. + #[test] + fn test_bounded_selection_falls_back_when_the_strategy_runs_short() { + let mut utxos: Vec = (0..50).map(|i| Utxo::dummy(i, 100, 100, false, true)).collect(); + utxos.push(Utxo::dummy(50, 50_000, 100, false, true)); + utxos.push(Utxo::dummy(51, 50_000, 100, false, true)); + + let unbounded = CoinSelector::new(SelectionStrategy::SmallestFirst).select_coins( + &utxos, + 99_000, + FeeRate::new(1000), + 200, + ); + assert!(matches!(unbounded, Err(SelectionError::InsufficientFunds { .. }))); + + let bounded = CoinSelector::new(SelectionStrategy::SmallestFirst) + .with_max_inputs(10) + .select_coins(&utxos, 99_000, FeeRate::new(1000), 200) + .expect("largest-first funds it with the two 50k inputs"); + assert_eq!(bounded.selected.len(), 2); + assert_eq!(bounded.total_value, 100_000); + } + + /// A bound of zero is treated as one, for every strategy: a drain must not + /// report "no UTXOs" while spendable ones exist. + #[test] + fn test_zero_bound_is_treated_as_one() { + let utxos = ladder(3, 10_000, 1_000); + let drain = CoinSelector::new(SelectionStrategy::All) + .with_max_inputs(0) + .select_coins(&utxos, 0, FeeRate::new(1000), 200) + .expect("a zero-bounded drain takes the single largest UTXO"); + assert_eq!(drain.selected.len(), 1); + assert_eq!(drain.total_value, 12_000); + + let exact = CoinSelector::new(SelectionStrategy::SmallestFirst) + .with_max_inputs(0) + .select_coins(&utxos, 5_000, FeeRate::new(1000), 200) + .expect("one input funds 5k"); + assert_eq!(exact.selected.len(), 1); + } + + /// A pool that cannot fund even a one-input spend fails with the first + /// strategy's error; the bounded fallback changes nothing there. + #[test] + fn test_bounded_selection_short_pool_reports_insufficient_funds() { + let utxos = ladder(5, 10_000, 0); + let result = CoinSelector::new(SelectionStrategy::SmallestFirst) + .with_max_inputs(3) + .select_coins(&utxos, 60_000, FeeRate::new(1000), 200); + assert!(matches!( + result, + Err(SelectionError::InsufficientFunds { + available: 50_000, + .. + }) + )); + } + + /// When no selection within the bound funds the target, the minimum-count + /// selection comes back so the caller can report how many inputs it needs. + #[test] + fn test_bounded_selection_reports_the_inputs_needed() { + let utxos = ladder(20, 10_000, 0); + let result = CoinSelector::new(SelectionStrategy::BranchAndBound) + .with_max_inputs(4) + .select_coins(&utxos, 100_000, FeeRate::new(1000), 200) + .unwrap(); + assert_eq!(result.selected.len(), 11, "100k + fee needs eleven 10k inputs"); + } + #[test] fn test_select_all_drains_everything() { let utxos = vec![ diff --git a/key-wallet/src/wallet/managed_wallet_info/transaction_builder.rs b/key-wallet/src/wallet/managed_wallet_info/transaction_builder.rs index 57afdefa6..adee78801 100644 --- a/key-wallet/src/wallet/managed_wallet_info/transaction_builder.rs +++ b/key-wallet/src/wallet/managed_wallet_info/transaction_builder.rs @@ -6,7 +6,8 @@ use crate::managed_account::reservation::{ReservationSet, ReservationToken}; use crate::managed_account::ManagedCoreFundsAccount; use crate::wallet::managed_wallet_info::coin_selection::{ - CoinSelector, SelectionStrategy, CHANGE_OUTPUT_SIZE, TX_OUTPUT_SIZE, + CoinSelector, SelectionResult, SelectionStrategy, CHANGE_OUTPUT_SIZE, TX_INPUT_SIZE, + TX_OUTPUT_SIZE, }; use crate::wallet::managed_wallet_info::fee::FeeRate; use crate::{Account, DerivationPath, Signer, Utxo, Wallet}; @@ -27,6 +28,38 @@ use std::collections::HashSet; /// A transaction with more inputs would exceed the relay standard-size cap (~100 KB at ~148 /// bytes/signed input) and be rejected by the network const MAX_STANDARD_TX_INPUTS: usize = 500; + +/// Maximum number of inputs an asset-lock transaction may spend. +/// +/// Mirrors Dash Core's `MAX_STANDARD_ASSET_LOCK_INPUTS` (`src/evo/specialtxman.cpp`, +/// `IsStandardSpecialTx`): a larger asset lock is non-standard +/// (`assetlocktx-too-many-inputs`), so peers drop it without relaying or +/// rejecting it. Platform enforces the same bound in consensus +/// (`max_asset_lock_transaction_inputs`). +/// +/// Every build carrying an asset-lock payload is held to it; over the cap it +/// fails with [`BuilderError::TooManyInputs`], except the asset-lock builders' +/// bounded drain. How the cap, the size limit, the bounded drain and its +/// `remaining_*` report fit together is documented once, on +/// `ManagedWalletInfo::build_asset_lock`. +/// +/// Re-exported from `asset_lock_builder`. +pub const MAX_ASSET_LOCK_INPUTS: usize = 100; + +/// Maximum serialized size, in bytes, of an asset-lock transaction. +/// +/// Mirrors Dash Core's standardness limit on asset locks (`assetlocktx-too-big` +/// in `IsStandardSpecialTx`); exceeding it fails with +/// [`BuilderError::AssetLockTooLarge`]. See `ManagedWalletInfo::build_asset_lock`. +/// +/// Re-exported from `asset_lock_builder`. +pub const MAX_ASSET_LOCK_TX_SIZE: usize = 20_480; + +/// Upper bound on the signed `scriptSig` of a P2PKH input: a push of a +/// low-S DER signature plus sighash byte (≤ 72) and a push of a compressed +/// public key (33). With the 41-byte unsigned input it adds up to the 148-byte +/// `TX_INPUT_SIZE` that coin selection prices each input at. +const MAX_P2PKH_SCRIPT_SIG_SIZE: usize = 1 + 72 + 1 + 33; /// Relay-policy limit for OP_RETURN payloads, matching Dash Core's `-datacarriersize` default. /// Public so callers can reject an over-long payload before handing over a builder /// `add_op_return` would consume. @@ -63,6 +96,52 @@ fn addresses_share_network(left: &Address, right: &Address) -> bool { ) } +/// Spendable candidates a build was offered but did not select, counted once +/// per outpoint. For a bounded asset-lock drain this is exactly what the input +/// cap left behind. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +pub(crate) struct Unselected { + pub(crate) utxo_count: usize, + pub(crate) duffs: u64, +} + +/// What `assemble_unsigned` hands to the build entry points. +struct Assembled { + transaction: Transaction, + inputs: Vec, + reservation: Option, + unselected: Unselected, +} + +// BIP-69: Sort outputs by amount first, then by scriptPubKey +// lexicographically. +fn bip69_output_sorter(a: &TxOut, b: &TxOut) -> Ordering { + match a.value.cmp(&b.value) { + Ordering::Equal => a.script_pubkey.as_bytes().cmp(b.script_pubkey.as_bytes()), + other => other, + } +} + +// BIP-69: Sort inputs by transaction hash and then by output index. +fn bip69_input_sorter(a: &Utxo, b: &Utxo) -> Ordering { + let tx_hash_a = a.outpoint.txid.to_byte_array(); + let tx_hash_b = b.outpoint.txid.to_byte_array(); + + match tx_hash_a.cmp(&tx_hash_b) { + Ordering::Equal => a.outpoint.vout.cmp(&b.outpoint.vout), + other => other, + } +} + +/// Upper bound on the signed serialized size of `transaction`, an unsigned +/// transaction whose inputs are P2PKH: its current size plus the largest +/// low-S compressed-key scriptSig per input. The scriptSig length varint is one +/// byte both empty and at that size, so nothing else grows. The one bound both +/// the size-aware selection and the pre-sign check use. +fn signed_size_upper_bound(transaction: &Transaction) -> usize { + transaction.size() + transaction.input.len() * MAX_P2PKH_SCRIPT_SIG_SIZE +} + /// A payload-finalization hook set via [`TransactionBuilder::set_payload_finalizer`]: it /// receives the assembled unsigned transaction and returns the finalized special payload. /// Boxed so it can live on the builder like the other `set_*` state; captures must be owned @@ -90,6 +169,9 @@ pub struct TransactionBuilder { /// Finalizes `special_payload` after input selection, for payloads that /// commit to the chosen inputs. See [`Self::set_payload_finalizer`]. payload_finalizer: Option, + /// Let an asset-lock drain spend only its largest inputs. See + /// [`Self::bounded_drain`]. + bounded_drain: bool, /// Reservation set of each funding account paired with the outpoints that /// account contributed, captured by `add_funding`. Reservations live on the /// account that holds the UTXO, so each account reserves its own share of @@ -118,10 +200,26 @@ impl TransactionBuilder { change_to_first_input: false, special_payload: None, payload_finalizer: None, + bounded_drain: false, funding: Vec::new(), } } + /// Let an asset-lock drain spend only its largest candidates — as many as + /// fit [`MAX_ASSET_LOCK_INPUTS`] and [`MAX_ASSET_LOCK_TX_SIZE`] — instead of + /// failing with [`BuilderError::TooManyInputs`]. No effect on any other + /// build. Semantics: `ManagedWalletInfo::build_asset_lock`. + /// + /// Crate-private because a partial drain must be reported. Use it only + /// with [`Self::build_signed_reserved_reporting`], the one path that + /// returns what was left behind; [`Self::build_unsigned_reserved`] and the + /// other build methods drop that report. Its only caller is the asset-lock + /// builders' drain mode (`build_asset_lock` / `build_asset_lock_with_signer`). + pub(crate) fn bounded_drain(mut self) -> Self { + self.bounded_drain = true; + self + } + /// Restrict coin selection to final inputs: confirmed or /// InstantSend-locked UTXOs. Per DIP-0010 only such inputs are /// InstantSend-eligible, so transactions that must receive an @@ -174,9 +272,10 @@ impl TransactionBuilder { /// [`SelectionStrategy::All`] takes all of them, so seeding a subset does /// not restrict anything: a caller splitting a large account into batches of /// at most `MAX_STANDARD_TX_INPUTS` still has every batch see the whole - /// account and fail with [`BuilderError::TooManyInputs`], and an account - /// above the cap can never be drained. That is what a chunked CoinJoin sweep - /// does. + /// account and fail with [`BuilderError::TooManyInputs`], and a regular + /// drain of an account above that cap cannot be built in one go. That is + /// what a chunked CoinJoin sweep does. (An asset-lock build is capped at + /// [`MAX_ASSET_LOCK_INPUTS`] instead; see `ManagedWalletInfo::build_asset_lock`.) /// /// Reservation bookkeeping is unchanged: `owned` still covers every /// unreserved UTXO of the account, so whichever seeded outpoints selection @@ -440,116 +539,51 @@ impl TransactionBuilder { size } - /// Select inputs, build the unsigned transaction, and reserve the chosen - /// inputs. The optional [`ReservationToken`] is `Some` exactly when a - /// reservation set was attached (via [`add_funding`]) and identifies the - /// reservation this build just took, so a caller that later abandons the - /// build can release *only* its own inputs via - /// [`ReservationSet::release_if_owner`]. It is `None` for builds with no - /// reservation set (nothing was reserved, nothing to release). - /// - /// [`add_funding`]: Self::add_funding - fn assemble_unsigned( - mut self, - ) -> Result<(Transaction, Vec, Option), BuilderError> { - if let Some(TransactionPayload::AssetLockPayloadType(p)) = &self.special_payload { - if p.credit_outputs.is_empty() { - return Err(BuilderError::NoOutputs); - } - } else if self.outputs.is_empty() && self.special_payload.is_none() { - return Err(BuilderError::NoOutputs); - } - - // A drain (`All`) never emits change; drop the change address before sizing so the fee - // estimate doesn't include a phantom (~34-byte) change output. - if self.selection_strategy == SelectionStrategy::All { - self.change_addr = None; - self.change_to_first_input = false; - } - - // For AssetLock the on-chain spend equals the OP_RETURN burn, which - // mirrors the sum of the credit_outputs carried in the payload. For - // every other tx type, it's just the sum of user-provided outputs. - let total_output: u64 = match &self.special_payload { - Some(TransactionPayload::AssetLockPayloadType(p)) => { - p.credit_outputs.iter().map(|o| o.value).sum() - } - _ => self.outputs.iter().map(|o| o.value).sum(), - }; - - if self.require_final_inputs { - self.inputs.retain(|utxo| utxo.is_confirmed || utxo.is_instantlocked); - } - - if !self.funding.is_empty() { - // Every UTXO a funding account offers is unreserved, but a seeded - // one need not be: `add_inputs` does not consult a reservation set, - // and may run after the funding call. Drop those here so no path - // into the builder can spend an outpoint another in-flight build - // holds. - let height = self.current_height; - let reserved: HashSet = self - .funding - .iter() - .flat_map(|(reservations, _)| reservations.reserved(height)) - .collect(); - self.inputs.retain(|utxo| !reserved.contains(&utxo.outpoint)); - } - - // Must match `calculate_base_size`, including the conservative VIN0 routing-script size. - let change_output_size = self.estimated_change_output_size(); - - let selection = CoinSelector::new(self.selection_strategy) - .select_coins_with_size( - self.inputs.iter(), - total_output, - self.fee_rate, - self.current_height, - self.calculate_base_size(), - change_output_size, - ) - .map_err(BuilderError::CoinSelection)?; - - let mut selected_inputs = selection.selected; - - if selected_inputs.len() > MAX_STANDARD_TX_INPUTS { - return Err(BuilderError::TooManyInputs { - count: selected_inputs.len(), - max: MAX_STANDARD_TX_INPUTS, - }); - } - + /// Turn one selection into the unsigned transaction: BIP-69 input order, + /// the drain carrier or change output, and the (possibly drain-rewritten) + /// special payload. `outputs` and `special_payload` are moved in, so the + /// single-pass build clones nothing; a retry takes the payload back out of + /// the transaction it rejects. + #[allow(clippy::too_many_arguments)] + fn unsigned_from_selection( + &self, + mut selected_inputs: Vec, + total_output: u64, + estimated_fee: u64, + is_asset_lock: bool, + outputs: Vec, + mut special_payload: Option, + ) -> Result<(Transaction, Vec), BuilderError> { let total_input: u64 = selected_inputs.iter().map(|u| u.value()).sum(); - if total_input < total_output + selection.estimated_fee { + if total_input < total_output + estimated_fee { return Err(BuilderError::InsufficientFunds { available: total_input, - required: total_output + selection.estimated_fee, + required: total_output + estimated_fee, }); } selected_inputs.sort_by(bip69_input_sorter); - let change_amount = - total_input.saturating_sub(total_output).saturating_sub(selection.estimated_fee); - // Computed before `self.outputs` is moved out below. + let change_amount = total_input.saturating_sub(total_output).saturating_sub(estimated_fee); let change_dust_threshold = Self::dust_threshold(self.estimated_change_output_size()); - let mut tx_outputs = match &self.special_payload { - Some(TransactionPayload::AssetLockPayloadType(_)) => vec![TxOut { + let mut tx_outputs = if is_asset_lock { + vec![TxOut { value: total_output, script_pubkey: ScriptBuf::new_op_return(&[]), - }], - _ => self.outputs, + }] + } else { + outputs }; if self.selection_strategy == SelectionStrategy::All { // Drain: the single VALUE-CARRYING output takes the whole balance minus fee (the // caller's amount is ignored); no change. - let drained = total_input.saturating_sub(selection.estimated_fee); + let drained = total_input.saturating_sub(estimated_fee); if drained == 0 { return Err(BuilderError::InsufficientFunds { available: total_input, - required: selection.estimated_fee, + required: estimated_fee, }); } // Zero-value data carriers ride along with a drain. A MAYAChain deposit is the @@ -562,8 +596,6 @@ impl TransactionBuilder { // // An asset lock is the exception: ITS single output IS an OP_RETURN (the burn // mirroring the payload credits), so it stays the value carrier. - let is_asset_lock = - matches!(self.special_payload, Some(TransactionPayload::AssetLockPayloadType(_))); if !is_asset_lock && tx_outputs.iter().any(|out| out.script_pubkey.is_op_return() && out.value != 0) { @@ -605,9 +637,7 @@ impl TransactionBuilder { // An asset-lock drain must also rewrite the payload's credit // output: the on-chain OP_RETURN burn (`out` above) mirrors the // payload credit sum, and a mismatch is consensus-invalid. - if let Some(TransactionPayload::AssetLockPayloadType(payload)) = - &mut self.special_payload - { + if let Some(TransactionPayload::AssetLockPayloadType(payload)) = &mut special_payload { let [credit] = payload.credit_outputs.as_mut_slice() else { return Err(BuilderError::InvalidData( "SelectionStrategy::All with an asset-lock payload requires exactly one \ @@ -633,7 +663,7 @@ impl TransactionBuilder { } first_input.address.script_pubkey() } else { - let Some(change_addr) = self.change_addr else { + let Some(change_addr) = self.change_addr.as_ref() else { return Err(BuilderError::NoChangeAddress); }; change_addr.script_pubkey() @@ -645,9 +675,7 @@ impl TransactionBuilder { }); } - if !self.preserve_output_order - && !matches!(self.special_payload, Some(TransactionPayload::AssetLockPayloadType(_))) - { + if !self.preserve_output_order && !is_asset_lock { tx_outputs.sort_by(bip69_output_sorter); } let tx_inputs: Vec = selected_inputs @@ -665,9 +693,197 @@ impl TransactionBuilder { lock_time: 0, input: tx_inputs, output: tx_outputs, - special_transaction_payload: self.special_payload, + special_transaction_payload: special_payload, + }; + + Ok((transaction, selected_inputs)) + } + + /// Select inputs, build the unsigned transaction, and reserve the chosen + /// inputs. The optional [`ReservationToken`] is `Some` exactly when a + /// reservation set was attached (via [`add_funding`]) and identifies the + /// reservation this build just took, so a caller that later abandons the + /// build can release *only* its own inputs via + /// [`ReservationSet::release_if_owner`]. It is `None` for builds with no + /// reservation set (nothing was reserved, nothing to release). + /// + /// [`add_funding`]: Self::add_funding + fn assemble_unsigned(mut self) -> Result { + let is_asset_lock = + matches!(self.special_payload, Some(TransactionPayload::AssetLockPayloadType(_))); + if let Some(TransactionPayload::AssetLockPayloadType(p)) = &self.special_payload { + if p.credit_outputs.is_empty() { + return Err(BuilderError::NoOutputs); + } + } else if self.outputs.is_empty() && self.special_payload.is_none() { + return Err(BuilderError::NoOutputs); + } + + // A drain (`All`) never emits change; drop the change address before sizing so the fee + // estimate doesn't include a phantom (~34-byte) change output. + if self.selection_strategy == SelectionStrategy::All { + self.change_addr = None; + self.change_to_first_input = false; + } + + // For AssetLock the on-chain spend equals the OP_RETURN burn, which + // mirrors the sum of the credit_outputs carried in the payload. For + // every other tx type, it's just the sum of user-provided outputs. + let total_output: u64 = match &self.special_payload { + Some(TransactionPayload::AssetLockPayloadType(p)) => { + p.credit_outputs.iter().map(|o| o.value).sum() + } + _ => self.outputs.iter().map(|o| o.value).sum(), + }; + + if self.require_final_inputs { + self.inputs.retain(|utxo| utxo.is_confirmed || utxo.is_instantlocked); + } + + if !self.funding.is_empty() { + // Every UTXO a funding account offers is unreserved, but a seeded + // one need not be: `add_inputs` does not consult a reservation set, + // and may run after the funding call. Drop those here so no path + // into the builder can spend an outpoint another in-flight build + // holds. + let height = self.current_height; + let reserved: HashSet = self + .funding + .iter() + .flat_map(|(reservations, _)| reservations.reserved(height)) + .collect(); + self.inputs.retain(|utxo| !reserved.contains(&utxo.outpoint)); + } + + // The asset-lock size bound prices every input as a P2PKH spend (and + // the signer only produces P2PKH scriptSigs), so other outputs are + // simply not offered to an asset lock's selection. + if is_asset_lock { + self.inputs.retain(|utxo| utxo.txout.script_pubkey.is_p2pkh()); + } + + // Must match `calculate_base_size`, including the conservative VIN0 routing-script size. + let change_output_size = self.estimated_change_output_size(); + + // An asset lock is capped at MAX_ASSET_LOCK_INPUTS. An exact-amount + // build is bounded inside selection (it stays within the cap or reports + // how many inputs it needs). A drain is bounded — the largest N, the + // rest left behind — only when the caller opted in with + // `bounded_drain`, the one path that reports what was left; otherwise + // an over-cap drain fails, as a regular drain over the standard cap + // always has, rather than silently leaving coins behind. + let max_inputs = if is_asset_lock { + MAX_ASSET_LOCK_INPUTS + } else { + MAX_STANDARD_TX_INPUTS + }; + let strategy = self.selection_strategy; + let is_drain = strategy == SelectionStrategy::All; + let bounded_drain = is_asset_lock && is_drain && self.bounded_drain; + let bound_selection = is_asset_lock && (!is_drain || self.bounded_drain); + let base_size = self.calculate_base_size(); + // Moved, not cloned: the single-pass build hands them straight to the + // transaction. Selection and sizing above already read them. + let outputs = std::mem::take(&mut self.outputs); + let special_payload = self.special_payload.take(); + let select = |strategy: SelectionStrategy, max: Option| { + let selector = CoinSelector::new(strategy); + let selector = match max { + Some(max) => selector.with_max_inputs(max), + None => selector, + }; + selector + .select_coins_with_size( + self.inputs.iter(), + total_output, + self.fee_rate, + self.current_height, + base_size, + change_output_size, + ) + .map_err(BuilderError::CoinSelection) + }; + let assemble = |selection: SelectionResult, + outputs: Vec, + special_payload: Option| { + self.unsigned_from_selection( + selection.selected, + total_output, + selection.estimated_fee, + is_asset_lock, + outputs, + special_payload, + ) + }; + + // A bounded selector only exceeds its bound when no selection within it + // funds the target (its length is then the inputs needed); an + // unbounded one is checked against the cap here too. + let select_within_cap = |strategy: SelectionStrategy, max: Option| { + let selection = select(strategy, max)?; + if selection.selected.len() > max_inputs { + return Err(BuilderError::TooManyInputs { + count: selection.selected.len(), + max: max_inputs, + }); + } + Ok(selection) }; + let selection = select_within_cap(strategy, bound_selection.then_some(max_inputs))?; + + let (mut transaction, mut selected_inputs) = assemble(selection, outputs, special_payload)?; + + // Core refuses an oversized asset lock as non-standard and peers drop it + // silently. Hold it to the signed-size upper bound now, before anything + // is reserved or signed (signing may be a hardware round-trip per input). + let oversized = is_asset_lock + .then(|| signed_size_upper_bound(&transaction)) + .filter(|size| *size > MAX_ASSET_LOCK_TX_SIZE); + if let Some(first_size) = oversized { + // A retry reassembles from the rejected transaction's payload (a + // drain rewrites its credit value again); an asset lock's on-chain + // output is the burn, rebuilt from the payload, so no outputs carry over. + let retry = if bounded_drain { + // A bounded drain drops its smallest inputs instead. Each one + // lowers the bound by exactly TX_INPUT_SIZE (the input-count + // varint stays one byte below 253 inputs), so one reselection + // at the right count fits. + let excess = first_size - MAX_ASSET_LOCK_TX_SIZE; + let keep = selected_inputs.len().saturating_sub(excess.div_ceil(TX_INPUT_SIZE)); + (keep > 0).then_some((SelectionStrategy::All, keep)) + } else if !is_drain && strategy != SelectionStrategy::LargestFirst { + // An exact-amount pick may fit with fewer, larger inputs: + // largest-first is the fewest-inputs pick. There is no + // no-change retry: change is only emitted above the dust + // threshold, so dropping it would burn real value as fee. + Some((SelectionStrategy::LargestFirst, max_inputs)) + } else { + None + }; + // The smallest transaction actually checked: the retry when it ran + // and came out smaller, otherwise the first pick. + let mut size = first_size; + if let Some((retry_strategy, retry_max)) = retry { + // A failed reselection (e.g. the kept inputs no longer cover + // the fee) is the real reason; surface it. + let smaller = select_within_cap(retry_strategy, Some(retry_max))?; + let payload = transaction.special_transaction_payload.take(); + let (tx, inputs) = assemble(smaller, Vec::new(), payload)?; + let retry_size = signed_size_upper_bound(&tx); + if retry_size < size { + size = retry_size; + (transaction, selected_inputs) = (tx, inputs); + } + } + if size > MAX_ASSET_LOCK_TX_SIZE { + return Err(BuilderError::AssetLockTooLarge { + size, + max: MAX_ASSET_LOCK_TX_SIZE, + }); + } + } + // Reserve the chosen inputs so a concurrent build skips them until the // broadcast transaction is processed back into the wallet (which // releases the reservation) or the TTL backstop reclaims it. Each @@ -688,27 +904,29 @@ impl TransactionBuilder { owner }); - return Ok((transaction, selected_inputs, reservation_token)); - - // BIP-69: Sort outputs by amount first, then by scriptPubKey - // lexicographically. - fn bip69_output_sorter(a: &TxOut, b: &TxOut) -> Ordering { - match a.value.cmp(&b.value) { - Ordering::Equal => a.script_pubkey.as_bytes().cmp(b.script_pubkey.as_bytes()), - other => other, + // What a bounded drain left behind: the spendable candidates it drew + // from but did not take, so the count cannot drift from selection. + let mut unselected = Unselected::default(); + if bounded_drain { + let selected: HashSet = selected_inputs.iter().map(|u| u.outpoint).collect(); + let mut seen: HashSet = HashSet::new(); + for utxo in &self.inputs { + if utxo.is_spendable(self.current_height) + && !selected.contains(&utxo.outpoint) + && seen.insert(utxo.outpoint) + { + unselected.utxo_count += 1; + unselected.duffs += utxo.value(); + } } } - // BIP-69: Sort inputs by transaction hash and then by output index. - fn bip69_input_sorter(a: &Utxo, b: &Utxo) -> Ordering { - let tx_hash_a = a.outpoint.txid.to_byte_array(); - let tx_hash_b = b.outpoint.txid.to_byte_array(); - - match tx_hash_a.cmp(&tx_hash_b) { - Ordering::Equal => a.outpoint.vout.cmp(&b.outpoint.vout), - other => other, - } - } + Ok(Assembled { + transaction, + inputs: selected_inputs, + reservation: reservation_token, + unselected, + }) } /// Build the unsigned transaction, returning it alongside the fee it pays @@ -732,6 +950,12 @@ impl TransactionBuilder { /// A payload finalizer set via [`Self::set_payload_finalizer`] runs here /// too, right after selection: the returned unsigned transaction carries /// the finalized payload, ready for external input signing. + /// + /// For an asset-lock payload the size limit ([`MAX_ASSET_LOCK_TX_SIZE`]) is + /// checked here only against an upper bound that assumes standard P2PKH + /// scriptSigs — a compressed public key and a low-S DER signature, at most + /// 107 bytes each. A caller signing externally must keep to that: there is + /// no post-sign check on this path, unlike [`Self::build_signed_reserved`]. pub fn build_unsigned_reserved( mut self, ) -> Result<(Transaction, u64, Option), BuilderError> { @@ -739,7 +963,12 @@ impl TransactionBuilder { let finalizer = self.payload_finalizer.take(); let funding = self.funding.clone(); - let (mut tx, inputs, reservation) = self.assemble_unsigned()?; + let Assembled { + transaction: mut tx, + inputs, + reservation, + .. + } = self.assemble_unsigned()?; if let Some(finalize_payload) = finalizer { if let Err(err) = Self::finalize_payload_in(&mut tx, finalize_payload) { @@ -783,10 +1012,27 @@ impl TransactionBuilder { /// A payload finalizer set via [`Self::set_payload_finalizer`] runs /// between input selection and input signing. pub async fn build_signed_reserved( - mut self, + self, signer: &S, path_resolver: P, ) -> Result<(Transaction, u64, Option), BuilderError> + where + S: TransactionSigner + ?Sized + Sync, + P: Fn(Address) -> Option + Send, + { + let (tx, fee, reservation, _unselected) = + self.build_signed_reserved_reporting(signer, path_resolver).await?; + Ok((tx, fee, reservation)) + } + + /// [`Self::build_signed_reserved`], also reporting the spendable candidates + /// selection was offered but left out — what a bounded asset-lock drain + /// left behind. + pub(crate) async fn build_signed_reserved_reporting( + mut self, + signer: &S, + path_resolver: P, + ) -> Result<(Transaction, u64, Option, Unselected), BuilderError> where S: TransactionSigner + ?Sized + Sync, P: Fn(Address) -> Option + Send, @@ -795,7 +1041,12 @@ impl TransactionBuilder { let finalizer = self.payload_finalizer.take(); let funding = self.funding.clone(); - let (mut tx, inputs, reservation) = self.assemble_unsigned()?; + let Assembled { + transaction: mut tx, + inputs, + reservation, + unselected, + } = self.assemble_unsigned()?; let total_input: u64 = inputs.iter().map(|utxo| utxo.value()).sum(); // A failure past this point — payload finalization or signing — leaves // the reserved inputs spendable, so release them now instead of @@ -827,9 +1078,27 @@ impl TransactionBuilder { } }; + // The pre-sign bound assumes compressed keys and low-S signatures; a + // signer may return larger scriptSigs. Hold the signed asset lock to + // Core's limit and give its inputs back rather than hand out a lock + // every peer would drop. + if matches!( + tx.special_transaction_payload, + Some(TransactionPayload::AssetLockPayloadType(_)) + ) { + let size = tx.size(); + if size > MAX_ASSET_LOCK_TX_SIZE { + Self::release_reservation(&funding, &reserved, reservation); + return Err(BuilderError::AssetLockTooLarge { + size, + max: MAX_ASSET_LOCK_TX_SIZE, + }); + } + } + let total_output: u64 = tx.output.iter().map(|out| out.value).sum(); - Ok((tx, total_input.saturating_sub(total_output), reservation)) + Ok((tx, total_input.saturating_sub(total_output), reservation, unselected)) } /// A payload finalizer without a placeholder payload cannot be priced into @@ -1040,6 +1309,14 @@ pub enum BuilderError { len: usize, max: usize, }, + /// The asset lock's signed size — the pre-sign upper bound, or the actual + /// size once signed — exceeds Dash Core's standard limit + /// ([`MAX_ASSET_LOCK_TX_SIZE`]); Core would refuse to relay it. + AssetLockTooLarge { + /// Upper bound on the signed transaction's serialized size, in bytes. + size: usize, + max: usize, + }, } impl fmt::Display for BuilderError { @@ -1072,6 +1349,12 @@ impl fmt::Display for BuilderError { } => { write!(f, "OP_RETURN payload too large: {len} bytes (max {max})") } + Self::AssetLockTooLarge { + size, + max, + } => { + write!(f, "Asset lock transaction too large: {size} bytes (max {max})") + } } } } @@ -1082,6 +1365,7 @@ impl std::error::Error for BuilderError {} mod tests { use super::*; use crate::test_utils::TestWalletContext; + use crate::wallet::managed_wallet_info::coin_selection::SelectionError; use crate::Network; use dashcore::blockdata::transaction::special_transaction::asset_lock::AssetLockPayload; use dashcore::consensus::serialize; @@ -1414,6 +1698,170 @@ mod tests { assert_eq!(fee, 526, "fee must include the 300-duff dropped dust remainder"); } + /// An exact-amount asset lock whose first pick fits the input cap but not + /// Core's size limit (a large payload plus many small inputs) falls back to + /// largest-first instead of failing. + #[test] + fn test_asset_lock_size_aware_fallback_to_largest_first() { + let mut utxos: Vec = + (0..90).map(|i| Utxo::dummy(i, 10_000, 100, false, true)).collect(); + utxos.push(Utxo::dummy(90, 1_000_000, 100, false, true)); + let payload = || { + TransactionPayload::AssetLockPayloadType(AssetLockPayload { + version: 1, + credit_outputs: vec![TxOut { + value: 800_000, + script_pubkey: ScriptBuf::from(vec![0x51; 10_000]), + }], + }) + }; + let builder = || { + TransactionBuilder::new() + .set_current_height(200) + .set_fee_rate(FeeRate::new(1000)) + .set_change_address(Address::dummy(Network::Testnet, 0)) + .set_special_payload(payload()) + .add_inputs(utxos.clone()) + }; + + // Smallest-first alone needs over 80 of the 10k inputs: within the + // 100-input cap, but ~22 KB signed with the 10 KB payload. + let smallest_first = CoinSelector::new(SelectionStrategy::SmallestFirst) + .with_max_inputs(MAX_ASSET_LOCK_INPUTS) + .select_coins_with_size( + utxos.iter(), + 800_000, + FeeRate::new(1000), + 200, + builder().calculate_base_size(), + CHANGE_OUTPUT_SIZE, + ) + .unwrap(); + assert!(smallest_first.selected.len() > 80); + assert!(smallest_first.selected.len() <= MAX_ASSET_LOCK_INPUTS); + + let (tx, _, _) = builder() + .set_selection_strategy(SelectionStrategy::SmallestFirst) + .build_unsigned_reserved() + .expect("largest-first funds it with the single 1M input"); + assert_eq!(tx.input.len(), 1); + assert_eq!(tx.input[0].previous_output, utxos[90].outpoint); + } + + fn asset_lock_payload(script_len: usize) -> TransactionPayload { + TransactionPayload::AssetLockPayloadType(AssetLockPayload { + version: 1, + credit_outputs: vec![TxOut { + value: 0, + script_pubkey: ScriptBuf::from(vec![0x51; script_len]), + }], + }) + } + + /// Through the public builder an asset-lock drain over the input cap fails + /// instead of silently spending only part of the candidates; only the + /// crate's bounded-drain opt-in (which reports the rest) may shrink it. + #[test] + fn test_asset_lock_drain_over_the_cap_requires_the_bounded_opt_in() { + let utxos: Vec = (0..101).map(|i| Utxo::dummy(i, 10_000, 100, false, true)).collect(); + let builder = || { + TransactionBuilder::new() + .set_current_height(200) + .set_fee_rate(FeeRate::new(1000)) + .set_selection_strategy(SelectionStrategy::All) + .set_special_payload(asset_lock_payload(25)) + .add_inputs(utxos.clone()) + }; + + assert!(matches!( + builder().build_unsigned_reserved(), + Err(BuilderError::TooManyInputs { + count: 101, + max: MAX_ASSET_LOCK_INPUTS + }) + )); + + let (tx, _, _) = + builder().bounded_drain().build_unsigned_reserved().expect("bounded drain builds"); + assert_eq!(tx.input.len(), MAX_ASSET_LOCK_INPUTS); + } + + /// The generic builder drops a non-P2PKH input from an asset lock's + /// candidates too, seeded or not. + #[test] + fn test_asset_lock_ignores_non_p2pkh_inputs() { + let good = Utxo::dummy(0, 1_000_000, 100, false, true); + let mut odd = Utxo::dummy(1, 5_000_000, 100, false, true); + odd.txout.script_pubkey = ScriptBuf::from(vec![0x51]); + let (tx, _, _) = TransactionBuilder::new() + .set_current_height(200) + .set_fee_rate(FeeRate::new(1000)) + .set_selection_strategy(SelectionStrategy::All) + .set_special_payload(asset_lock_payload(25)) + .add_inputs([good.clone(), odd]) + .build_unsigned_reserved() + .expect("the P2PKH input funds the drain"); + assert_eq!(tx.input.len(), 1); + assert_eq!(tx.input[0].previous_output, good.outpoint); + } + + /// When the shrink's reselection itself fails — the few inputs that would + /// fit no longer cover the fee — that is the error reported, not a + /// misleading size refusal. + #[test] + fn test_bounded_drain_shrink_surfaces_the_reselection_error() { + let utxos: Vec = (0..100).map(|i| Utxo::dummy(i, 2_000, 100, false, true)).collect(); + let result = TransactionBuilder::new() + .set_current_height(200) + .set_fee_rate(FeeRate::new(1000)) + .set_selection_strategy(SelectionStrategy::All) + .set_special_payload(asset_lock_payload(20_000)) + .add_inputs(utxos) + .bounded_drain() + .build_unsigned_reserved(); + assert!( + matches!( + result, + Err(BuilderError::CoinSelection(SelectionError::InsufficientFunds { .. })) + ), + "got {:?}", + result.err() + ); + } + + /// A bounded drain also honours Core's size limit: when the largest 100 + /// would be too large it drops its smallest inputs until the signed-size + /// bound fits. + #[test] + fn test_bounded_drain_shrinks_to_the_size_limit() { + let utxos: Vec = (0..100) + .map(|i| Utxo::dummy(i, 10_000 + u64::from(i) * 100, 100, false, true)) + .collect(); + let (tx, _, _) = TransactionBuilder::new() + .set_current_height(200) + .set_fee_rate(FeeRate::new(1000)) + .set_selection_strategy(SelectionStrategy::All) + .set_special_payload(asset_lock_payload(8_000)) + .add_inputs(utxos.clone()) + .bounded_drain() + .build_unsigned_reserved() + .expect("a bounded drain shrinks rather than failing on size"); + + assert!(tx.input.len() < MAX_ASSET_LOCK_INPUTS); + assert!(signed_size_upper_bound(&tx) <= MAX_ASSET_LOCK_TX_SIZE); + // One more input would not have fit: it shrank no further than needed. + assert!( + signed_size_upper_bound(&tx) + TX_INPUT_SIZE > MAX_ASSET_LOCK_TX_SIZE, + "dropped more inputs than the size limit required" + ); + // What it kept are the largest (ladder index = value rank). + let smallest_kept = 100 - tx.input.len() as u8; + let kept: HashSet = tx.input.iter().map(|txin| txin.previous_output).collect(); + let expected: HashSet = + utxos.iter().skip(smallest_kept as usize).map(|u| u.outpoint).collect(); + assert_eq!(kept, expected); + } + /// An asset lock burns the locked amount into an on-chain OP_RETURN output /// mirroring the payload's credit outputs. That output is part of /// Σ(outputs), so the returned fee must be the miner fee only — the locked