Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
Expand Down
3 changes: 2 additions & 1 deletion examples/synopsis.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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,
},
)?;

Expand Down
11 changes: 3 additions & 8 deletions src/input_candidates.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down Expand Up @@ -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)
}
}
Expand All @@ -219,8 +216,6 @@ pub enum IntoSelectionError<E> {
Selector(SelectorError),
/// Selection algorithm failed.
SelectionAlgorithm(E),
/// The target cannot be met
CannotMeetTarget(CannotMeetTarget),
}

impl<E: fmt::Display> fmt::Display for IntoSelectionError<E> {
Expand All @@ -232,7 +227,6 @@ impl<E: fmt::Display> fmt::Display for IntoSelectionError<E> {
IntoSelectionError::SelectionAlgorithm(error) => {
write!(f, "selection algorithm failed: {error}")
}
IntoSelectionError::CannotMeetTarget(error) => write!(f, "{error}"),
}
}
}
Expand All @@ -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(
Expand Down
134 changes: 98 additions & 36 deletions src/selector.rs
Original file line number Diff line number Diff line change
@@ -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;

Expand All @@ -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.
Expand Down Expand Up @@ -64,6 +60,11 @@ pub struct SelectorParams {

/// Params for replacing tx(s).
pub replace: Option<RbfParams>,

/// 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.
Expand Down Expand Up @@ -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<Output>,
Expand All @@ -261,6 +262,7 @@ impl SelectorParams {
change_longterm_feerate: None,
replace: None,
change_dust_relay_feerate: None,
max_weight: Weight::MAX_BLOCK,
}
}

Expand All @@ -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()),
}
}

Expand Down Expand Up @@ -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
Expand All @@ -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<SelectError> for SelectorError {
fn from(e: SelectError) -> Self {
match e {
SelectError::InsufficientFunds(e) => Self::InsufficientFunds(e),
SelectError::MaxWeightExceeded => Self::MaxWeightExceeded,
}
}
}
Expand All @@ -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).
Expand Down Expand Up @@ -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.
Expand All @@ -483,14 +493,38 @@ impl<'c> Selector<'c> {
Some(has_drain)
}

/// Maximum allowed transaction weight.
pub fn max_weight(&self) -> Option<Weight> {
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<Selection> {
/// # 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<Selection, SelectorError> {
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::<Vec<_>>();
let inputs = self
.inner
Expand All @@ -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))
}
}

Expand Down Expand Up @@ -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(())
}
}