Skip to content
Open
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
9 changes: 6 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,9 @@ 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)),
// An optional cap on the resulting transaction weight (e.g. for TRUC). `None` = unconstrained.
max_weight: None,
};

let candidates = vec![
Expand Down Expand Up @@ -55,10 +57,10 @@ let candidates = vec![
let mut coin_selector = CoinSelector::new(&candidates);
coin_selector.select(0);

assert!(!coin_selector.is_target_met(target), "we didn't select enough");
assert!(!coin_selector.is_funded(target), "we didn't select enough");
println!("we didn't select enough yet we're missing: {}", coin_selector.missing(target));
coin_selector.select(1);
assert!(coin_selector.is_target_met(target), "we should have enough now");
assert!(coin_selector.is_funded(target), "we should have enough now");

// Now we need to know if we need a change output to drain the excess if we overshot too much
//
Expand Down Expand Up @@ -130,6 +132,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 feerate used to work out whether a change output would be dust (and so shouldn't be added).
Expand Down
1 change: 1 addition & 0 deletions benches/coin_selector.rs
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ fn make_bnb_inputs(candidates: &[Candidate]) -> (Target, FeeRate) {
let target = Target {
fee: TargetFee::from_feerate(target_fr),
outputs: TargetOutputs::fund_outputs([(TXOUT_BASE_WEIGHT + TR_SPK_WEIGHT, total / 2)]),
max_weight: None,
};
(target, long_term_fr)
}
Expand Down
121 changes: 94 additions & 27 deletions src/coin_selector.rs
Original file line number Diff line number Diff line change
Expand Up @@ -111,17 +111,23 @@ impl<'a> CoinSelector<'a> {
self.selected.contains(index)
}

/// Is meeting this `target` possible with the current selection with this `drain` (i.e. change output).
/// Note this will respect [`ban`]ned candidates.
/// Whether the candidates can cover this `target`'s **value** (net of input fees) — i.e. whether
/// enough value is reachable for [`is_funded`] to hold. Respects [`ban`]ned candidates.
///
/// This simply selects all effective inputs at the target's feerate and checks whether we have
/// enough value.
/// Selecting *all* effective inputs maximizes the value available, so if that can't meet the
/// target value, nothing can. Monotone, hence exact.
///
/// NOTE: this does **not** account for [`Target::max_weight`] — a `true` result can still be
/// infeasible under the weight cap. Use [`select_until_target_met`] or branch and bound (both of
/// which enforce the cap) to actually build a selection.
///
/// [`ban`]: Self::ban
pub fn is_selection_possible(&self, target: Target) -> bool {
/// [`is_funded`]: Self::is_funded
/// [`select_until_target_met`]: Self::select_until_target_met
pub fn is_fundable(&self, target: Target) -> bool {
let mut test = self.clone();
test.select_all_effective(target.fee.rate);
test.is_target_met(target)
test.is_funded(target)
}

/// Returns true if no candidates have been selected.
Expand Down Expand Up @@ -429,20 +435,38 @@ impl<'a> CoinSelector<'a> {
self.unselected_indices().next().is_none()
}

/// Whether the constraints of `Target` have been met if we include a specific `drain` ouput.
/// Whether the tx implied by the current selection and `drain` is within [`Target::max_weight`].
///
/// Note if [`is_target_met`] is true and the `drain` is produced from the [`drain`] method then
/// this method will also always be true.
/// Always `true` when `max_weight` is `None`. Note this is the *anti-monotone* half of
/// feasibility (adding inputs adds weight), so it is kept separate from the monotone
/// value-only [`is_funded`](Self::is_funded).
pub 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 selection covers the target value (i.e. [`excess`](Self::excess) is
/// non-negative), ignoring [`Target::max_weight`].
///
/// [`is_target_met`]: Self::is_target_met
/// [`drain`]: Self::drain
pub fn is_target_met_with_drain(&self, target: Target, drain: Drain) -> bool {
/// This is **monotone**: selecting more never un-meets it. It deliberately does *not* include
/// the weight cap — see [`is_within_max_weight`](Self::is_within_max_weight).
pub fn is_funded_with_drain(&self, target: Target, drain: Drain) -> bool {
self.excess(target, drain) >= 0
}

/// Whether the constraints of `Target` have been met.
pub fn is_target_met(&self, target: Target) -> bool {
self.is_target_met_with_drain(target, Drain::NONE)
/// Whether the selection covers the target **value** (net of input fees), i.e. [`excess`] is
/// non-negative. **Monotone** (selecting more never un-meets it), and it deliberately does
/// *not* check [`Target::max_weight`] — that is the separate, anti-monotone
/// [`is_within_max_weight`]. See [`is_funded_with_drain`] for the version that
/// accounts for a specific `drain`.
///
/// [`excess`]: Self::excess
/// [`is_within_max_weight`]: Self::is_within_max_weight
/// [`is_funded_with_drain`]: Self::is_funded_with_drain
pub fn is_funded(&self, target: Target) -> bool {
self.is_funded_with_drain(target, Drain::NONE)
}

/// Select all unselected candidates
Expand All @@ -468,8 +492,8 @@ 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(
self.is_funded(target),
self.is_funded_with_drain(
target,
Drain {
weights: change_policy.drain_weights,
Expand All @@ -488,12 +512,12 @@ impl<'a> CoinSelector<'a> {
/// `change_policy`. If it should not, then it will return [`Drain::NONE`]. The value of the
/// `Drain` will be the same as [`drain_value`].
///
/// If [`is_target_met`] returns true for this selection then [`is_target_met_with_drain`] will
/// If [`is_funded`] returns true for this selection then [`is_funded_with_drain`] will
/// also be true if you pass in the drain returned from this method.
///
/// [`drain_value`]: Self::drain_value
/// [`is_target_met_with_drain`]: Self::is_target_met_with_drain
/// [`is_target_met`]: Self::is_target_met
/// [`is_funded_with_drain`]: Self::is_funded_with_drain
/// [`is_funded`]: Self::is_funded
#[must_use]
pub fn drain(&self, target: Target, change_policy: ChangePolicy) -> Drain {
match self.drain_value(target, change_policy) {
Expand Down Expand Up @@ -521,14 +545,25 @@ 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> {
self.select_until(|cs| cs.is_target_met(target))
.ok_or_else(|| InsufficientFunds {
missing: self.excess(target, Drain::NONE).unsigned_abs(),
})
/// # Errors
///
/// - [`SelectError::InsufficientFunds`] if the candidates can't cover the target value.
/// - [`SelectError::MaxWeightExceeded`] if the value is met but the resulting selection exceeds
/// [`Target::max_weight`]. Note this only reflects *this* in-order greedy selection; a
/// different selection might still fit the cap (use branch and bound to search for one).
pub fn select_until_target_met(&mut self, target: Target) -> Result<(), SelectError> {
self.select_until(|cs| cs.is_funded(target))
.ok_or_else(|| {
SelectError::InsufficientFunds(InsufficientFunds {
missing: self.excess(target, Drain::NONE).unsigned_abs(),
})
})?;
if !self.is_within_max_weight(target, Drain::NONE) {
return Err(SelectError::MaxWeightExceeded);
}
Ok(())
}

/// Select candidates until some predicate has been satisfied.
Expand Down Expand Up @@ -663,6 +698,38 @@ impl core::fmt::Display for InsufficientFunds {
#[cfg(feature = "std")]
impl std::error::Error for InsufficientFunds {}

/// Error returned by [`CoinSelector::select_until_target_met`].
#[derive(Clone, Debug, Copy, PartialEq, Eq)]
pub enum SelectError {
/// The candidates can't cover the target value.
InsufficientFunds(InsufficientFunds),
/// The value target is met, but the resulting selection exceeds [`Target::max_weight`].
MaxWeightExceeded,
}

impl From<InsufficientFunds> for SelectError {
fn from(e: InsufficientFunds) -> Self {
SelectError::InsufficientFunds(e)
}
}

impl core::fmt::Display for SelectError {
fn fmt(&self, f: &mut core::fmt::Formatter) -> core::fmt::Result {
match self {
SelectError::InsufficientFunds(e) => write!(f, "{}", e),
SelectError::MaxWeightExceeded => {
write!(
f,
"Selection meets the target value but exceeds `max_weight`."
)
}
}
}
}

#[cfg(feature = "std")]
impl std::error::Error for SelectError {}

/// Error type for when a solution cannot be found by branch-and-bound.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct NoBnbSolution {
Expand Down
105 changes: 82 additions & 23 deletions src/metrics/lowest_fee.rs
Original file line number Diff line number Diff line change
Expand Up @@ -53,8 +53,47 @@ impl LowestFee {
return None;
}

// ...and only if the change output would not push the tx over `max_weight`. If it would,
// we refuse the drain and the excess goes to fee instead (a slightly conservative choice:
// it can refuse change even when a no-change tx of this selection would fit).
let drain = Drain {
weights: self.drain_weights,
value: excess_with_drain_weight.unsigned_abs(),
};
if !cs.is_within_max_weight(target, drain) {
return None;
}

Some(excess_with_drain_weight.unsigned_abs())
}

/// The long-term-fee score **ignoring** the `max_weight` cap, together with the drain it
/// assumes. `None` iff the value target isn't met. Used inside [`bound`](BnbMetric::bound),
/// where ignoring the (feasibility) cap only loosens the lower bound and never makes it
/// inadmissible; [`score`](BnbMetric::score) reuses the returned drain for its cap check so the
/// drain is only decided once.
fn fee_score(&self, cs: &CoinSelector<'_>, target: Target) -> Option<(Ordf32, Drain)> {
if !cs.is_funded(target) {
return None;
}
let drain = self
.drain_value(cs, target)
.map_or(Drain::NONE, |value| Drain {
weights: self.drain_weights,
value,
});
let fee_for_the_tx = cs.fee(target.value(), drain.value);
assert!(
fee_for_the_tx >= 0,
"must not be called unless selection has met target: fee={}",
fee_for_the_tx
);
let fee_for_spending_drain = drain.weights.spend_fee(self.long_term_feerate);
Some((
Ordf32((fee_for_the_tx as u64 + fee_for_spending_drain) as f32),
drain,
))
}
}

impl BnbMetric for LowestFee {
Expand All @@ -67,30 +106,27 @@ impl BnbMetric for LowestFee {
}

fn score(&mut self, cs: &CoinSelector<'_>, target: Target) -> Option<Ordf32> {
if !cs.is_target_met(target) {
let (score, drain) = self.fee_score(cs, target)?;
// A final selection must fit the weight cap. `drain_value` already refuses an over-cap
// change, but a changeless selection can still be too heavy on its own. Reuse the drain
// `fee_score` already decided rather than recomputing it here.
if !cs.is_within_max_weight(target, drain) {
return None;
}

let long_term_fee = {
let drain = self.drain(cs, target);
let fee_for_the_tx = cs.fee(target.value(), drain.value);
assert!(
fee_for_the_tx >= 0,
"must not be called unless selection has met target: fee={}",
fee_for_the_tx
);
// `spend_fee` rounds up here. We could use floats but I felt it was just better to
// accept the extra 1 sat penality to having a change output
let fee_for_spending_drain = drain.weights.spend_fee(self.long_term_feerate);
fee_for_the_tx as u64 + fee_for_spending_drain
};

Some(Ordf32(long_term_fee as f32))
Some(score)
}

fn bound(&mut self, cs: &CoinSelector<'_>, target: Target) -> Option<Ordf32> {
if cs.is_target_met(target) {
let current_score = self.score(cs, target).unwrap();
// Weight hard-prune: input weight only grows as this branch is extended, so the lightest
// solution in the subtree is this selection with no drain. If even that busts `max_weight`,
// the whole subtree is infeasible -> prune. (Also keeps `fee_score(cs).unwrap()` below
// sound: a value-met but over-cap node would otherwise score `None`.)
if !cs.is_within_max_weight(target, Drain::NONE) {
return None;
}

if cs.is_funded(target) {
let current_score = self.fee_score(cs, target).unwrap().0;

// `current_score` is already a valid lower bound for a selection that has change: a
// descendant can never lower the fee by removing an existing (worthwhile) change
Expand Down Expand Up @@ -125,7 +161,17 @@ impl BnbMetric for LowestFee {

let best_score_with_change =
Ordf32(current_score.0 - cost_of_no_change as f32 + cost_of_adding_change);
if best_score_with_change < current_score {
// max_weight-aware: recovering that value requires a change output (and more
// inputs to clear the dust threshold), which only makes the tx heavier. If a change
// output can't fit the cap now it never will down this branch, so don't credit the
// improvement — keep `current_score` (a tighter, still-admissible bound).
let change_output = Drain {
weights: self.drain_weights,
value: 0,
};
if best_score_with_change < current_score
&& cs.is_within_max_weight(target, change_output)
{
return Some(best_score_with_change);
}
}
Expand All @@ -136,11 +182,11 @@ impl BnbMetric for LowestFee {
let (mut cs, resize_index, to_resize) = cs
.clone()
.select_iter()
.find(|(cs, _, _)| cs.is_target_met(target))?;
.find(|(cs, _, _)| cs.is_funded(target))?;

// If this selection is already perfect, return its score directly.
if cs.excess(target, Drain::NONE) == 0 {
return Some(self.score(&cs, target).unwrap());
return Some(self.fee_score(&cs, target).unwrap().0);
};
cs.deselect(resize_index);

Expand Down Expand Up @@ -207,7 +253,20 @@ impl BnbMetric for LowestFee {
}
}

// `scale` could be 0 even if `is_target_met` is `false` due to the latter being based on
// max_weight-aware: reaching the feerate needs a perfect input weighing
// `scale * to_resize.weight`. `to_resize` is the best value-per-weight input available,
// so if even that (fractionally) can't fit the remaining weight budget, no within-cap
// selection down this branch reaches the target -> prune. This is the fractional
// relaxation, so it never prunes a branch that has an (integer) within-cap solution.
if let Some(max_weight) = target.max_weight {
let budget =
max_weight.saturating_sub(cs.weight(target.outputs, DrainWeights::NONE));
if scale.0 * to_resize.weight as f32 > budget as f32 {
return None;
}
}

// `scale` could be 0 even if `is_funded` is `false` due to the latter being based on
// rounded-up vbytes.
let ideal_fee = scale.0 * to_resize.value as f32 + cs.selected_value() as f32
- target.value() as f32;
Expand Down
5 changes: 5 additions & 0 deletions src/target.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,11 @@ 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.
///
/// This is a feasibility constraint on the answer (the sibling of the value target: a lower
/// bound on value, this an upper bound on weight). Relevant e.g. for TRUC/BIP-431 packages.
pub max_weight: Option<u64>,
}

impl Target {
Expand Down
Loading
Loading