diff --git a/README.md b/README.md index ada89aa..95677a8 100644 --- a/README.md +++ b/README.md @@ -24,7 +24,8 @@ let outputs = vec![TxOut { let target = Target { outputs: TargetOutputs::fund_outputs(outputs.iter().map(|output| (output.weight().to_wu(), output.value.to_sat()))), - fee: TargetFee::from_feerate(FeeRate::from_sat_per_vb(42.0)) + fee: TargetFee::from_feerate(FeeRate::from_sat_per_vb(42.0)), + max_weight: None }; let candidates = vec![ @@ -130,6 +131,7 @@ let mut coin_selector = CoinSelector::new(&candidates); let target = Target { fee: TargetFee::from_feerate(FeeRate::from_sat_per_vb(15.0)), outputs: TargetOutputs::fund_outputs(outputs.iter().map(|output| (output.weight().to_wu(), output.value.to_sat()))), + max_weight: None }; // The change output must be at least this size to be relayed. diff --git a/src/coin_selector.rs b/src/coin_selector.rs index d6cdd7b..0b1cc12 100644 --- a/src/coin_selector.rs +++ b/src/coin_selector.rs @@ -308,7 +308,7 @@ impl<'a> CoinSelector<'a> { self.selected_value() as i64 - (self.input_weight() as f32 * feerate.spwu()).ceil() as i64 } - // /// Waste sum of all selected inputs. + /// Waste sum of all selected inputs. fn input_waste(&self, feerate: FeeRate, long_term_feerate: FeeRate) -> f32 { self.input_weight() as f32 * (feerate.spwu() - long_term_feerate.spwu()) } @@ -429,15 +429,20 @@ impl<'a> CoinSelector<'a> { self.unselected_indices().next().is_none() } + /// Whether the tx implied by the current selection and `drain` is within `target.max_weight`. + fn is_within_max_weight(&self, target: Target, drain: Drain) -> bool { + match target.max_weight { + Some(max_weight) => self.weight(target.outputs, drain.weights) <= max_weight, + None => true, + } + } + /// Whether the constraints of `Target` have been met if we include a specific `drain` ouput. /// - /// Note if [`is_target_met`] is true and the `drain` is produced from the [`drain`] method then - /// this method will also always be true. - /// - /// [`is_target_met`]: Self::is_target_met - /// [`drain`]: Self::drain + /// Note: even if [`Self::is_target_met`] is true, this can be false when adding the `drain` pushes + /// the transaction weight over [`Target::max_weight`]. pub fn is_target_met_with_drain(&self, target: Target, drain: Drain) -> bool { - self.excess(target, drain) >= 0 + self.excess(target, drain) >= 0 && self.is_within_max_weight(target, drain) } /// Whether the constraints of `Target` have been met. @@ -467,16 +472,14 @@ impl<'a> CoinSelector<'a> { }, ); if excess > change_policy.min_value as i64 { - debug_assert_eq!( - self.is_target_met(target), - self.is_target_met_with_drain( - target, - Drain { - weights: change_policy.drain_weights, - value: excess as u64 - } - ), - "if the target is met without a drain it must be met after adding the drain" + let drain = Drain { + weights: change_policy.drain_weights, + value: excess as u64, + }; + debug_assert!( + self.is_target_met_with_drain(target, drain) + || !self.is_within_max_weight(target, drain), + "if the target is met without a drain it must be met after adding the drain, unless max weight is exceeded" ); Some(excess as u64) } else { @@ -520,13 +523,22 @@ impl<'a> CoinSelector<'a> { } } - /// Select candidates until `target` has been met assuming the `drain` output is attached. + /// Select candidates until `target` has been met. /// - /// Returns an error if the target was unable to be met. - pub fn select_until_target_met(&mut self, target: Target) -> Result<(), InsufficientFunds> { + /// # Errors + /// + /// - [`SelectError::InsufficientFunds`] if the candidates can't cover the target value. + /// - [`SelectError::MaxWeightExceeded`] if the value is met but the selection exceeds [`Target::max_weight`]. + pub fn select_until_target_met(&mut self, target: Target) -> Result<(), SelectError> { self.select_until(|cs| cs.is_target_met(target)) - .ok_or_else(|| InsufficientFunds { - missing: self.excess(target, Drain::NONE).unsigned_abs(), + .ok_or_else(|| { + if !self.is_within_max_weight(target, Drain::NONE) { + SelectError::MaxWeightExceeded + } else { + SelectError::InsufficientFunds(InsufficientFunds { + missing: self.excess(target, Drain::NONE).unsigned_abs(), + }) + } }) } @@ -641,6 +653,38 @@ impl DoubleEndedIterator for SelectIter<'_> { } } +/// Error returned when a selection cannot satisfy the [`Target`]. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum SelectError { + /// The selected inputs don't cover the target value (plus fees). + InsufficientFunds(InsufficientFunds), + /// The target value is met, but the resulting transaction exceeds [`Target::max_weight`]. + MaxWeightExceeded, +} + +impl core::fmt::Display for SelectError { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + match self { + Self::InsufficientFunds(e) => write!(f, "{}", e), + Self::MaxWeightExceeded => { + write!( + f, + "selection exceeds the maximum allowed transaction weight" + ) + } + } + } +} + +#[cfg(feature = "std")] +impl std::error::Error for SelectError {} + +impl From for SelectError { + fn from(e: InsufficientFunds) -> Self { + Self::InsufficientFunds(e) + } +} + /// Error type that occurs when the target amount cannot be met. #[derive(Clone, Debug, Copy, PartialEq, Eq)] pub struct InsufficientFunds { diff --git a/src/target.rs b/src/target.rs index 1bd3ae0..b561dc8 100644 --- a/src/target.rs +++ b/src/target.rs @@ -7,6 +7,9 @@ pub struct Target { pub fee: TargetFee, /// The aggregate properties of outputs you're trying to fund pub outputs: TargetOutputs, + /// Maximum allowed weight of the resulting transaction (WU). + /// `None` = unconstrained. + pub max_weight: Option, } impl Target { diff --git a/tests/bnb.rs b/tests/bnb.rs index 6a2f437..b81d22f 100644 --- a/tests/bnb.rs +++ b/tests/bnb.rs @@ -118,6 +118,7 @@ fn bnb_finds_an_exact_solution_in_n_iter() { }, // we're trying to find an exact selection value so set fees to 0 fee: TargetFee::ZERO, + max_weight: None, }; let solutions = cs.bnb_solutions(MinExcessThenWeight { target }); @@ -152,6 +153,7 @@ fn bnb_finds_solution_if_possible_in_n_iter() { n_outputs: 1, }, fee: TargetFee::default(), + max_weight: None, }; let solutions = cs.bnb_solutions(MinExcessThenWeight { target }); @@ -195,10 +197,7 @@ proptest! { let candidates = wv.take(num_inputs).collect::>(); let cs = CoinSelector::new(&candidates); - let target = Target { - outputs: TargetOutputs { value_sum: target_value, weight_sum: 0, n_outputs: 1 }, - fee: TargetFee::ZERO, - }; + let target = Target {outputs:TargetOutputs{value_sum:target_value,weight_sum:0,n_outputs:1},fee:TargetFee::ZERO, max_weight: None }; let solutions = cs.bnb_solutions(MinExcessThenWeight { target }); @@ -244,6 +243,7 @@ proptest! { outputs: TargetOutputs { value_sum: target_value, weight_sum: 0, n_outputs: 1 }, // we're trying to find an exact selection value so set fees to 0 fee: TargetFee::ZERO, + max_weight: None }; let solutions = cs.bnb_solutions(MinExcessThenWeight { target }); diff --git a/tests/changeless.rs b/tests/changeless.rs index 42224e9..a3825c6 100644 --- a/tests/changeless.rs +++ b/tests/changeless.rs @@ -67,7 +67,8 @@ proptest! { rate: feerate, replace, ..TargetFee::ZERO - } + }, + max_weight: None }; let solutions = cs.bnb_solutions(metrics::Changeless { diff --git a/tests/common.rs b/tests/common.rs index 8b8cb9d..d809c50 100644 --- a/tests/common.rs +++ b/tests/common.rs @@ -221,6 +221,7 @@ impl StrategyParams { weight_sum: self.target_weight as u64, n_outputs: self.n_target_outputs, }, + max_weight: None, } } diff --git a/tests/lowest_fee.rs b/tests/lowest_fee.rs index 27e2948..633bfd7 100644 --- a/tests/lowest_fee.rs +++ b/tests/lowest_fee.rs @@ -192,6 +192,7 @@ fn adding_another_input_to_remove_change() { weight_sum: 200 - TX_FIXED_FIELD_WEIGHT - 1, n_outputs: 1, }, + max_weight: None, }; let candidates = vec![ @@ -284,6 +285,7 @@ fn zero_fee_tx() { weight_sum: 200 - TX_FIXED_FIELD_WEIGHT - 1, n_outputs: 1, }, + max_weight: None, }; let candidates = vec![