From bdbe9e9811d203c0269f7ce39de769c5f4469efc Mon Sep 17 00:00:00 2001 From: Abiodun Awoyemi Date: Tue, 2 Jun 2026 02:15:12 +0100 Subject: [PATCH] feat!: add configurable max_weight to SelectorParams - enforce `max_weight` in `Selector::try_finalize`, which now returns `Result` instead of `Option` - expose `Selector::weight()` - collapse `CannotMeetTarget` into a `SelectorError` variant --- Cargo.toml | 3 +- examples/synopsis.rs | 3 +- src/input_candidates.rs | 11 +--- src/selector.rs | 134 +++++++++++++++++++++++++++++----------- 4 files changed, 105 insertions(+), 46 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 5c7c1fb2..aebc7afa 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -15,7 +15,8 @@ unexpected_cfgs = { level = "warn", check-cfg = ['cfg(coverage_nightly)'] } [dependencies] # TODO: coin-select dependency should set no default features -bdk_coin_select = { version = "0.4.1" } +# Temporary: unreleased MaxWeight metric. Replace with a released bdk_coin_select version before merge. +bdk_coin_select = { git = "https://github.com/aagbotemi/coin-select", branch = "feat/max-weight-metric" } miniscript = { version = "12.3.7", default-features = false } rand_core = { version = "0.6.4", default-features = false } rand = { version = "0.8", optional = true } diff --git a/examples/synopsis.rs b/examples/synopsis.rs index 2d629ba6..27325ce0 100644 --- a/examples/synopsis.rs +++ b/examples/synopsis.rs @@ -3,7 +3,7 @@ use bdk_tx::{ filter_unspendable, group_by_spk, selection_algorithm_lowest_fee_bnb, Output, PsbtParams, SelectorParams, Signer, }; -use bitcoin::{key::Secp256k1, Amount, FeeRate}; +use bitcoin::{key::Secp256k1, Amount, FeeRate, Weight}; use miniscript::Descriptor; mod common; @@ -143,6 +143,7 @@ fn main() -> anyhow::Result<()> { change_dust_relay_feerate: None, // This ensures that we satisfy mempool-replacement policy rules 4 and 6. replace: Some(rbf_params), + max_weight: Weight::MAX_BLOCK, }, )?; diff --git a/src/input_candidates.rs b/src/input_candidates.rs index 131f6af8..38e10082 100644 --- a/src/input_candidates.rs +++ b/src/input_candidates.rs @@ -6,10 +6,7 @@ use bitcoin::{absolute, FeeRate, OutPoint}; use miniscript::bitcoin; use crate::collections::{BTreeMap, HashSet}; -use crate::{ - CannotMeetTarget, FeeRateExt, Input, InputGroup, Selection, Selector, SelectorError, - SelectorParams, -}; +use crate::{FeeRateExt, Input, InputGroup, Selection, Selector, SelectorError, SelectorParams}; /// Input candidates. #[must_use] @@ -207,7 +204,7 @@ impl InputCandidates { .map_err(IntoSelectionError::SelectionAlgorithm)?; let selection = selector .try_finalize() - .ok_or(IntoSelectionError::CannotMeetTarget(CannotMeetTarget))?; + .map_err(IntoSelectionError::Selector)?; Ok(selection) } } @@ -219,8 +216,6 @@ pub enum IntoSelectionError { Selector(SelectorError), /// Selection algorithm failed. SelectionAlgorithm(E), - /// The target cannot be met - CannotMeetTarget(CannotMeetTarget), } impl fmt::Display for IntoSelectionError { @@ -232,7 +227,6 @@ impl fmt::Display for IntoSelectionError { IntoSelectionError::SelectionAlgorithm(error) => { write!(f, "selection algorithm failed: {error}") } - IntoSelectionError::CannotMeetTarget(error) => write!(f, "{error}"), } } } @@ -249,6 +243,7 @@ pub fn selection_algorithm_lowest_fee_bnb( move |selector| { let target = selector.target(); let change_policy = selector.cs_change_policy(); + selector .inner_mut() .run_bnb( diff --git a/src/selector.rs b/src/selector.rs index 4c98ec86..11860556 100644 --- a/src/selector.rs +++ b/src/selector.rs @@ -1,4 +1,4 @@ -use bdk_coin_select::{InsufficientFunds, Replace, Target, TargetFee, TargetOutputs}; +use bdk_coin_select::{Drain, Replace, SelectError, Target, TargetFee, TargetOutputs}; use bitcoin::{Amount, FeeRate, ScriptBuf, Transaction, Weight}; use miniscript::bitcoin; @@ -23,12 +23,8 @@ pub struct Selector<'c> { /// Parameters for creating tx. /// -/// TODO: Create a builder interface on this that does checks. I.e. -/// * Error if recipient is dust. -/// * Error on multi OP_RETURN outputs. -/// * Error on anything that does not satisfy mempool policy. -/// If the caller wants to create non-mempool-policy conforming txs, they can just fill in the -/// fields directly. +/// Required fields are set via [`SelectorParams::new`]; optional fields are +/// set directly on the struct. #[derive(Debug)] pub struct SelectorParams { /// Target feerate. @@ -64,6 +60,11 @@ pub struct SelectorParams { /// Params for replacing tx(s). pub replace: Option, + + /// Maximum allowed weight of the transaction. + /// + /// Defaults to the consensus block-weight limit ([`Weight::MAX_BLOCK`]). + pub max_weight: Weight, } /// Source of the change output script and its spending cost. @@ -247,7 +248,7 @@ impl RbfParams { } impl SelectorParams { - /// With default params. + /// Construct params from the required fields. pub fn new( target_feerate: FeeRate, target_outputs: Vec, @@ -261,6 +262,7 @@ impl SelectorParams { change_longterm_feerate: None, replace: None, change_dust_relay_feerate: None, + max_weight: Weight::MAX_BLOCK, } } @@ -274,12 +276,14 @@ impl SelectorParams { fee: TargetFee { rate: self.target_feerate.max(feerate_lb).into_cs_feerate(), replace: self.replace.as_ref().map(|r| r.to_cs_replace()), + absolute: 0, }, outputs: TargetOutputs::fund_outputs( self.target_outputs .iter() .map(|o| (o.txout().weight().to_wu(), o.value.to_sat())), ), + max_weight: Some(self.max_weight.to_wu()), } } @@ -331,29 +335,13 @@ impl SelectorParams { } } -/// Error when the selection is impossible with the input candidates -#[derive(Debug)] -pub struct CannotMeetTarget; - -impl fmt::Display for CannotMeetTarget { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - write!( - f, - "meeting the target is not possible with the input candidates" - ) - } -} - -#[cfg(feature = "std")] -impl std::error::Error for CannotMeetTarget {} - /// Selector error #[derive(Debug)] pub enum SelectorError { /// Miniscript error (e.g. the change descriptor is inherently unsatisfiable). Miniscript(miniscript::Error), /// Meeting the target is not possible with the input candidates. - CannotMeetTarget(CannotMeetTarget), + CannotMeetTarget, /// The provided assets cannot satisfy the change descriptor. InsufficientAssets, /// Input candidates have absolute timelocks of mixed units (some height-based, others @@ -363,19 +351,40 @@ pub enum SelectorError { /// Filter the [`InputCandidates`] down to a single-unit subset before constructing the /// [`Selector`]. LockTypeMismatch, + /// The selection exceeds the maximum allowed transaction weight. + MaxWeightExceeded, + /// Not enough value in the candidates to meet the target. + InsufficientFunds(bdk_coin_select::InsufficientFunds), } impl fmt::Display for SelectorError { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { match self { Self::Miniscript(err) => write!(f, "{err}"), - Self::CannotMeetTarget(err) => write!(f, "{err}"), + Self::CannotMeetTarget => write!( + f, + "meeting the target is not possible with the input candidates" + ), Self::InsufficientAssets => { write!(f, "provided assets cannot satisfy the change descriptor") } Self::LockTypeMismatch => { write!(f, "input candidates have absolute timelocks of mixed units") } + Self::MaxWeightExceeded => write!( + f, + "selection exceeds the maximum allowed transaction weight" + ), + Self::InsufficientFunds(e) => write!(f, "{}", e), + } + } +} + +impl From for SelectorError { + fn from(e: SelectError) -> Self { + match e { + SelectError::InsufficientFunds(e) => Self::InsufficientFunds(e), + SelectError::MaxWeightExceeded => Self::MaxWeightExceeded, } } } @@ -400,7 +409,7 @@ impl<'c> Selector<'c> { let change_script = params.change_script.source(); if target.value() > candidates.groups().map(|grp| grp.value().to_sat()).sum() { - return Err(SelectorError::CannotMeetTarget(CannotMeetTarget)); + return Err(SelectorError::CannotMeetTarget); } // Verify that all inputs agree on absolute timelock unit (height vs time). @@ -465,8 +474,9 @@ impl<'c> Selector<'c> { } /// Select in order until target is met. - pub fn select_until_target_met(&mut self) -> Result<(), InsufficientFunds> { - self.inner.select_until_target_met(self.target) + pub fn select_until_target_met(&mut self) -> Result<(), SelectorError> { + self.inner.select_until_target_met(self.target)?; + Ok(()) } /// Whether we added the change output to the selection. @@ -483,14 +493,38 @@ impl<'c> Selector<'c> { Some(has_drain) } + /// Maximum allowed transaction weight. + pub fn max_weight(&self) -> Option { + self.target.max_weight.map(Weight::from_wu) + } + + /// Estimated weight of the transaction for a given drain decision. + fn weight_with(&self, drain: Drain) -> Weight { + Weight::from_wu(self.inner.weight(self.target.outputs, drain.weights)) + } + + /// Estimated weight of the transaction. + pub fn weight(&self) -> Weight { + let drain = self.inner.drain(self.target, self.change_policy); + self.weight_with(drain) + } + /// Try get final selection. /// - /// Return `None` if target is not met yet. - pub fn try_finalize(&self) -> Option { + /// # Errors + /// + /// - [`SelectorError::CannotMeetTarget`] if the target is not met yet. + /// - [`SelectorError::MaxWeightExceeded`] if the estimated transaction weight exceeds [`SelectorParams::max_weight`]. + pub fn try_finalize(&self) -> Result { + let drain = self.inner.drain(self.target, self.change_policy); + if let Some(max_wu) = self.target.max_weight { + if self.weight_with(drain).to_wu() > max_wu { + return Err(SelectorError::MaxWeightExceeded); + } + } if !self.inner.is_target_met(self.target) { - return None; + return Err(SelectorError::CannotMeetTarget); } - let maybe_change = self.inner.drain(self.target, self.change_policy); let to_apply = self.candidates.groups().collect::>(); let inputs = self .inner @@ -500,13 +534,13 @@ impl<'c> Selector<'c> { .cloned() .collect(); let mut outputs = self.target_outputs.clone(); - if maybe_change.is_some() { + if drain.is_some() { outputs.push(Output::from(( self.change_script.clone(), - Amount::from_sat(maybe_change.value), + Amount::from_sat(drain.value), ))); } - Some(Selection::new(inputs, outputs)) + Ok(Selection::new(inputs, outputs)) } } @@ -560,4 +594,32 @@ mod tests { )); Ok(()) } + + #[test] + fn into_selection_errors_when_max_weight_exceeded() -> anyhow::Result<()> { + let input = setup_cltv_input(absolute::LockTime::from_consensus(10_000))?; + let recipient_spk = input.prev_txout().script_pubkey.clone(); + let candidates = InputCandidates::new([], [input]); + + let mut params = SelectorParams::new( + FeeRate::from_sat_per_vb_u32(2), + vec![Output::with_script(recipient_spk, Amount::from_sat(10_000))], + ChangeScript::from_script(ScriptBuf::new(), Weight::ZERO), + ); + params.max_weight = Weight::from_wu(100); + + let err = candidates + .into_selection(|selector| selector.select_until_target_met(), params) + .unwrap_err(); + + assert!( + matches!( + err, + IntoSelectionError::SelectionAlgorithm(SelectorError::MaxWeightExceeded) + ), + "expected MaxWeightExceeded, got {err:?}" + ); + + Ok(()) + } }