From 6de7e13f7184d518c1c29bd5cfbbc169caabd263 Mon Sep 17 00:00:00 2001 From: Roman <51091564+jeanpierreroma@users.noreply.github.com> Date: Fri, 16 Oct 2026 22:18:01 +0300 Subject: [PATCH 01/10] fix(key-wallet): cap asset-lock inputs at Core's standard limit of 100 Dash Core refuses an asset lock with more than 100 inputs as non-standard (MAX_STANDARD_ASSET_LOCK_INPUTS, `assetlocktx-too-many-inputs`) and peers drop it silently; Platform enforces the same bound in consensus. The asset-lock builder had no input cap, so a drain over an account with more than 100 UTXOs (e.g. a heavily mixed CoinJoin account) built a transaction the network could never accept, and the wallet only saw the broadcast time out. - Add MAX_ASSET_LOCK_INPUTS (100) and MAX_ASSET_LOCK_TX_SIZE (20480). - CoinSelector::with_max_inputs / TransactionBuilder::set_max_inputs: a drain becomes a bounded drain that spends the N largest UTXOs and leaves the rest; other strategies fall back to largest-first when their pick is over the cap, and the builder fails with BuilderError::TooManyInputs when no selection within the cap funds the amount. A builder carrying an asset-lock payload is bounded at MAX_ASSET_LOCK_INPUTS automatically; regular builds are unchanged. - Check the signed asset lock against both limits after the build and release the reservation owner-guarded on failure. Co-Authored-By: Claude Opus 5.5 --- .../managed_wallet_info/asset_lock_builder.rs | 79 ++++++++++-- .../managed_wallet_info/coin_selection.rs | 115 +++++++++++++++++- .../transaction_builder.rs | 54 +++++++- 3 files changed, 233 insertions(+), 15 deletions(-) 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..17cfae8e9 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 @@ -27,6 +27,27 @@ use crate::wallet::managed_wallet_info::ManagedWalletInfo; use crate::wallet::Wallet; use crate::DerivationPath; +/// 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 and it can +/// never be mined or InstantSend-locked. Platform enforces the same bound in +/// consensus (`max_asset_lock_transaction_inputs`). +/// +/// Every asset-lock build is held to it: a drain spends at most this many of the +/// largest UTXOs (see [`ManagedWalletInfo::build_asset_lock`]), and an +/// exact-amount build that cannot be funded within it fails with +/// [`BuilderError::TooManyInputs`]. +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`). At [`MAX_ASSET_LOCK_INPUTS`] P2PKH inputs a built +/// asset lock stays well below it; it is checked after signing as a backstop. +pub const MAX_ASSET_LOCK_TX_SIZE: usize = 20_480; + /// Which funding account to derive the one-time key from. #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -46,7 +67,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 @@ -294,6 +316,25 @@ fn validate_funding_sources( Ok(()) } +/// Reject a signed asset lock that Dash Core would refuse to relay: more than +/// [`MAX_ASSET_LOCK_INPUTS`] inputs, or larger than [`MAX_ASSET_LOCK_TX_SIZE`]. +fn check_asset_lock_standard(transaction: &Transaction) -> Result<(), AssetLockError> { + if transaction.input.len() > MAX_ASSET_LOCK_INPUTS { + return Err(AssetLockError::Builder(BuilderError::TooManyInputs { + count: transaction.input.len(), + max: MAX_ASSET_LOCK_INPUTS, + })); + } + let size = transaction.size(); + if size > MAX_ASSET_LOCK_TX_SIZE { + return Err(AssetLockError::Builder(BuilderError::InvalidData(format!( + "asset lock transaction is {size} bytes, above the {MAX_ASSET_LOCK_TX_SIZE}-byte \ + standard limit" + )))); + } + Ok(()) +} + /// The accounts among `offered` that contributed an input to `transaction`. /// /// Only these hold a share of the build's reservation, so this is what the @@ -394,7 +435,10 @@ impl ManagedWalletInfo { .set_special_payload(TransactionPayload::AssetLockPayloadType(AssetLockPayload::new( credit_outputs, ))) - .require_final_inputs(); + .require_final_inputs() + // Implied by the asset-lock payload already; stated so the bound is + // visible where asset locks are built. + .set_max_inputs(MAX_ASSET_LOCK_INPUTS); if drain { builder = builder.set_selection_strategy(SelectionStrategy::All); } @@ -419,16 +463,26 @@ impl ManagedWalletInfo { let reserved: Vec = transaction.input.iter().map(|input| input.previous_output).collect(); + let reservations = BuildReservations { + sets, + reserved, + token, + }; + + // Backstop for the bounds coin selection already enforces: an asset + // lock Core deems non-standard is dropped by every peer without a + // reject, so the wallet would only ever see the broadcast time out. + // Refuse it here and give the inputs back instead. + if let Err(e) = check_asset_lock_standard(&transaction) { + reservations.release(); + return Err(e); + } Ok(SignedAssetLock { transaction, fee, offered, - reservations: BuildReservations { - sets, - reserved, - token, - }, + reservations, }) } @@ -450,12 +504,19 @@ 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 — or, when there are more than [`MAX_ASSET_LOCK_INPUTS`], the + /// [`MAX_ASSET_LOCK_INPUTS`] largest, leaving the rest in the account 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. + /// + /// 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, 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..d79da919f 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,31 @@ 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`, + /// 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`). + /// + /// `max` is clamped to at least 1. + 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 +238,93 @@ 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. + spendable.sort_by(|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 => { + let pool: Vec<&'a Utxo> = utxos.into_iter().collect(); + let result = self.select_with_strategy( + strategy, + pool.iter().copied(), + target_amount, + fee_rate, + current_height, + base_size, + change_output_size, + )?; + if result.selected.len() <= max_inputs + || strategy == SelectionStrategy::LargestFirst + { + return Ok(result); + } + // 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 +347,7 @@ impl CoinSelector { }); } - match self.strategy { + match strategy { SelectionStrategy::SmallestFirst => { available.sort_by_key(|u| u.value()); self.accumulate_coins_with_size( 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..5ddf72e80 100644 --- a/key-wallet/src/wallet/managed_wallet_info/transaction_builder.rs +++ b/key-wallet/src/wallet/managed_wallet_info/transaction_builder.rs @@ -5,6 +5,7 @@ use crate::managed_account::reservation::{ReservationSet, ReservationToken}; use crate::managed_account::ManagedCoreFundsAccount; +use crate::wallet::managed_wallet_info::asset_lock_builder::MAX_ASSET_LOCK_INPUTS; use crate::wallet::managed_wallet_info::coin_selection::{ CoinSelector, SelectionStrategy, CHANGE_OUTPUT_SIZE, TX_OUTPUT_SIZE, }; @@ -90,6 +91,8 @@ 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, + /// Caller-set input bound. See [`Self::set_max_inputs`]. + max_inputs: Option, /// 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 +121,47 @@ impl TransactionBuilder { change_to_first_input: false, special_payload: None, payload_finalizer: None, + max_inputs: None, funding: Vec::new(), } } + /// Bound the number of inputs this build may spend, and have coin + /// selection respect the bound instead of only rejecting an oversized pick. + /// + /// A drain ([`SelectionStrategy::All`]) becomes a *bounded drain*: it + /// spends the `max` largest spendable UTXOs and leaves the rest. Any other + /// strategy picks a selection within `max` inputs, or fails with + /// [`BuilderError::TooManyInputs`] (whose `count` is the fewest inputs the + /// amount needs) when none exists. + /// + /// The bound is clamped to the relay-standard maximum of 500 inputs. A + /// builder carrying an asset-lock payload is bounded at + /// [`MAX_ASSET_LOCK_INPUTS`] without calling this; a lower value set here + /// still wins. Without either, a build is only checked against the 500-input + /// standard limit after selection, as before: an unbounded drain over a + /// larger account fails rather than leaving coins behind. + pub fn set_max_inputs(mut self, max: usize) -> Self { + self.max_inputs = Some(max); + self + } + + /// The input bound coin selection must respect, when one applies: the + /// caller's [`Self::set_max_inputs`], and/or [`MAX_ASSET_LOCK_INPUTS`] for an + /// asset-lock payload (Core refuses a larger asset lock as non-standard, + /// and Platform rejects it), the tighter of the two, never above + /// `MAX_STANDARD_TX_INPUTS`. + fn selection_input_bound(&self) -> Option { + let asset_lock_bound = + matches!(self.special_payload, Some(TransactionPayload::AssetLockPayloadType(_))) + .then_some(MAX_ASSET_LOCK_INPUTS); + let bound = match (self.max_inputs, asset_lock_bound) { + (Some(a), Some(b)) => Some(a.min(b)), + (a, b) => a.or(b), + }?; + Some(bound.min(MAX_STANDARD_TX_INPUTS)) + } + /// 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 @@ -499,7 +539,13 @@ impl TransactionBuilder { // 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) + let input_bound = self.selection_input_bound(); + let max_inputs = input_bound.unwrap_or(MAX_STANDARD_TX_INPUTS); + let mut selector = CoinSelector::new(self.selection_strategy); + if let Some(bound) = input_bound { + selector = selector.with_max_inputs(bound); + } + let selection = selector .select_coins_with_size( self.inputs.iter(), total_output, @@ -512,10 +558,12 @@ impl TransactionBuilder { let mut selected_inputs = selection.selected; - if selected_inputs.len() > MAX_STANDARD_TX_INPUTS { + // A bounded selector only exceeds its bound when no selection within it + // funds the target; an unbounded one is checked against the standard cap. + if selected_inputs.len() > max_inputs { return Err(BuilderError::TooManyInputs { count: selected_inputs.len(), - max: MAX_STANDARD_TX_INPUTS, + max: max_inputs, }); } From ed01170006e47b7324bfff4f90de4858a2ca0e04 Mon Sep 17 00:00:00 2001 From: Roman <51091564+jeanpierreroma@users.noreply.github.com> Date: Fri, 16 Oct 2026 22:21:18 +0300 Subject: [PATCH 02/10] test(key-wallet): cover the asset-lock input cap Bounded drain over 104 and 103 UTXOs (soft wallet and external signer) takes exactly the 100 largest and reserves only them; a drain of exactly 100 is unchanged; an exact-amount lock that needs more than 100 inputs fails with BuilderError::TooManyInputs and reserves nothing; one that fits still builds; the post-build standardness backstop rejects over-cap and oversized transactions. Coin-selector tests cover the bounded drain, tie determinism, the largest-first fallback and the reported input need. Co-Authored-By: Claude Opus 5.5 --- .../managed_wallet_info/asset_lock_builder.rs | 287 +++++++++++++++++- .../managed_wallet_info/coin_selection.rs | 103 +++++++ 2 files changed, 382 insertions(+), 8 deletions(-) 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 17cfae8e9..f2f052285 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 @@ -751,13 +751,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 { @@ -784,6 +796,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(); @@ -798,10 +825,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 { @@ -820,7 +843,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] @@ -865,6 +888,254 @@ 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); + 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 account = info.accounts.coinjoin_accounts.get(&0).unwrap(); + assert!(left_out.iter().all(|op| account.utxos.contains_key(op))); + } + + /// 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); + } + + /// 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)); + } + + /// 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()); + } + + /// The post-build backstop refuses an over-cap or oversized transaction + /// even if one ever got past selection. + #[test] + fn standardness_backstop_rejects_oversized_asset_locks() { + let input = |vout| dashcore::TxIn { + previous_output: nth_outpoint(vout), + script_sig: ScriptBuf::new(), + sequence: 0xffff_ffff, + witness: dashcore::Witness::new(), + }; + let tx = |inputs: Vec, script_len: usize| Transaction { + version: 3, + lock_time: 0, + input: inputs, + output: vec![TxOut { + value: 1, + script_pubkey: ScriptBuf::from(vec![0u8; script_len]), + }], + special_transaction_payload: None, + }; + + let at_cap = tx((0..MAX_ASSET_LOCK_INPUTS as u32).map(input).collect(), 1); + assert!(check_asset_lock_standard(&at_cap).is_ok()); + + let over_cap = tx((0..=MAX_ASSET_LOCK_INPUTS as u32).map(input).collect(), 1); + assert!(matches!( + check_asset_lock_standard(&over_cap), + Err(AssetLockError::Builder(BuilderError::TooManyInputs { + count: 101, + max: MAX_ASSET_LOCK_INPUTS + })) + )); + + let too_big = tx(vec![input(0)], MAX_ASSET_LOCK_TX_SIZE); + assert!(matches!( + check_asset_lock_standard(&too_big), + Err(AssetLockError::Builder(BuilderError::InvalidData(_))) + )); + } + /// 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 d79da919f..94a6b1b35 100644 --- a/key-wallet/src/wallet/managed_wallet_info/coin_selection.rs +++ b/key-wallet/src/wallet/managed_wallet_info/coin_selection.rs @@ -909,8 +909,111 @@ 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); + } + + /// 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![ From cc15f530f18a9a6d4b9a123121b4fce905558705 Mon Sep 17 00:00:00 2001 From: Roman <51091564+jeanpierreroma@users.noreply.github.com> Date: Fri, 16 Oct 2026 22:21:54 +0300 Subject: [PATCH 03/10] fix(key-wallet): clamp the builder's input bound to at least one set_max_inputs(0) bounded coin selection at one input but the post-selection check at zero, so every build failed with TooManyInputs. Clamp both alike. Co-Authored-By: Claude Opus 5.5 --- .../src/wallet/managed_wallet_info/transaction_builder.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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 5ddf72e80..4d263707e 100644 --- a/key-wallet/src/wallet/managed_wallet_info/transaction_builder.rs +++ b/key-wallet/src/wallet/managed_wallet_info/transaction_builder.rs @@ -135,7 +135,7 @@ impl TransactionBuilder { /// [`BuilderError::TooManyInputs`] (whose `count` is the fewest inputs the /// amount needs) when none exists. /// - /// The bound is clamped to the relay-standard maximum of 500 inputs. A + /// The bound is clamped to 1..=500 (the relay-standard maximum). A /// builder carrying an asset-lock payload is bounded at /// [`MAX_ASSET_LOCK_INPUTS`] without calling this; a lower value set here /// still wins. Without either, a build is only checked against the 500-input @@ -159,7 +159,7 @@ impl TransactionBuilder { (Some(a), Some(b)) => Some(a.min(b)), (a, b) => a.or(b), }?; - Some(bound.min(MAX_STANDARD_TX_INPUTS)) + Some(bound.clamp(1, MAX_STANDARD_TX_INPUTS)) } /// Restrict coin selection to final inputs: confirmed or From 1bb820cca63982bac163cc06347970056cc9b84f Mon Sep 17 00:00:00 2001 From: Roman <51091564+jeanpierreroma@users.noreply.github.com> Date: Fri, 16 Oct 2026 22:31:45 +0300 Subject: [PATCH 04/10] fix(key-wallet): address review of the asset-lock input cap - AssetLockResult gains remaining_utxo_count / remaining_duffs: what a bounded drain left behind (0/0 for a full drain or an exact build), so a partial drain is no longer indistinguishable from a full one. - Bounded selection also falls back to largest-first when the first strategy reports insufficient funds, not only when its pick is over the cap. - Oversized asset locks fail with the typed BuilderError::AssetLockTooLarge, checked against the signed-size upper bound inside the builder before anything is reserved or signed; the post-sign backstop is gone. - Move MAX_ASSET_LOCK_INPUTS / MAX_ASSET_LOCK_TX_SIZE beside MAX_STANDARD_TX_INPUTS and re-export them from asset_lock_builder, removing the circular module import; drop the redundant explicit set_max_inputs in the asset-lock builder (the payload-derived bound applies). - Clamp the input cap once (input_cap -> usize); CoinSelector::with_max_inputs takes the bound as given. - Bounded drain partitions with select_nth_unstable_by instead of sorting. - Docs: the cap makes a lock relayable by Core and within Platform's input cap, not every state transition small enough (InstantSend proof size, use a ChainLock proof); a bounded drain leaves the smallest UTXOs, so a follow-up drain may fall below the transition's minimum amount. Co-Authored-By: Claude Opus 5.5 --- .../managed_wallet_info/asset_lock_builder.rs | 240 +++++++++++------- .../managed_wallet_info/coin_selection.rs | 82 ++++-- .../transaction_builder.rs | 113 +++++++-- 3 files changed, 305 insertions(+), 130 deletions(-) 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 f2f052285..1a683d7ec 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 @@ -7,6 +7,7 @@ use dashcore::blockdata::transaction::special_transaction::asset_lock::AssetLock use dashcore::blockdata::transaction::special_transaction::TransactionPayload; use dashcore::{OutPoint, Transaction, TxOut}; use secp256k1::PublicKey; +use std::collections::HashSet; use std::fmt; use crate::account::AccountType; @@ -19,6 +20,11 @@ use crate::wallet::managed_wallet_info::fee::FeeRate; use crate::wallet::managed_wallet_info::transaction_builder::{ BuilderError, TransactionBuilder, TransactionSigner, }; +/// 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, }; @@ -27,27 +33,6 @@ use crate::wallet::managed_wallet_info::ManagedWalletInfo; use crate::wallet::Wallet; use crate::DerivationPath; -/// 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 and it can -/// never be mined or InstantSend-locked. Platform enforces the same bound in -/// consensus (`max_asset_lock_transaction_inputs`). -/// -/// Every asset-lock build is held to it: a drain spends at most this many of the -/// largest UTXOs (see [`ManagedWalletInfo::build_asset_lock`]), and an -/// exact-amount build that cannot be funded within it fails with -/// [`BuilderError::TooManyInputs`]. -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`). At [`MAX_ASSET_LOCK_INPUTS`] P2PKH inputs a built -/// asset lock stays well below it; it is checked after signing as a backstop. -pub const MAX_ASSET_LOCK_TX_SIZE: usize = 20_480; - /// Which funding account to derive the one-time key from. #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -188,6 +173,18 @@ pub struct AssetLockResult { /// bookkeeping scale with the address book while claiming contributions /// that never happened. pub funding_accounts: Vec, + /// How many spendable UTXOs of the funding source(s) a **drain** left + /// unspent because it hit [`MAX_ASSET_LOCK_INPUTS`]; `0` when the drain + /// took everything, and always `0` for an exact-amount build. + /// + /// A bounded drain keeps the largest UTXOs, so what remains is the + /// *smallest*: a follow-up drain of the rest may fall below the minimum + /// amount the target state transition accepts. Surface it to the user + /// rather than assuming the account was emptied. + 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. @@ -316,25 +313,6 @@ fn validate_funding_sources( Ok(()) } -/// Reject a signed asset lock that Dash Core would refuse to relay: more than -/// [`MAX_ASSET_LOCK_INPUTS`] inputs, or larger than [`MAX_ASSET_LOCK_TX_SIZE`]. -fn check_asset_lock_standard(transaction: &Transaction) -> Result<(), AssetLockError> { - if transaction.input.len() > MAX_ASSET_LOCK_INPUTS { - return Err(AssetLockError::Builder(BuilderError::TooManyInputs { - count: transaction.input.len(), - max: MAX_ASSET_LOCK_INPUTS, - })); - } - let size = transaction.size(); - if size > MAX_ASSET_LOCK_TX_SIZE { - return Err(AssetLockError::Builder(BuilderError::InvalidData(format!( - "asset lock transaction is {size} bytes, above the {MAX_ASSET_LOCK_TX_SIZE}-byte \ - standard limit" - )))); - } - Ok(()) -} - /// The accounts among `offered` that contributed an input to `transaction`. /// /// Only these hold a share of the build's reservation, so this is what the @@ -403,9 +381,49 @@ struct SignedAssetLock { /// order; the first supplied the change address. offered: Vec, reservations: BuildReservations, + left_behind: LeftBehind, +} + +/// What a bounded drain did not spend. See [`AssetLockResult::remaining_utxo_count`]. +#[derive(Default)] +struct LeftBehind { + utxo_count: usize, + duffs: u64, } impl ManagedWalletInfo { + /// The UTXOs a drain over `offered` could have spent but did not, because + /// it hit [`MAX_ASSET_LOCK_INPUTS`]: final, spendable at the current height, + /// not reserved (this build's own inputs are reserved by now, and a UTXO + /// another in-flight build holds was never a candidate) and not an input of + /// `transaction`. An outpoint offered by two overlapping accounts counts once. + fn left_behind_by_drain( + &self, + offered: &[AccountType], + transaction: &Transaction, + ) -> LeftBehind { + let height = self.last_processed_height(); + let spent: HashSet = + transaction.input.iter().map(|input| input.previous_output).collect(); + let mut seen: HashSet = HashSet::new(); + let mut left = LeftBehind::default(); + for account in offered.iter().filter_map(|at| self.accounts.funds_account(at)) { + let reserved = account.reservations().reserved(height); + for utxo in account.utxos.values() { + if (utxo.is_confirmed || utxo.is_instantlocked) + && utxo.is_spendable(height) + && !spent.contains(&utxo.outpoint) + && !reserved.contains(&utxo.outpoint) + && seen.insert(utxo.outpoint) + { + left.utxo_count += 1; + left.duffs += utxo.value(); + } + } + } + left + } + /// Everything both asset-lock builders do before they diverge: validate the /// sources, assemble the payload, fund from the pooled accounts, capture /// what a post-build failure must hand back, and sign. @@ -435,10 +453,9 @@ impl ManagedWalletInfo { .set_special_payload(TransactionPayload::AssetLockPayloadType(AssetLockPayload::new( credit_outputs, ))) - .require_final_inputs() - // Implied by the asset-lock payload already; stated so the bound is - // visible where asset locks are built. - .set_max_inputs(MAX_ASSET_LOCK_INPUTS); + // 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); } @@ -463,26 +480,22 @@ impl ManagedWalletInfo { let reserved: Vec = transaction.input.iter().map(|input| input.previous_output).collect(); - let reservations = BuildReservations { - sets, - reserved, - token, + let left_behind = if drain { + self.left_behind_by_drain(&offered, &transaction) + } else { + LeftBehind::default() }; - // Backstop for the bounds coin selection already enforces: an asset - // lock Core deems non-standard is dropped by every peer without a - // reject, so the wallet would only ever see the broadcast time out. - // Refuse it here and give the inputs back instead. - if let Err(e) = check_asset_lock_standard(&transaction) { - reservations.release(); - return Err(e); - } - Ok(SignedAssetLock { transaction, fee, offered, - reservations, + reservations: BuildReservations { + sets, + reserved, + token, + }, + left_behind, }) } @@ -513,6 +526,20 @@ impl ManagedWalletInfo { /// be the sole source: pooling mixed outputs with transparent ones in one /// transaction links them and undoes the mixing. /// + /// A bounded drain leaves the smallest UTXOs behind; the result reports + /// them in [`AssetLockResult::remaining_utxo_count`] and + /// [`AssetLockResult::remaining_duffs`]. A follow-up drain of those may + /// fall below the target transition's minimum lock amount. + /// + /// The input cap makes the lock relayable by Core and within Platform's + /// input cap; it does not bound the size of a state transition that embeds + /// it. An InstantSend asset-lock proof carries the whole transaction and an + /// IS lock listing every spent outpoint, so near the cap the transition can + /// exceed Platform's size limit — choosing a ChainLock proof then is the + /// consumer's concern. An asset lock whose estimated signed size exceeds + /// [`MAX_ASSET_LOCK_TX_SIZE`] fails with [`BuilderError::AssetLockTooLarge`] + /// before anything is reserved or signed. + /// /// 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) @@ -541,6 +568,7 @@ impl ManagedWalletInfo { fee, offered, reservations, + left_behind, } = self .build_signed_asset_lock( wallet, @@ -584,6 +612,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, }) } @@ -624,6 +654,7 @@ impl ManagedWalletInfo { fee, offered, reservations, + left_behind, } = self .build_signed_asset_lock( wallet, @@ -702,6 +733,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, }) } } @@ -930,6 +963,8 @@ mod tests { 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 @@ -973,6 +1008,9 @@ mod tests { // 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))); } @@ -1000,6 +1038,7 @@ mod tests { 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. @@ -1035,6 +1074,8 @@ mod tests { 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 @@ -1096,44 +1137,55 @@ mod tests { assert_eq!(reserved_outpoints(&info).len(), result.transaction.input.len()); } - /// The post-build backstop refuses an over-cap or oversized transaction - /// even if one ever got past selection. - #[test] - fn standardness_backstop_rejects_oversized_asset_locks() { - let input = |vout| dashcore::TxIn { - previous_output: nth_outpoint(vout), - script_sig: ScriptBuf::new(), - sequence: 0xffff_ffff, - witness: dashcore::Witness::new(), - }; - let tx = |inputs: Vec, script_len: usize| Transaction { - version: 3, - lock_time: 0, - input: inputs, - output: vec![TxOut { - value: 1, - script_pubkey: ScriptBuf::from(vec![0u8; script_len]), - }], - special_transaction_payload: None, - }; + /// 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); - let at_cap = tx((0..MAX_ASSET_LOCK_INPUTS as u32).map(input).collect(), 1); - assert!(check_asset_lock_standard(&at_cap).is_ok()); + // 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 over_cap = tx((0..=MAX_ASSET_LOCK_INPUTS as u32).map(input).collect(), 1); - assert!(matches!( - check_asset_lock_standard(&over_cap), - Err(AssetLockError::Builder(BuilderError::TooManyInputs { - count: 101, - max: MAX_ASSET_LOCK_INPUTS - })) - )); - - let too_big = tx(vec![input(0)], MAX_ASSET_LOCK_TX_SIZE); - assert!(matches!( - check_asset_lock_standard(&too_big), - Err(AssetLockError::Builder(BuilderError::InvalidData(_))) - )); + 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"); + } + + /// 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 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 94a6b1b35..54a4819b6 100644 --- a/key-wallet/src/wallet/managed_wallet_info/coin_selection.rs +++ b/key-wallet/src/wallet/managed_wallet_info/coin_selection.rs @@ -182,16 +182,19 @@ impl CoinSelector { /// 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`, - /// 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`). + /// 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`). /// - /// `max` is clamped to at least 1. + /// `max` is taken as given; a drain bounded at `0` selects nothing. The + /// transaction builder clamps its bound to `1..=500` before passing it here. pub fn with_max_inputs(mut self, max: usize) -> Self { - self.max_inputs = Some(max.max(1)); + self.max_inputs = Some(max); self } @@ -260,9 +263,15 @@ impl CoinSelector { if spendable.len() > max_inputs { // Bounded drain: the `max_inputs` largest carry the most // value for the same input count, hence the same fee. - spendable.sort_by(|a, b| { - b.value().cmp(&a.value()).then_with(|| a.outpoint.cmp(&b.outpoint)) - }); + // 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). + if max_inputs > 0 { + 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( @@ -275,9 +284,20 @@ impl CoinSelector { change_output_size, ) } + SelectionStrategy::LargestFirst => self.select_with_strategy( + SelectionStrategy::LargestFirst, + utxos, + 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 result = self.select_with_strategy( + match self.select_with_strategy( strategy, pool.iter().copied(), target_amount, @@ -285,11 +305,15 @@ impl CoinSelector { current_height, base_size, change_output_size, - )?; - if result.selected.len() <= max_inputs - || strategy == SelectionStrategy::LargestFirst - { - return Ok(result); + ) { + Ok(result) if result.selected.len() <= max_inputs => return Ok(result), + // Over the bound, or short because small inputs cost more + // than they add: largest-first may still fund it within it. + Ok(_) + | Err(SelectionError::InsufficientFunds { + .. + }) => {} + 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 @@ -1002,6 +1026,30 @@ mod tests { 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); + } + /// 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] 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 4d263707e..3900fe71d 100644 --- a/key-wallet/src/wallet/managed_wallet_info/transaction_builder.rs +++ b/key-wallet/src/wallet/managed_wallet_info/transaction_builder.rs @@ -5,7 +5,6 @@ use crate::managed_account::reservation::{ReservationSet, ReservationToken}; use crate::managed_account::ManagedCoreFundsAccount; -use crate::wallet::managed_wallet_info::asset_lock_builder::MAX_ASSET_LOCK_INPUTS; use crate::wallet::managed_wallet_info::coin_selection::{ CoinSelector, SelectionStrategy, CHANGE_OUTPUT_SIZE, TX_OUTPUT_SIZE, }; @@ -28,6 +27,44 @@ 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: a drain spends at +/// most this many of the largest UTXOs, and an exact-amount build that cannot be +/// funded within it fails with [`BuilderError::TooManyInputs`]. That makes the +/// lock relayable by Core and within Platform's input cap. It does not make +/// every *state transition* built on it fit Platform's size limit: an +/// InstantSend asset-lock proof embeds the whole transaction plus an IS lock +/// listing every spent outpoint, so a lock near this cap can still yield an +/// oversized transition. Sizing the transition is the consumer's concern (a +/// ChainLock proof, which carries no transaction, avoids it). +/// +/// 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`). The builder checks its signed-size upper bound +/// against it before reserving or signing anything, and fails with +/// [`BuilderError::AssetLockTooLarge`]. At [`MAX_ASSET_LOCK_INPUTS`] P2PKH +/// inputs a built asset lock stays well below it. +/// +/// 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. @@ -146,20 +183,30 @@ impl TransactionBuilder { self } - /// The input bound coin selection must respect, when one applies: the - /// caller's [`Self::set_max_inputs`], and/or [`MAX_ASSET_LOCK_INPUTS`] for an - /// asset-lock payload (Core refuses a larger asset lock as non-standard, - /// and Platform rejects it), the tighter of the two, never above - /// `MAX_STANDARD_TX_INPUTS`. - fn selection_input_bound(&self) -> Option { - let asset_lock_bound = - matches!(self.special_payload, Some(TransactionPayload::AssetLockPayloadType(_))) - .then_some(MAX_ASSET_LOCK_INPUTS); - let bound = match (self.max_inputs, asset_lock_bound) { - (Some(a), Some(b)) => Some(a.min(b)), - (a, b) => a.or(b), - }?; - Some(bound.clamp(1, MAX_STANDARD_TX_INPUTS)) + fn is_asset_lock(&self) -> bool { + matches!(self.special_payload, Some(TransactionPayload::AssetLockPayloadType(_))) + } + + /// The most inputs this build may spend: the caller's + /// [`Self::set_max_inputs`] and/or [`MAX_ASSET_LOCK_INPUTS`] for an + /// asset-lock payload, the tighter of the two, clamped to + /// `1..=MAX_STANDARD_TX_INPUTS`; `MAX_STANDARD_TX_INPUTS` when neither applies. + fn input_cap(&self) -> usize { + let asset_lock_cap = self.is_asset_lock().then_some(MAX_ASSET_LOCK_INPUTS); + [self.max_inputs, asset_lock_cap] + .into_iter() + .flatten() + .min() + .unwrap_or(MAX_STANDARD_TX_INPUTS) + .clamp(1, MAX_STANDARD_TX_INPUTS) + } + + /// Whether coin selection itself must respect [`Self::input_cap`] (bounded + /// drain, bounded selection). Without an explicit or asset-lock bound the + /// cap is only checked after selection, so an over-cap regular drain fails + /// rather than silently leaving coins behind. + fn selection_is_bounded(&self) -> bool { + self.max_inputs.is_some() || self.is_asset_lock() } /// Restrict coin selection to final inputs: confirmed or @@ -539,11 +586,10 @@ impl TransactionBuilder { // Must match `calculate_base_size`, including the conservative VIN0 routing-script size. let change_output_size = self.estimated_change_output_size(); - let input_bound = self.selection_input_bound(); - let max_inputs = input_bound.unwrap_or(MAX_STANDARD_TX_INPUTS); + let max_inputs = self.input_cap(); let mut selector = CoinSelector::new(self.selection_strategy); - if let Some(bound) = input_bound { - selector = selector.with_max_inputs(bound); + if self.selection_is_bounded() { + selector = selector.with_max_inputs(max_inputs); } let selection = selector .select_coins_with_size( @@ -716,6 +762,22 @@ impl TransactionBuilder { special_transaction_payload: self.special_payload, }; + // Core refuses an oversized asset lock as non-standard and peers drop it + // silently. Check the signed-size upper bound now, before anything is + // reserved or signed (signing may be a hardware round-trip per input). + if matches!( + transaction.special_transaction_payload, + Some(TransactionPayload::AssetLockPayloadType(_)) + ) { + let size = transaction.size() + transaction.input.len() * MAX_P2PKH_SCRIPT_SIG_SIZE; + 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 @@ -1088,6 +1150,13 @@ pub enum BuilderError { len: usize, max: usize, }, + /// The asset lock's estimated signed size 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 { @@ -1120,6 +1189,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})") + } } } } From c078e8650b49a2b1b4ba4d42e801739e9a3148f9 Mon Sep 17 00:00:00 2001 From: Roman <51091564+jeanpierreroma@users.noreply.github.com> Date: Fri, 16 Oct 2026 22:39:54 +0300 Subject: [PATCH 05/10] fix(key-wallet): address second review of the asset-lock input cap - Re-check the signed asset lock's actual size after signing (an external signer may return larger scriptSigs than the pre-sign bound assumes) and release the reservation owner-guarded on BuilderError::AssetLockTooLarge. - Size-aware fallback: an exact-amount asset lock that fits the input cap but not the size limit retries largest-first before failing. - Derive remaining_utxo_count / remaining_duffs from the candidates the builder actually offered selection (new crate-private build_signed_reserved_reporting) instead of re-filtering the accounts. - Remove the unused TransactionBuilder::set_max_inputs: only an asset-lock payload bounds selection, so a regular drain never silently leaves coins. - Compute is_asset_lock once in assemble_unsigned. - Coin selection: one call path for LargestFirst, and no largest-first retry when the spendable pool is below even a one-input spend. - Fix the stale add_funding_reservation_only doc. Co-Authored-By: Claude Opus 5.5 --- .../managed_wallet_info/asset_lock_builder.rs | 92 ++++---- .../managed_wallet_info/coin_selection.rs | 36 +-- .../transaction_builder.rs | 216 +++++++++++------- 3 files changed, 193 insertions(+), 151 deletions(-) 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 1a683d7ec..014f3392c 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 @@ -7,7 +7,6 @@ use dashcore::blockdata::transaction::special_transaction::asset_lock::AssetLock use dashcore::blockdata::transaction::special_transaction::TransactionPayload; use dashcore::{OutPoint, Transaction, TxOut}; use secp256k1::PublicKey; -use std::collections::HashSet; use std::fmt; use crate::account::AccountType; @@ -18,7 +17,7 @@ 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. @@ -313,6 +312,19 @@ fn validate_funding_sources( Ok(()) } +/// Reject a signed asset lock larger than Core's standard limit +/// ([`MAX_ASSET_LOCK_TX_SIZE`]). +fn check_signed_asset_lock_size(transaction: &Transaction) -> Result<(), AssetLockError> { + let size = transaction.size(); + if size > MAX_ASSET_LOCK_TX_SIZE { + return Err(AssetLockError::Builder(BuilderError::AssetLockTooLarge { + size, + max: MAX_ASSET_LOCK_TX_SIZE, + })); + } + Ok(()) +} + /// The accounts among `offered` that contributed an input to `transaction`. /// /// Only these hold a share of the build's reservation, so this is what the @@ -381,49 +393,12 @@ struct SignedAssetLock { /// order; the first supplied the change address. offered: Vec, reservations: BuildReservations, - left_behind: LeftBehind, -} - -/// What a bounded drain did not spend. See [`AssetLockResult::remaining_utxo_count`]. -#[derive(Default)] -struct LeftBehind { - utxo_count: usize, - duffs: u64, + /// What a bounded drain left behind; zero for an exact-amount build. See + /// [`AssetLockResult::remaining_utxo_count`]. + left_behind: Unselected, } impl ManagedWalletInfo { - /// The UTXOs a drain over `offered` could have spent but did not, because - /// it hit [`MAX_ASSET_LOCK_INPUTS`]: final, spendable at the current height, - /// not reserved (this build's own inputs are reserved by now, and a UTXO - /// another in-flight build holds was never a candidate) and not an input of - /// `transaction`. An outpoint offered by two overlapping accounts counts once. - fn left_behind_by_drain( - &self, - offered: &[AccountType], - transaction: &Transaction, - ) -> LeftBehind { - let height = self.last_processed_height(); - let spent: HashSet = - transaction.input.iter().map(|input| input.previous_output).collect(); - let mut seen: HashSet = HashSet::new(); - let mut left = LeftBehind::default(); - for account in offered.iter().filter_map(|at| self.accounts.funds_account(at)) { - let reserved = account.reservations().reserved(height); - for utxo in account.utxos.values() { - if (utxo.is_confirmed || utxo.is_instantlocked) - && utxo.is_spendable(height) - && !spent.contains(&utxo.outpoint) - && !reserved.contains(&utxo.outpoint) - && seen.insert(utxo.outpoint) - { - left.utxo_count += 1; - left.duffs += utxo.value(); - } - } - } - left - } - /// Everything both asset-lock builders do before they diverge: validate the /// sources, assemble the payload, fund from the pooled accounts, capture /// what a post-build failure must hand back, and sign. @@ -475,26 +450,41 @@ 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(); + let reservations = BuildReservations { + sets, + reserved, + token, + }; + + // The builder checked a signed-size upper bound before signing, but it + // assumes compressed keys and low-S signatures; an external signer + // could return larger scriptSigs. Check what was actually signed, and + // give the inputs back rather than hand out a lock Core drops. + if let Err(e) = check_signed_asset_lock_size(&transaction) { + reservations.release(); + return Err(e); + } + + // Selection drew from every final, unreserved, spendable UTXO of the + // offered accounts; what it left out is what the cap left behind. An + // exact-amount build leaves UTXOs out by design, so it reports none. let left_behind = if drain { - self.left_behind_by_drain(&offered, &transaction) + unselected } else { - LeftBehind::default() + Unselected::default() }; Ok(SignedAssetLock { transaction, fee, offered, - reservations: BuildReservations { - sets, - reserved, - token, - }, + reservations, left_behind, }) } 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 54a4819b6..dcd2faa23 100644 --- a/key-wallet/src/wallet/managed_wallet_info/coin_selection.rs +++ b/key-wallet/src/wallet/managed_wallet_info/coin_selection.rs @@ -284,20 +284,11 @@ impl CoinSelector { change_output_size, ) } - SelectionStrategy::LargestFirst => self.select_with_strategy( - SelectionStrategy::LargestFirst, - utxos, - 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(); - match self.select_with_strategy( + let first = self.select_with_strategy( strategy, pool.iter().copied(), target_amount, @@ -305,14 +296,27 @@ impl CoinSelector { 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, or short because small inputs cost more - // than they add: largest-first may still fund it within it. - Ok(_) - | Err(SelectionError::InsufficientFunds { + // 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 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 3900fe71d..59f0553c5 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,7 @@ 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, 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}; @@ -101,6 +101,23 @@ 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, +} + /// 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 @@ -128,8 +145,6 @@ 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, - /// Caller-set input bound. See [`Self::set_max_inputs`]. - max_inputs: Option, /// 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 @@ -158,57 +173,10 @@ impl TransactionBuilder { change_to_first_input: false, special_payload: None, payload_finalizer: None, - max_inputs: None, funding: Vec::new(), } } - /// Bound the number of inputs this build may spend, and have coin - /// selection respect the bound instead of only rejecting an oversized pick. - /// - /// A drain ([`SelectionStrategy::All`]) becomes a *bounded drain*: it - /// spends the `max` largest spendable UTXOs and leaves the rest. Any other - /// strategy picks a selection within `max` inputs, or fails with - /// [`BuilderError::TooManyInputs`] (whose `count` is the fewest inputs the - /// amount needs) when none exists. - /// - /// The bound is clamped to 1..=500 (the relay-standard maximum). A - /// builder carrying an asset-lock payload is bounded at - /// [`MAX_ASSET_LOCK_INPUTS`] without calling this; a lower value set here - /// still wins. Without either, a build is only checked against the 500-input - /// standard limit after selection, as before: an unbounded drain over a - /// larger account fails rather than leaving coins behind. - pub fn set_max_inputs(mut self, max: usize) -> Self { - self.max_inputs = Some(max); - self - } - - fn is_asset_lock(&self) -> bool { - matches!(self.special_payload, Some(TransactionPayload::AssetLockPayloadType(_))) - } - - /// The most inputs this build may spend: the caller's - /// [`Self::set_max_inputs`] and/or [`MAX_ASSET_LOCK_INPUTS`] for an - /// asset-lock payload, the tighter of the two, clamped to - /// `1..=MAX_STANDARD_TX_INPUTS`; `MAX_STANDARD_TX_INPUTS` when neither applies. - fn input_cap(&self) -> usize { - let asset_lock_cap = self.is_asset_lock().then_some(MAX_ASSET_LOCK_INPUTS); - [self.max_inputs, asset_lock_cap] - .into_iter() - .flatten() - .min() - .unwrap_or(MAX_STANDARD_TX_INPUTS) - .clamp(1, MAX_STANDARD_TX_INPUTS) - } - - /// Whether coin selection itself must respect [`Self::input_cap`] (bounded - /// drain, bounded selection). Without an explicit or asset-lock bound the - /// cap is only checked after selection, so an over-cap regular drain fails - /// rather than silently leaving coins behind. - fn selection_is_bounded(&self) -> bool { - self.max_inputs.is_some() || self.is_asset_lock() - } - /// 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 @@ -261,9 +229,11 @@ 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. (A build carrying an asset-lock + /// payload is different: its drain is bounded at [`MAX_ASSET_LOCK_INPUTS`] + /// and spends the largest UTXOs, leaving the rest.) /// /// Reservation bookkeeping is unchanged: `owned` still covers every /// unreserved UTXO of the account, so whichever seeded outpoints selection @@ -536,9 +506,9 @@ impl TransactionBuilder { /// reservation set (nothing was reserved, nothing to release). /// /// [`add_funding`]: Self::add_funding - fn assemble_unsigned( - mut self, - ) -> Result<(Transaction, Vec, Option), BuilderError> { + 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); @@ -586,21 +556,59 @@ impl TransactionBuilder { // Must match `calculate_base_size`, including the conservative VIN0 routing-script size. let change_output_size = self.estimated_change_output_size(); - let max_inputs = self.input_cap(); - let mut selector = CoinSelector::new(self.selection_strategy); - if self.selection_is_bounded() { - selector = selector.with_max_inputs(max_inputs); - } - let selection = selector - .select_coins_with_size( - self.inputs.iter(), - total_output, - self.fee_rate, - self.current_height, - self.calculate_base_size(), - change_output_size, + // An asset lock is bounded inside selection (a drain keeps the largest + // UTXOs, an exact build stays within the cap or reports how many inputs + // it needs). A regular build is only checked against the standard cap + // after selection, so an over-cap regular drain fails rather than + // silently leaving coins behind. + let max_inputs = if is_asset_lock { + MAX_ASSET_LOCK_INPUTS + } else { + MAX_STANDARD_TX_INPUTS + }; + let base_size = self.calculate_base_size(); + let select = |strategy: SelectionStrategy| { + let selector = CoinSelector::new(strategy); + let selector = if is_asset_lock { + selector.with_max_inputs(max_inputs) + } else { + 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) + }; + // Conservative signed size: `base_size` budgets a change output and + // the payload's maximum signed size, and each input is priced at its + // largest low-S P2PKH size. + let estimated_signed_size = + |inputs: usize| base_size + inputs * TX_INPUT_SIZE + varint_size(inputs) - 1; + + let mut selection = select(self.selection_strategy)?; + // Size-aware fallback: an exact-amount asset lock that fits the input + // cap but not Core's size limit may still fit with fewer, larger inputs. + // A drain cannot shrink (it spends the largest UTXOs already), and + // largest-first is already the fewest-inputs pick. + if is_asset_lock + && !matches!( + self.selection_strategy, + SelectionStrategy::All | SelectionStrategy::LargestFirst ) - .map_err(BuilderError::CoinSelection)?; + && estimated_signed_size(selection.selected.len()) > MAX_ASSET_LOCK_TX_SIZE + { + if let Ok(largest_first) = select(SelectionStrategy::LargestFirst) { + if largest_first.selected.len() < selection.selected.len() { + selection = largest_first; + } + } + } let mut selected_inputs = selection.selected; @@ -656,8 +664,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) { @@ -739,9 +745,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 @@ -765,10 +769,7 @@ impl TransactionBuilder { // Core refuses an oversized asset lock as non-standard and peers drop it // silently. Check the signed-size upper bound now, before anything is // reserved or signed (signing may be a hardware round-trip per input). - if matches!( - transaction.special_transaction_payload, - Some(TransactionPayload::AssetLockPayloadType(_)) - ) { + if is_asset_lock { let size = transaction.size() + transaction.input.len() * MAX_P2PKH_SCRIPT_SIG_SIZE; if size > MAX_ASSET_LOCK_TX_SIZE { return Err(BuilderError::AssetLockTooLarge { @@ -798,7 +799,27 @@ impl TransactionBuilder { owner }); - return Ok((transaction, selected_inputs, reservation_token)); + // What selection considered but left out: the same spendable candidate + // pool it drew from, so the count cannot drift from selection. + let selected: HashSet = selected_inputs.iter().map(|u| u.outpoint).collect(); + let mut seen: HashSet = HashSet::new(); + let mut unselected = Unselected::default(); + 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(); + } + } + + return Ok(Assembled { + transaction, + inputs: selected_inputs, + reservation: reservation_token, + unselected, + }); // BIP-69: Sort outputs by amount first, then by scriptPubKey // lexicographically. @@ -849,7 +870,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) { @@ -893,10 +919,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, @@ -905,7 +948,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 @@ -939,7 +987,7 @@ impl TransactionBuilder { 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 From 958154bde272ef4652ae42b38c395dc10e39c230 Mon Sep 17 00:00:00 2001 From: Roman <51091564+jeanpierreroma@users.noreply.github.com> Date: Fri, 16 Oct 2026 22:41:21 +0300 Subject: [PATCH 06/10] test(key-wallet): cover the post-sign size check and size-aware fallback - A signer returning oversized scriptSigs is caught after signing with AssetLockTooLarge and the reservation is released. - An exact-amount asset lock whose smallest-first pick fits the input cap but not the size limit falls back to largest-first. - A bounded selection over a pool below a one-input spend reports the first strategy's InsufficientFunds. Co-Authored-By: Claude Opus 5.5 --- .../managed_wallet_info/asset_lock_builder.rs | 60 +++++++++++++++++++ .../managed_wallet_info/coin_selection.rs | 17 ++++++ .../transaction_builder.rs | 50 ++++++++++++++++ 3 files changed, 127 insertions(+) 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 014f3392c..fccabf8c8 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 @@ -1155,6 +1155,66 @@ mod tests { 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"); + } + /// An exact-amount build never reports anything left behind, even when the /// account holds UTXOs it did not need. #[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 dcd2faa23..547fb5405 100644 --- a/key-wallet/src/wallet/managed_wallet_info/coin_selection.rs +++ b/key-wallet/src/wallet/managed_wallet_info/coin_selection.rs @@ -1054,6 +1054,23 @@ mod tests { assert_eq!(bounded.total_value, 100_000); } + /// 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] 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 59f0553c5..677f4f30d 100644 --- a/key-wallet/src/wallet/managed_wallet_info/transaction_builder.rs +++ b/key-wallet/src/wallet/managed_wallet_info/transaction_builder.rs @@ -1589,6 +1589,56 @@ mod tests { /// mirroring the payload's credit outputs. That output is part of /// Σ(outputs), so the returned fee must be the miner fee only — the locked /// credits must never be counted as fee. + /// 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); + } + #[test] fn test_asset_lock_fee_excludes_locked_credits() { let utxos = vec![Utxo::dummy(0, 1_000_000, 100, false, true)]; From a557fa7f6dd565bf3e3910dc4b595415feff22fc Mon Sep 17 00:00:00 2001 From: Roman <51091564+jeanpierreroma@users.noreply.github.com> Date: Fri, 16 Oct 2026 22:52:40 +0300 Subject: [PATCH 07/10] fix(key-wallet): no silent partial drain through the generic builder - An asset-lock drain through the public TransactionBuilder over 100 candidates now fails with BuilderError::TooManyInputs { max: 100 }. The bounded "largest N" drain needs the crate-private bounded_drain() flag, set only by the asset-lock builder, the one path that reports what was left behind (remaining_utxo_count / remaining_duffs). Unselected is computed only for that drain. - One signed-size upper bound (signed_size_upper_bound, from the assembled transaction) drives both the size-aware fallback and the pre-sign check. - A bounded drain whose largest N exceed MAX_ASSET_LOCK_TX_SIZE drops its smallest inputs until the bound fits instead of failing. - The builder skips its largest-first size retry when selection already ran or fell back to largest-first (CoinSelector::select_coins_reporting). - The post-sign actual-size check moves into the builder's signed path, so every signed asset-lock build is covered; the builder releases its own reservation owner-guarded on that error. - CoinSelector::with_max_inputs treats 0 as 1; doc no longer claims a builder clamp. - Selection is assembled through unsigned_from_selection so a retry does not consume the builder state. - Move a test inserted inside another test's doc comment. Co-Authored-By: Claude Opus 5.5 --- .../managed_wallet_info/asset_lock_builder.rs | 97 ++-- .../managed_wallet_info/coin_selection.rs | 105 +++- .../transaction_builder.rs | 531 ++++++++++++------ 3 files changed, 485 insertions(+), 248 deletions(-) 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 fccabf8c8..333b3d1ab 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 @@ -312,19 +312,6 @@ fn validate_funding_sources( Ok(()) } -/// Reject a signed asset lock larger than Core's standard limit -/// ([`MAX_ASSET_LOCK_TX_SIZE`]). -fn check_signed_asset_lock_size(transaction: &Transaction) -> Result<(), AssetLockError> { - let size = transaction.size(); - if size > MAX_ASSET_LOCK_TX_SIZE { - return Err(AssetLockError::Builder(BuilderError::AssetLockTooLarge { - size, - max: MAX_ASSET_LOCK_TX_SIZE, - })); - } - Ok(()) -} - /// The accounts among `offered` that contributed an input to `transaction`. /// /// Only these hold a share of the build's reservation, so this is what the @@ -393,7 +380,7 @@ struct SignedAssetLock { /// order; the first supplied the change address. offered: Vec, reservations: BuildReservations, - /// What a bounded drain left behind; zero for an exact-amount build. See + /// What a bounded drain left behind; zero otherwise. See /// [`AssetLockResult::remaining_utxo_count`]. left_behind: Unselected, } @@ -432,7 +419,9 @@ impl ManagedWalletInfo { // 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, @@ -456,36 +445,18 @@ impl ManagedWalletInfo { let reserved: Vec = transaction.input.iter().map(|input| input.previous_output).collect(); - let reservations = BuildReservations { - sets, - reserved, - token, - }; - - // The builder checked a signed-size upper bound before signing, but it - // assumes compressed keys and low-S signatures; an external signer - // could return larger scriptSigs. Check what was actually signed, and - // give the inputs back rather than hand out a lock Core drops. - if let Err(e) = check_signed_asset_lock_size(&transaction) { - reservations.release(); - return Err(e); - } - - // Selection drew from every final, unreserved, spendable UTXO of the - // offered accounts; what it left out is what the cap left behind. An - // exact-amount build leaves UTXOs out by design, so it reports none. - let left_behind = if drain { - unselected - } else { - Unselected::default() - }; - + // 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, offered, - reservations, - left_behind, + reservations: BuildReservations { + sets, + reserved, + token, + }, + left_behind: unselected, }) } @@ -509,13 +480,20 @@ impl ManagedWalletInfo { /// /// `drain` locks the sourced accounts' spendable balance: every final UTXO /// is consumed — or, when there are more than [`MAX_ASSET_LOCK_INPUTS`], the - /// [`MAX_ASSET_LOCK_INPUTS`] largest, leaving the rest in the account for a - /// later lock — and the single credit output's value is rewritten to + /// [`MAX_ASSET_LOCK_INPUTS`] largest (fewer still, dropping the smallest, + /// if those would exceed [`MAX_ASSET_LOCK_TX_SIZE`]), leaving the rest in + /// the account 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. /// + /// This is the only entry point that drains partially: a drain through the + /// generic [`TransactionBuilder`] with an asset-lock payload fails with + /// [`BuilderError::TooManyInputs`] over the cap instead, because only here + /// is what was left behind reported. + /// /// A bounded drain leaves the smallest UTXOs behind; the result reports /// them in [`AssetLockResult::remaining_utxo_count`] and /// [`AssetLockResult::remaining_duffs`]. A follow-up drain of those may @@ -1005,6 +983,39 @@ mod tests { 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() { 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 547fb5405..6e180d07f 100644 --- a/key-wallet/src/wallet/managed_wallet_info/coin_selection.rs +++ b/key-wallet/src/wallet/managed_wallet_info/coin_selection.rs @@ -191,10 +191,10 @@ impl CoinSelector { /// target really needs — and the caller must reject it (the transaction /// builder does, with `BuilderError::TooManyInputs`). /// - /// `max` is taken as given; a drain bounded at `0` selects nothing. The - /// transaction builder clamps its bound to `1..=500` before passing it here. + /// 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); + self.max_inputs = Some(max.max(1)); self } @@ -244,16 +244,47 @@ impl CoinSelector { where I: IntoIterator, { + self.select_coins_reporting( + utxos, + target_amount, + fee_rate, + current_height, + base_size, + change_output_size, + ) + .map(|(result, _)| result) + } + + /// [`Self::select_coins_with_size`], also reporting the strategy that + /// produced the result: the configured one, or + /// [`SelectionStrategy::LargestFirst`] when a bounded selection fell back + /// to it. + pub(crate) fn select_coins_reporting<'a, I>( + &self, + utxos: I, + target_amount: u64, + fee_rate: FeeRate, + current_height: u32, + base_size: usize, + change_output_size: usize, + ) -> Result<(SelectionResult, SelectionStrategy), SelectionError> + where + I: IntoIterator, + { + let strategy = self.strategy; + let tag = |result: SelectionResult| (result, strategy); 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, - ); + return self + .select_with_strategy( + self.strategy, + utxos, + target_amount, + fee_rate, + current_height, + base_size, + change_output_size, + ) + .map(tag); }; match self.strategy { @@ -267,11 +298,10 @@ impl CoinSelector { // 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). - if max_inputs > 0 { - spendable.select_nth_unstable_by(max_inputs - 1, |a, b| { - b.value().cmp(&a.value()).then_with(|| a.outpoint.cmp(&b.outpoint)) - }); - } + // `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( @@ -283,6 +313,7 @@ impl CoinSelector { base_size, change_output_size, ) + .map(tag) } strategy => { // References only, collected once so the fallback can reuse @@ -299,7 +330,7 @@ impl CoinSelector { ); if strategy == SelectionStrategy::LargestFirst { // Already the fewest-inputs pick; nothing to fall back to. - return first; + return first.map(tag); } // The cheapest any selection can be: one input, no change. let floor = @@ -307,7 +338,7 @@ impl CoinSelector { base_size.saturating_sub(change_output_size) + TX_INPUT_SIZE, )); match first { - Ok(result) if result.selected.len() <= max_inputs => return Ok(result), + Ok(result) if result.selected.len() <= max_inputs => return Ok(tag(result)), // Over the bound: largest-first may fit within it. Ok(_) => {} // Short because small inputs cost more than they add, where @@ -331,6 +362,7 @@ impl CoinSelector { base_size, change_output_size, ) + .map(|result| (result, SelectionStrategy::LargestFirst)) } } } @@ -1054,6 +1086,41 @@ mod tests { 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); + } + + /// The reporting entry point names the strategy that produced the result. + #[test] + fn test_selection_reports_the_strategy_used() { + let utxos = ladder(20, 10_000, 1_000); + let selector = CoinSelector::new(SelectionStrategy::SmallestFirst); + let (_, used) = selector + .select_coins_reporting(&utxos, 20_000, FeeRate::new(1000), 200, 78, 34) + .unwrap(); + assert_eq!(used, SelectionStrategy::SmallestFirst); + let (_, used) = selector + .with_max_inputs(4) + .select_coins_reporting(&utxos, 80_000, FeeRate::new(1000), 200, 78, 34) + .unwrap(); + assert_eq!(used, SelectionStrategy::LargestFirst, "the bounded fallback ran"); + } + /// A pool that cannot fund even a one-input spend fails with the first /// strategy's error; the bounded fallback changes nothing there. #[test] 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 677f4f30d..30309337f 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_INPUT_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}; @@ -36,9 +37,11 @@ const MAX_STANDARD_TX_INPUTS: usize = 500; /// 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: a drain spends at -/// most this many of the largest UTXOs, and an exact-amount build that cannot be -/// funded within it fails with [`BuilderError::TooManyInputs`]. That makes the +/// Every build carrying an asset-lock payload is held to it: an exact-amount +/// build that cannot be funded within it, and a drain over more candidates, +/// fail with [`BuilderError::TooManyInputs`]. Only the asset-lock builder's +/// drain (`ManagedWalletInfo::build_asset_lock`) spends just the largest UTXOs +/// instead, and reports the rest. That makes the /// lock relayable by Core and within Platform's input cap. It does not make /// every *state transition* built on it fit Platform's size limit: an /// InstantSend asset-lock proof embeds the whole transaction plus an IS lock @@ -118,6 +121,35 @@ struct Assembled { 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 @@ -145,6 +177,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 @@ -173,10 +208,28 @@ impl TransactionBuilder { change_to_first_input: false, special_payload: None, payload_finalizer: None, + bounded_drain: false, funding: Vec::new(), } } + /// Let an asset-lock drain ([`SelectionStrategy::All`] with an asset-lock + /// payload) over more than [`MAX_ASSET_LOCK_INPUTS`] candidates spend the + /// largest ones — as many as fit both the input cap and + /// [`MAX_ASSET_LOCK_TX_SIZE`] — and leave the rest, instead of failing with + /// [`BuilderError::TooManyInputs`]. + /// + /// Crate-private because a partial drain must be reported: the asset-lock + /// builder sets it and surfaces what was left behind (through + /// [`Self::build_signed_reserved_reporting`]) as + /// `AssetLockResult::remaining_utxo_count` / `remaining_duffs`. Through the + /// public builder an over-cap asset-lock drain fails rather than silently + /// leaving coins behind. No effect on any other build. + 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 @@ -232,8 +285,8 @@ impl TransactionBuilder { /// 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. (A build carrying an asset-lock - /// payload is different: its drain is bounded at [`MAX_ASSET_LOCK_INPUTS`] - /// and spends the largest UTXOs, leaving the rest.) + /// payload is capped at [`MAX_ASSET_LOCK_INPUTS`] instead; only the + /// asset-lock builder's drain may then spend just the largest UTXOs.) /// /// Reservation bookkeeping is unchanged: `owned` still covers every /// unreserved UTXO of the account, so whichever seeded outpoints selection @@ -497,161 +550,46 @@ 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 { - 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)); - } - - // 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 bounded inside selection (a drain keeps the largest - // UTXOs, an exact build stays within the cap or reports how many inputs - // it needs). A regular build is only checked against the standard cap - // after selection, so an over-cap regular drain fails rather than - // silently leaving coins behind. - let max_inputs = if is_asset_lock { - MAX_ASSET_LOCK_INPUTS - } else { - MAX_STANDARD_TX_INPUTS - }; - let base_size = self.calculate_base_size(); - let select = |strategy: SelectionStrategy| { - let selector = CoinSelector::new(strategy); - let selector = if is_asset_lock { - selector.with_max_inputs(max_inputs) - } else { - 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) - }; - // Conservative signed size: `base_size` budgets a change output and - // the payload's maximum signed size, and each input is priced at its - // largest low-S P2PKH size. - let estimated_signed_size = - |inputs: usize| base_size + inputs * TX_INPUT_SIZE + varint_size(inputs) - 1; - - let mut selection = select(self.selection_strategy)?; - // Size-aware fallback: an exact-amount asset lock that fits the input - // cap but not Core's size limit may still fit with fewer, larger inputs. - // A drain cannot shrink (it spends the largest UTXOs already), and - // largest-first is already the fewest-inputs pick. - if is_asset_lock - && !matches!( - self.selection_strategy, - SelectionStrategy::All | SelectionStrategy::LargestFirst - ) - && estimated_signed_size(selection.selected.len()) > MAX_ASSET_LOCK_TX_SIZE - { - if let Ok(largest_first) = select(SelectionStrategy::LargestFirst) { - if largest_first.selected.len() < selection.selected.len() { - selection = largest_first; - } - } - } - - let mut selected_inputs = selection.selected; - - // A bounded selector only exceeds its bound when no selection within it - // funds the target; an unbounded one is checked against the standard cap. - if selected_inputs.len() > max_inputs { - return Err(BuilderError::TooManyInputs { - count: selected_inputs.len(), - max: max_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. Leaves `self` untouched so a selection can be retried. + fn unsigned_from_selection( + &self, + mut selected_inputs: Vec, + total_output: u64, + estimated_fee: u64, + is_asset_lock: bool, + ) -> Result<(Transaction, Vec), BuilderError> { + let mut special_payload = self.special_payload.clone(); 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 { value: total_output, script_pubkey: ScriptBuf::new_op_return(&[]), }], - _ => self.outputs, + _ => self.outputs.clone(), }; 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 @@ -705,9 +643,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 \ @@ -733,7 +669,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() @@ -763,14 +699,165 @@ 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)); + } + + // 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(); + 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_reporting( + self.inputs.iter(), + total_output, + self.fee_rate, + self.current_height, + base_size, + change_output_size, + ) + .map_err(BuilderError::CoinSelection) + }; + let assemble = |selection: SelectionResult| { + self.unsigned_from_selection( + selection.selected, + total_output, + selection.estimated_fee, + is_asset_lock, + ) + }; + + let (selection, produced_by) = select(strategy, bound_selection.then_some(max_inputs))?; + + // A bounded selector only exceeds its bound when no selection within it + // funds the target; an unbounded one is checked against the cap here. + if selection.selected.len() > max_inputs { + return Err(BuilderError::TooManyInputs { + count: selection.selected.len(), + max: max_inputs, + }); + } + + let (mut transaction, mut selected_inputs) = assemble(selection)?; + // Core refuses an oversized asset lock as non-standard and peers drop it - // silently. Check the signed-size upper bound now, before anything is - // reserved or signed (signing may be a hardware round-trip per input). - if is_asset_lock { - let size = transaction.size() + transaction.input.len() * MAX_P2PKH_SCRIPT_SIG_SIZE; + // 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). + if is_asset_lock && signed_size_upper_bound(&transaction) > MAX_ASSET_LOCK_TX_SIZE { + if bounded_drain { + // A bounded drain shrinks: drop its smallest inputs until it + // fits. Each dropped input saves at least TX_INPUT_SIZE bytes of + // the bound, so jump straight to a count that may fit, and keep + // stepping down if rounding left it just over. + let mut keep = selected_inputs.len(); + while signed_size_upper_bound(&transaction) > MAX_ASSET_LOCK_TX_SIZE { + let excess = signed_size_upper_bound(&transaction) - MAX_ASSET_LOCK_TX_SIZE; + keep = keep.saturating_sub(excess.div_ceil(TX_INPUT_SIZE).max(1)); + if keep == 0 { + break; + } + let Ok((smaller, _)) = select(SelectionStrategy::All, Some(keep)) else { + break; + }; + (transaction, selected_inputs) = assemble(smaller)?; + } + } else if !is_drain && produced_by != SelectionStrategy::LargestFirst { + // An exact-amount pick may fit with fewer, larger inputs. + // Largest-first is already the fewest-inputs pick, so skip it + // when selection already ran or fell back to it. + if let Ok((largest_first, _)) = + select(SelectionStrategy::LargestFirst, Some(max_inputs)) + { + if largest_first.selected.len() <= max_inputs { + if let Ok((tx, inputs)) = assemble(largest_first) { + if signed_size_upper_bound(&tx) <= MAX_ASSET_LOCK_TX_SIZE { + (transaction, selected_inputs) = (tx, inputs); + } + } + } + } + } + let size = signed_size_upper_bound(&transaction); if size > MAX_ASSET_LOCK_TX_SIZE { return Err(BuilderError::AssetLockTooLarge { size, @@ -799,47 +886,29 @@ impl TransactionBuilder { owner }); - // What selection considered but left out: the same spendable candidate - // pool it drew from, so the count cannot drift from selection. - let selected: HashSet = selected_inputs.iter().map(|u| u.outpoint).collect(); - let mut seen: HashSet = HashSet::new(); + // 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(); - 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(); + 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(); + } } } - return Ok(Assembled { + Ok(Assembled { transaction, inputs: selected_inputs, reservation: reservation_token, 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, - } - } + }) } /// Build the unsigned transaction, returning it alongside the fee it pays @@ -985,6 +1054,24 @@ 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, unselected)) @@ -1198,8 +1285,9 @@ pub enum BuilderError { len: usize, max: usize, }, - /// The asset lock's estimated signed size exceeds Dash Core's standard - /// limit ([`MAX_ASSET_LOCK_TX_SIZE`]); Core would refuse to relay it. + /// 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, @@ -1585,10 +1673,6 @@ mod tests { assert_eq!(fee, 526, "fee must include the 300-duff dropped dust remainder"); } - /// 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 - /// credits must never be counted as fee. /// 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. @@ -1639,6 +1723,81 @@ mod tests { 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); + } + + /// 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 + /// credits must never be counted as fee. #[test] fn test_asset_lock_fee_excludes_locked_credits() { let utxos = vec![Utxo::dummy(0, 1_000_000, 100, false, true)]; From 75174b353f99bff4e0307d1e3466767b078182ee Mon Sep 17 00:00:00 2001 From: Roman <51091564+jeanpierreroma@users.noreply.github.com> Date: Fri, 16 Oct 2026 23:04:22 +0300 Subject: [PATCH 08/10] fix(key-wallet): refuse non-P2PKH asset-lock inputs, simplify the drain shrink - The asset-lock size bound prices every input as a P2PKH spend, so an asset-lock build now refuses a non-P2PKH candidate up front with the typed BuilderError::AssetLockNonP2pkhInput { outpoint }, before anything is selected or reserved. build_unsigned_reserved documents that an external signer must keep standard 107-byte P2PKH scriptSigs (no post-sign check on that path). Test UTXO dummies now carry their address's P2PKH script. - Bounded-drain shrink: each dropped input lowers the bound by exactly TX_INPUT_SIZE, so compute the kept count once and reselect once; otherwise fail with AssetLockTooLarge for the transaction actually checked. - Docs: remaining_* include inputs dropped by the cap or the size shrink and must be read to learn about a partial drain; both build_asset_lock and build_asset_lock_with_signer are bounded-drain paths; an InstantSend proof for a lock near the cap may exceed Platform's transition size limit (use a ChainLock proof, cf. dashpay/platform#5460). Co-Authored-By: Claude Opus 5.5 --- key-wallet/src/test_utils/utxo.rs | 15 ++-- .../managed_wallet_info/asset_lock_builder.rs | 74 ++++++++++++++--- .../transaction_builder.rs | 83 +++++++++++++++---- 3 files changed, 134 insertions(+), 38 deletions(-) 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 333b3d1ab..9b67f35eb 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 @@ -173,8 +173,9 @@ pub struct AssetLockResult { /// that never happened. pub funding_accounts: Vec, /// How many spendable UTXOs of the funding source(s) a **drain** left - /// unspent because it hit [`MAX_ASSET_LOCK_INPUTS`]; `0` when the drain - /// took everything, and always `0` for an exact-amount build. + /// unspent — dropped by the [`MAX_ASSET_LOCK_INPUTS`] cap or by the shrink + /// to [`MAX_ASSET_LOCK_TX_SIZE`]; `0` when the drain took everything, and + /// always `0` for an exact-amount build. /// /// A bounded drain keeps the largest UTXOs, so what remains is the /// *smallest*: a follow-up drain of the rest may fall below the minimum @@ -489,24 +490,32 @@ impl ManagedWalletInfo { /// be the sole source: pooling mixed outputs with transparent ones in one /// transaction links them and undoes the mixing. /// - /// This is the only entry point that drains partially: a drain through the - /// generic [`TransactionBuilder`] with an asset-lock payload fails with - /// [`BuilderError::TooManyInputs`] over the cap instead, because only here - /// is what was left behind reported. + /// This and [`Self::build_asset_lock_with_signer`] are the only entry + /// points that drain partially: a drain through the generic + /// [`TransactionBuilder`] with an asset-lock payload fails with + /// [`BuilderError::TooManyInputs`] over the cap instead, because only these + /// report what was left behind. /// /// A bounded drain leaves the smallest UTXOs behind; the result reports /// them in [`AssetLockResult::remaining_utxo_count`] and /// [`AssetLockResult::remaining_duffs`]. A follow-up drain of those may /// fall below the target transition's minimum lock amount. /// + /// A drain may therefore be partial, and nothing but those two fields says + /// so: callers must read `remaining_utxo_count` / `remaining_duffs` to learn + /// that UTXOs were left behind (dropped by the input cap or by the shrink + /// to [`MAX_ASSET_LOCK_TX_SIZE`]). + /// /// The input cap makes the lock relayable by Core and within Platform's /// input cap; it does not bound the size of a state transition that embeds /// it. An InstantSend asset-lock proof carries the whole transaction and an - /// IS lock listing every spent outpoint, so near the cap the transition can - /// exceed Platform's size limit — choosing a ChainLock proof then is the - /// consumer's concern. An asset lock whose estimated signed size exceeds - /// [`MAX_ASSET_LOCK_TX_SIZE`] fails with [`BuilderError::AssetLockTooLarge`] - /// before anything is reserved or signed. + /// IS lock listing every spent outpoint, so for a lock near the cap the + /// transition can exceed Platform's size limit — the consumer should use a + /// ChainLock proof then (cf. `dashpay/platform#5460`). An asset lock whose + /// signed-size bound exceeds [`MAX_ASSET_LOCK_TX_SIZE`] (a drain shrinks to + /// fit first) fails with [`BuilderError::AssetLockTooLarge`] before + /// anything is reserved or signed; every input must be P2PKH + /// ([`BuilderError::AssetLockNonP2pkhInput`] otherwise). /// /// A non-drain build spends at most [`MAX_ASSET_LOCK_INPUTS`] inputs; when /// the amount cannot be funded within that many it fails with @@ -601,6 +610,9 @@ impl ManagedWalletInfo { /// request signatures from the same signer when later consuming the /// credits on Platform. /// + /// The bounded drain, the `remaining_*` fields that report it, and the + /// asset-lock size and input limits all apply here exactly as there. + /// /// `funding_sources` / `source_index` / `drain` — see /// [`Self::build_asset_lock`]. #[allow(clippy::too_many_arguments)] @@ -1226,6 +1238,46 @@ mod tests { assert!(coinjoin_reserved(&info).is_empty(), "the reservation must be released"); } + /// A non-P2PKH funding UTXO is refused before selection, because the size + /// limit is enforced on a bound that prices every input as P2PKH. + #[tokio::test] + async fn non_p2pkh_funding_is_refused_before_anything_is_reserved() { + let (wallet, mut info) = test_wallet_and_info(); + insert_funded_utxo(&mut info, &wallet, 0x11, 1_000_000, true); + let odd = insert_funded_utxo(&mut info, &wallet, 0x22, 1_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(&[200_000]), + 1000, + false, + ) + .await; + assert!( + matches!( + result, + Err(AssetLockError::Builder(BuilderError::AssetLockNonP2pkhInput { outpoint })) + if outpoint == odd + ), + "expected AssetLockNonP2pkhInput, got {:?}", + result.err() + ); + assert!(reserved_outpoints(&info).is_empty(), "a refused build must reserve nothing"); + } + /// An exact-amount build never reports anything left behind, even when the /// account holds UTXOs it did not need. #[tokio::test] 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 30309337f..cb062afec 100644 --- a/key-wallet/src/wallet/managed_wallet_info/transaction_builder.rs +++ b/key-wallet/src/wallet/managed_wallet_info/transaction_builder.rs @@ -39,9 +39,10 @@ const MAX_STANDARD_TX_INPUTS: usize = 500; /// /// Every build carrying an asset-lock payload is held to it: an exact-amount /// build that cannot be funded within it, and a drain over more candidates, -/// fail with [`BuilderError::TooManyInputs`]. Only the asset-lock builder's -/// drain (`ManagedWalletInfo::build_asset_lock`) spends just the largest UTXOs -/// instead, and reports the rest. That makes the +/// fail with [`BuilderError::TooManyInputs`]. Only the asset-lock builders' +/// drains (`ManagedWalletInfo::build_asset_lock` and +/// `ManagedWalletInfo::build_asset_lock_with_signer`) spend just the largest +/// UTXOs instead, and report the rest in `AssetLockResult::remaining_*`. That makes the /// lock relayable by Core and within Platform's input cap. It does not make /// every *state transition* built on it fit Platform's size limit: an /// InstantSend asset-lock proof embeds the whole transaction plus an IS lock @@ -220,7 +221,9 @@ impl TransactionBuilder { /// [`BuilderError::TooManyInputs`]. /// /// Crate-private because a partial drain must be reported: the asset-lock - /// builder sets it and surfaces what was left behind (through + /// builders (`ManagedWalletInfo::build_asset_lock` and + /// `ManagedWalletInfo::build_asset_lock_with_signer`, in drain mode) set it + /// and surface what was left behind (through /// [`Self::build_signed_reserved_reporting`]) as /// `AssetLockResult::remaining_utxo_count` / `remaining_duffs`. Through the /// public builder an over-cap asset-lock drain fails rather than silently @@ -761,6 +764,17 @@ impl TransactionBuilder { self.inputs.retain(|utxo| !reserved.contains(&utxo.outpoint)); } + // The asset-lock size bound prices every input as a P2PKH spend; refuse + // anything else before selection, so nothing is reserved. + if is_asset_lock { + if let Some(utxo) = self.inputs.iter().find(|utxo| !utxo.txout.script_pubkey.is_p2pkh()) + { + return Err(BuilderError::AssetLockNonP2pkhInput { + outpoint: utxo.outpoint, + }); + } + } + // Must match `calculate_base_size`, including the conservative VIN0 routing-script size. let change_output_size = self.estimated_change_output_size(); @@ -825,21 +839,16 @@ impl TransactionBuilder { // is reserved or signed (signing may be a hardware round-trip per input). if is_asset_lock && signed_size_upper_bound(&transaction) > MAX_ASSET_LOCK_TX_SIZE { if bounded_drain { - // A bounded drain shrinks: drop its smallest inputs until it - // fits. Each dropped input saves at least TX_INPUT_SIZE bytes of - // the bound, so jump straight to a count that may fit, and keep - // stepping down if rounding left it just over. - let mut keep = selected_inputs.len(); - while signed_size_upper_bound(&transaction) > MAX_ASSET_LOCK_TX_SIZE { - let excess = signed_size_upper_bound(&transaction) - MAX_ASSET_LOCK_TX_SIZE; - keep = keep.saturating_sub(excess.div_ceil(TX_INPUT_SIZE).max(1)); - if keep == 0 { - break; + // 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 = signed_size_upper_bound(&transaction) - MAX_ASSET_LOCK_TX_SIZE; + let keep = selected_inputs.len().saturating_sub(excess.div_ceil(TX_INPUT_SIZE)); + if keep > 0 { + if let Ok((smaller, _)) = select(SelectionStrategy::All, Some(keep)) { + (transaction, selected_inputs) = assemble(smaller)?; } - let Ok((smaller, _)) = select(SelectionStrategy::All, Some(keep)) else { - break; - }; - (transaction, selected_inputs) = assemble(smaller)?; } } else if !is_drain && produced_by != SelectionStrategy::LargestFirst { // An exact-amount pick may fit with fewer, larger inputs. @@ -932,6 +941,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> { @@ -1293,6 +1308,12 @@ pub enum BuilderError { size: usize, max: usize, }, + /// An asset-lock candidate input is not a P2PKH output. The asset-lock + /// size limit is enforced on a bound that prices every input as a P2PKH + /// spend, so other scripts are refused before anything is selected. + AssetLockNonP2pkhInput { + outpoint: OutPoint, + }, } impl fmt::Display for BuilderError { @@ -1331,6 +1352,11 @@ impl fmt::Display for BuilderError { } => { write!(f, "Asset lock transaction too large: {size} bytes (max {max})") } + Self::AssetLockNonP2pkhInput { + outpoint, + } => { + write!(f, "Asset lock input {outpoint} is not a P2PKH output") + } } } } @@ -1761,6 +1787,27 @@ mod tests { assert_eq!(tx.input.len(), MAX_ASSET_LOCK_INPUTS); } + /// The generic builder refuses a non-P2PKH input for an asset lock too, + /// whether the caller signs here or externally. + #[test] + fn test_asset_lock_refuses_non_p2pkh_inputs() { + let mut odd = Utxo::dummy(1, 1_000_000, 100, false, true); + odd.txout.script_pubkey = ScriptBuf::from(vec![0x51]); + 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(25)) + .add_inputs([Utxo::dummy(0, 1_000_000, 100, false, true), odd.clone()]) + .build_unsigned_reserved(); + assert_eq!( + result.err(), + Some(BuilderError::AssetLockNonP2pkhInput { + outpoint: odd.outpoint + }) + ); + } + /// 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. From 0453b6cf639af1a8305c16ae30014c4c8d663eed Mon Sep 17 00:00:00 2001 From: Roman <51091564+jeanpierreroma@users.noreply.github.com> Date: Fri, 16 Oct 2026 23:15:58 +0300 Subject: [PATCH 09/10] fix(key-wallet): filter non-P2PKH asset-lock candidates, surface retry errors - Asset-lock builds no longer refuse when any candidate is non-P2PKH: such outputs are simply not offered to selection (one odd coin must not block every asset lock from the account). The unreleased AssetLockNonP2pkhInput variant is dropped. - Size retries (bounded-drain shrink, exact-amount largest-first) propagate a failed reselection's real error, and AssetLockTooLarge reports the smallest transaction actually checked. - unsigned_from_selection takes outputs and payload by value: the single-pass build moves them; a retry reuses the payload of the transaction it rejects. - Remove CoinSelector::select_coins_reporting; the builder skips its largest-first retry only when LargestFirst is the configured strategy. - remaining_* docs say exactly what is counted; bounded_drain documents that only the reporting signed path may be used with it. - One canonical description of the asset-lock limits and bounded drain on build_asset_lock, linked from the constants, the builder flag and add_funding_reservation_only. Co-Authored-By: Claude Opus 5.5 --- .../managed_wallet_info/asset_lock_builder.rs | 113 +++++----- .../managed_wallet_info/coin_selection.rs | 71 +----- .../transaction_builder.rs | 210 ++++++++++-------- 3 files changed, 183 insertions(+), 211 deletions(-) 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 9b67f35eb..9bdc5d2e8 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 @@ -172,15 +172,15 @@ pub struct AssetLockResult { /// bookkeeping scale with the address book while claiming contributions /// that never happened. pub funding_accounts: Vec, - /// How many spendable UTXOs of the funding source(s) a **drain** left - /// unspent — dropped by the [`MAX_ASSET_LOCK_INPUTS`] cap or by the shrink - /// to [`MAX_ASSET_LOCK_TX_SIZE`]; `0` when the drain took everything, and - /// always `0` for an exact-amount build. + /// 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. /// - /// A bounded drain keeps the largest UTXOs, so what remains is the - /// *smallest*: a follow-up drain of the rest may fall below the minimum - /// amount the target state transition accepts. Surface it to the user - /// rather than assuming the account was emptied. + /// 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`]. @@ -480,42 +480,44 @@ impl ManagedWalletInfo { /// source-list semantics. /// /// `drain` locks the sourced accounts' spendable balance: every final UTXO - /// is consumed — or, when there are more than [`MAX_ASSET_LOCK_INPUTS`], the - /// [`MAX_ASSET_LOCK_INPUTS`] largest (fewer still, dropping the smallest, - /// if those would exceed [`MAX_ASSET_LOCK_TX_SIZE`]), leaving the rest in - /// the account for a later lock — and the single credit output's value is - /// rewritten to + /// 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. /// - /// This and [`Self::build_asset_lock_with_signer`] are the only entry - /// points that drain partially: a drain through the generic - /// [`TransactionBuilder`] with an asset-lock payload fails with - /// [`BuilderError::TooManyInputs`] over the cap instead, because only these - /// report what was left behind. + /// # Asset-lock limits and the bounded drain /// - /// A bounded drain leaves the smallest UTXOs behind; the result reports - /// them in [`AssetLockResult::remaining_utxo_count`] and - /// [`AssetLockResult::remaining_duffs`]. A follow-up drain of those may - /// fall below the target transition's minimum lock amount. + /// This is the canonical description; the constants, the builder flag and + /// [`AssetLockResult`] link here. /// - /// A drain may therefore be partial, and nothing but those two fields says - /// so: callers must read `remaining_utxo_count` / `remaining_duffs` to learn - /// that UTXOs were left behind (dropped by the input cap or by the shrink - /// to [`MAX_ASSET_LOCK_TX_SIZE`]). - /// - /// The input cap makes the lock relayable by Core and within Platform's - /// input cap; it does not bound the size of 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 — the consumer should use a - /// ChainLock proof then (cf. `dashpay/platform#5460`). An asset lock whose - /// signed-size bound exceeds [`MAX_ASSET_LOCK_TX_SIZE`] (a drain shrinks to - /// fit first) fails with [`BuilderError::AssetLockTooLarge`] before - /// anything is reserved or signed; every input must be P2PKH - /// ([`BuilderError::AssetLockNonP2pkhInput`] otherwise). + /// * **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. + /// * **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 @@ -610,8 +612,8 @@ impl ManagedWalletInfo { /// request signatures from the same signer when later consuming the /// credits on Platform. /// - /// The bounded drain, the `remaining_*` fields that report it, and the - /// asset-lock size and input limits all apply here exactly as there. + /// The asset-lock limits and the bounded drain apply exactly as described + /// there. /// /// `funding_sources` / `source_index` / `drain` — see /// [`Self::build_asset_lock`]. @@ -1238,13 +1240,14 @@ mod tests { assert!(coinjoin_reserved(&info).is_empty(), "the reservation must be released"); } - /// A non-P2PKH funding UTXO is refused before selection, because the size - /// limit is enforced on a bound that prices every input as P2PKH. + /// 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_refused_before_anything_is_reserved() { + async fn non_p2pkh_funding_is_not_offered() { let (wallet, mut info) = test_wallet_and_info(); - insert_funded_utxo(&mut info, &wallet, 0x11, 1_000_000, true); - let odd = insert_funded_utxo(&mut info, &wallet, 0x22, 1_000_000, true); + 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) @@ -1261,21 +1264,17 @@ mod tests { &wallet, &[AccountTypePreference::BIP44], 0, - test_credit_outputs(&[200_000]), + test_credit_outputs(&[0]), 1000, - false, + true, ) - .await; - assert!( - matches!( - result, - Err(AssetLockError::Builder(BuilderError::AssetLockNonP2pkhInput { outpoint })) - if outpoint == odd - ), - "expected AssetLockNonP2pkhInput, got {:?}", - result.err() - ); - assert!(reserved_outpoints(&info).is_empty(), "a refused build must reserve nothing"); + .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 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 6e180d07f..918458176 100644 --- a/key-wallet/src/wallet/managed_wallet_info/coin_selection.rs +++ b/key-wallet/src/wallet/managed_wallet_info/coin_selection.rs @@ -244,47 +244,16 @@ impl CoinSelector { where I: IntoIterator, { - self.select_coins_reporting( - utxos, - target_amount, - fee_rate, - current_height, - base_size, - change_output_size, - ) - .map(|(result, _)| result) - } - - /// [`Self::select_coins_with_size`], also reporting the strategy that - /// produced the result: the configured one, or - /// [`SelectionStrategy::LargestFirst`] when a bounded selection fell back - /// to it. - pub(crate) fn select_coins_reporting<'a, I>( - &self, - utxos: I, - target_amount: u64, - fee_rate: FeeRate, - current_height: u32, - base_size: usize, - change_output_size: usize, - ) -> Result<(SelectionResult, SelectionStrategy), SelectionError> - where - I: IntoIterator, - { - let strategy = self.strategy; - let tag = |result: SelectionResult| (result, strategy); 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, - ) - .map(tag); + return self.select_with_strategy( + self.strategy, + utxos, + target_amount, + fee_rate, + current_height, + base_size, + change_output_size, + ); }; match self.strategy { @@ -313,7 +282,6 @@ impl CoinSelector { base_size, change_output_size, ) - .map(tag) } strategy => { // References only, collected once so the fallback can reuse @@ -330,7 +298,7 @@ impl CoinSelector { ); if strategy == SelectionStrategy::LargestFirst { // Already the fewest-inputs pick; nothing to fall back to. - return first.map(tag); + return first; } // The cheapest any selection can be: one input, no change. let floor = @@ -338,7 +306,7 @@ impl CoinSelector { base_size.saturating_sub(change_output_size) + TX_INPUT_SIZE, )); match first { - Ok(result) if result.selected.len() <= max_inputs => return Ok(tag(result)), + 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 @@ -362,7 +330,6 @@ impl CoinSelector { base_size, change_output_size, ) - .map(|result| (result, SelectionStrategy::LargestFirst)) } } } @@ -1105,22 +1072,6 @@ mod tests { assert_eq!(exact.selected.len(), 1); } - /// The reporting entry point names the strategy that produced the result. - #[test] - fn test_selection_reports_the_strategy_used() { - let utxos = ladder(20, 10_000, 1_000); - let selector = CoinSelector::new(SelectionStrategy::SmallestFirst); - let (_, used) = selector - .select_coins_reporting(&utxos, 20_000, FeeRate::new(1000), 200, 78, 34) - .unwrap(); - assert_eq!(used, SelectionStrategy::SmallestFirst); - let (_, used) = selector - .with_max_inputs(4) - .select_coins_reporting(&utxos, 80_000, FeeRate::new(1000), 200, 78, 34) - .unwrap(); - assert_eq!(used, SelectionStrategy::LargestFirst, "the bounded fallback ran"); - } - /// A pool that cannot fund even a one-input spend fails with the first /// strategy's error; the bounded fallback changes nothing there. #[test] 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 cb062afec..938478b43 100644 --- a/key-wallet/src/wallet/managed_wallet_info/transaction_builder.rs +++ b/key-wallet/src/wallet/managed_wallet_info/transaction_builder.rs @@ -37,18 +37,11 @@ const MAX_STANDARD_TX_INPUTS: usize = 500; /// 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: an exact-amount -/// build that cannot be funded within it, and a drain over more candidates, -/// fail with [`BuilderError::TooManyInputs`]. Only the asset-lock builders' -/// drains (`ManagedWalletInfo::build_asset_lock` and -/// `ManagedWalletInfo::build_asset_lock_with_signer`) spend just the largest -/// UTXOs instead, and report the rest in `AssetLockResult::remaining_*`. That makes the -/// lock relayable by Core and within Platform's input cap. It does not make -/// every *state transition* built on it fit Platform's size limit: an -/// InstantSend asset-lock proof embeds the whole transaction plus an IS lock -/// listing every spent outpoint, so a lock near this cap can still yield an -/// oversized transition. Sizing the transition is the consumer's concern (a -/// ChainLock proof, which carries no transaction, avoids it). +/// 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; @@ -56,10 +49,8 @@ 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`). The builder checks its signed-size upper bound -/// against it before reserving or signing anything, and fails with -/// [`BuilderError::AssetLockTooLarge`]. At [`MAX_ASSET_LOCK_INPUTS`] P2PKH -/// inputs a built asset lock stays well below it. +/// 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; @@ -214,20 +205,16 @@ impl TransactionBuilder { } } - /// Let an asset-lock drain ([`SelectionStrategy::All`] with an asset-lock - /// payload) over more than [`MAX_ASSET_LOCK_INPUTS`] candidates spend the - /// largest ones — as many as fit both the input cap and - /// [`MAX_ASSET_LOCK_TX_SIZE`] — and leave the rest, instead of failing with - /// [`BuilderError::TooManyInputs`]. + /// 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: the asset-lock - /// builders (`ManagedWalletInfo::build_asset_lock` and - /// `ManagedWalletInfo::build_asset_lock_with_signer`, in drain mode) set it - /// and surface what was left behind (through - /// [`Self::build_signed_reserved_reporting`]) as - /// `AssetLockResult::remaining_utxo_count` / `remaining_duffs`. Through the - /// public builder an over-cap asset-lock drain fails rather than silently - /// leaving coins behind. No effect on any other build. + /// 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 @@ -287,9 +274,8 @@ impl TransactionBuilder { /// at most `MAX_STANDARD_TX_INPUTS` still has every batch see the whole /// 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. (A build carrying an asset-lock - /// payload is capped at [`MAX_ASSET_LOCK_INPUTS`] instead; only the - /// asset-lock builder's drain may then spend just the largest UTXOs.) + /// 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 @@ -555,15 +541,19 @@ impl TransactionBuilder { /// Turn one selection into the unsigned transaction: BIP-69 input order, /// the drain carrier or change output, and the (possibly drain-rewritten) - /// special payload. Leaves `self` untouched so a selection can be retried. + /// 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 mut special_payload = self.special_payload.clone(); let total_input: u64 = selected_inputs.iter().map(|u| u.value()).sum(); if total_input < total_output + estimated_fee { @@ -577,12 +567,13 @@ impl TransactionBuilder { 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.clone(), + }] + } else { + outputs }; if self.selection_strategy == SelectionStrategy::All { @@ -764,15 +755,11 @@ impl TransactionBuilder { self.inputs.retain(|utxo| !reserved.contains(&utxo.outpoint)); } - // The asset-lock size bound prices every input as a P2PKH spend; refuse - // anything else before selection, so nothing is reserved. + // 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 { - if let Some(utxo) = self.inputs.iter().find(|utxo| !utxo.txout.script_pubkey.is_p2pkh()) - { - return Err(BuilderError::AssetLockNonP2pkhInput { - outpoint: utxo.outpoint, - }); - } + self.inputs.retain(|utxo| utxo.txout.script_pubkey.is_p2pkh()); } // Must match `calculate_base_size`, including the conservative VIN0 routing-script size. @@ -795,6 +782,10 @@ impl TransactionBuilder { 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 { @@ -802,7 +793,7 @@ impl TransactionBuilder { None => selector, }; selector - .select_coins_reporting( + .select_coins_with_size( self.inputs.iter(), total_output, self.fee_rate, @@ -812,16 +803,20 @@ impl TransactionBuilder { ) .map_err(BuilderError::CoinSelection) }; - let assemble = |selection: SelectionResult| { + 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, ) }; - let (selection, produced_by) = select(strategy, bound_selection.then_some(max_inputs))?; + let selection = select(strategy, bound_selection.then_some(max_inputs))?; // A bounded selector only exceeds its bound when no selection within it // funds the target; an unbounded one is checked against the cap here. @@ -832,41 +827,56 @@ impl TransactionBuilder { }); } - let (mut transaction, mut selected_inputs) = assemble(selection)?; + 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). - if is_asset_lock && signed_size_upper_bound(&transaction) > MAX_ASSET_LOCK_TX_SIZE { - if bounded_drain { + let first_size = if is_asset_lock { + signed_size_upper_bound(&transaction) + } else { + 0 + }; + if first_size > MAX_ASSET_LOCK_TX_SIZE { + // 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 = signed_size_upper_bound(&transaction) - MAX_ASSET_LOCK_TX_SIZE; + let excess = first_size - MAX_ASSET_LOCK_TX_SIZE; let keep = selected_inputs.len().saturating_sub(excess.div_ceil(TX_INPUT_SIZE)); - if keep > 0 { - if let Ok((smaller, _)) = select(SelectionStrategy::All, Some(keep)) { - (transaction, selected_inputs) = assemble(smaller)?; - } + (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. + 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(retry_strategy, Some(retry_max))?; + if smaller.selected.len() > max_inputs { + return Err(BuilderError::TooManyInputs { + count: smaller.selected.len(), + max: max_inputs, + }); } - } else if !is_drain && produced_by != SelectionStrategy::LargestFirst { - // An exact-amount pick may fit with fewer, larger inputs. - // Largest-first is already the fewest-inputs pick, so skip it - // when selection already ran or fell back to it. - if let Ok((largest_first, _)) = - select(SelectionStrategy::LargestFirst, Some(max_inputs)) - { - if largest_first.selected.len() <= max_inputs { - if let Ok((tx, inputs)) = assemble(largest_first) { - if signed_size_upper_bound(&tx) <= MAX_ASSET_LOCK_TX_SIZE { - (transaction, selected_inputs) = (tx, inputs); - } - } - } + 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); } } - let size = signed_size_upper_bound(&transaction); if size > MAX_ASSET_LOCK_TX_SIZE { return Err(BuilderError::AssetLockTooLarge { size, @@ -1308,12 +1318,6 @@ pub enum BuilderError { size: usize, max: usize, }, - /// An asset-lock candidate input is not a P2PKH output. The asset-lock - /// size limit is enforced on a bound that prices every input as a P2PKH - /// spend, so other scripts are refused before anything is selected. - AssetLockNonP2pkhInput { - outpoint: OutPoint, - }, } impl fmt::Display for BuilderError { @@ -1352,11 +1356,6 @@ impl fmt::Display for BuilderError { } => { write!(f, "Asset lock transaction too large: {size} bytes (max {max})") } - Self::AssetLockNonP2pkhInput { - outpoint, - } => { - write!(f, "Asset lock input {outpoint} is not a P2PKH output") - } } } } @@ -1367,6 +1366,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; @@ -1787,24 +1787,46 @@ mod tests { assert_eq!(tx.input.len(), MAX_ASSET_LOCK_INPUTS); } - /// The generic builder refuses a non-P2PKH input for an asset lock too, - /// whether the caller signs here or externally. + /// The generic builder drops a non-P2PKH input from an asset lock's + /// candidates too, seeded or not. #[test] - fn test_asset_lock_refuses_non_p2pkh_inputs() { - let mut odd = Utxo::dummy(1, 1_000_000, 100, false, true); + 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 result = TransactionBuilder::new() + 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([Utxo::dummy(0, 1_000_000, 100, false, true), odd.clone()]) + .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_eq!( - result.err(), - Some(BuilderError::AssetLockNonP2pkhInput { - outpoint: odd.outpoint - }) + assert!( + matches!( + result, + Err(BuilderError::CoinSelection(SelectionError::InsufficientFunds { .. })) + ), + "got {:?}", + result.err() ); } From 7fad4a3fed5e8fec4ab4e87486565f3265a21e6e Mon Sep 17 00:00:00 2001 From: Roman <51091564+jeanpierreroma@users.noreply.github.com> Date: Fri, 16 Oct 2026 23:23:46 +0300 Subject: [PATCH 10/10] refactor(key-wallet): gate the asset-lock size check on Option, fold cap check - Replace the 0 sentinel for "not an asset lock" with an Option gated on is_asset_lock. - Fold the first pick's and the retry's TooManyInputs check into one select_within_cap helper. - Document that an exact-amount asset lock never drops a change output to fit the size limit (change is only emitted above dust, so dropping it would burn that value as fee). Co-Authored-By: Claude Opus 5.5 --- .../managed_wallet_info/asset_lock_builder.rs | 5 +++ .../transaction_builder.rs | 45 +++++++++---------- 2 files changed, 27 insertions(+), 23 deletions(-) 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 9bdc5d2e8..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 @@ -512,6 +512,11 @@ impl ManagedWalletInfo { /// [`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 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 938478b43..adee78801 100644 --- a/key-wallet/src/wallet/managed_wallet_info/transaction_builder.rs +++ b/key-wallet/src/wallet/managed_wallet_info/transaction_builder.rs @@ -816,28 +816,31 @@ impl TransactionBuilder { ) }; - let selection = select(strategy, bound_selection.then_some(max_inputs))?; - // A bounded selector only exceeds its bound when no selection within it - // funds the target; an unbounded one is checked against the cap here. - if selection.selected.len() > max_inputs { - return Err(BuilderError::TooManyInputs { - count: selection.selected.len(), - max: max_inputs, - }); - } + // 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 first_size = if is_asset_lock { - signed_size_upper_bound(&transaction) - } else { - 0 - }; - if first_size > MAX_ASSET_LOCK_TX_SIZE { + 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. @@ -851,7 +854,9 @@ impl TransactionBuilder { (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. + // 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 @@ -862,13 +867,7 @@ impl TransactionBuilder { 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(retry_strategy, Some(retry_max))?; - if smaller.selected.len() > max_inputs { - return Err(BuilderError::TooManyInputs { - count: smaller.selected.len(), - max: max_inputs, - }); - } + 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);