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
93 changes: 93 additions & 0 deletions src/metrics.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,3 +7,96 @@ mod lowest_fee;
pub use lowest_fee::*;
mod changeless;
pub use changeless::*;
mod changeless_waste;
pub use changeless_waste::*;

use crate::{Candidate, CoinSelector, Drain, Target};

/// Outcome of the "resize trick" (see [`resize_bound`]).
enum ResizeBound<'a> {
/// The crossing selection hit the target with exactly zero excess, so it is itself the
/// minimum-cost target-meeting descendant. Holds that selection.
Exact(CoinSelector<'a>),
/// The crossing input must be fractionally resized. Holds the selection with the crossing input
/// deselected, the crossing candidate, and the `scale ∈ [0, 1]` of it that satisfies every fee
/// constraint with exactly zero excess.
Resize(CoinSelector<'a>, Candidate, f32),
}

/// Shared "resize trick" used by bounds that need a tight lower bound on some monotone quantity
/// (fee, `input_weight`, …) of any target-meeting descendant `D ⊇ cs`, for the case where `cs`
/// does not yet meet the target.
///
/// Walk the `value_pwu`-sorted unselected list until the target is first crossed, then represent
/// the crossing candidate as a fractional `scale ∈ [0, 1]` that satisfies each fee constraint
/// (rate, replacement, absolute) with exactly zero excess. Among all subsets of unselected that
/// reach the target, the highest-`value_pwu` candidates are the most efficient, so the
/// resize-scaled prefix is a valid lower bound on any target-meeting descendant.
///
/// Callers compute their own metric value from the outcome, e.g.
/// ```ignore
/// let ideal_fee = scale * to_resize.value as f32 + cs.selected_value() as f32 - target.value() as f32;
/// let ideal_iw = cs.input_weight() as f32 + scale * to_resize.weight as f32;
/// ```
///
/// Returns `None` if no target-meeting descendant exists (a fee constraint cannot be satisfied by
/// any available candidate).
fn resize_bound<'a>(cs: &CoinSelector<'a>, target: Target) -> Option<ResizeBound<'a>> {
// Step 1: select everything up until the input that first hits the target.
let (mut cs, resize_index, to_resize) = cs
.clone()
.select_iter()
.find(|(cs, _, _)| cs.is_target_met(target))?;

// If this selection is already perfect, it is the minimum-cost target-meeting descendant.
if cs.excess(target, Drain::NONE) == 0 {
return Some(ResizeBound::Exact(cs));
}
cs.deselect(resize_index);

// Find the smallest `scale` of `to_resize` that satisfies every fee constraint. We imagine a
// perfect input that hits the target with zero excess: for a feerate constraint,
//
// scale = remaining_value_to_reach_feerate / effective_value_of_resized_input
//
// In this perfect scenario no extra fee is needed for weight-unit-to-vbyte rounding, so all
// computations are on weight units directly.
let mut scale = 0.0_f32;

let rate_excess = cs.rate_excess_wu(target, Drain::NONE) as f32;
if rate_excess < 0.0 {
let remaining = rate_excess.abs();
let ev_resized = to_resize.effective_value(target.fee.rate);
if ev_resized > 0.0 {
scale = scale.max(remaining / ev_resized);
} else {
return None; // we can never satisfy the constraint
}
}
// Replacement uses the same approach with the incremental relay feerate.
if let Some(replace) = target.fee.replace {
let replace_excess = cs.replacement_excess_wu(target, Drain::NONE) as f32;
if replace_excess < 0.0 {
let remaining = replace_excess.abs();
let ev_resized = to_resize.effective_value(replace.incremental_relay_feerate);
if ev_resized > 0.0 {
scale = scale.max(remaining / ev_resized);
} else {
return None; // we can never satisfy the constraint
}
}
}
// The absolute fee is a fixed amount (not weight-proportional), so we just need enough raw
// value to cover the gap.
let absolute_excess = cs.absolute_excess(target, Drain::NONE) as f32;
if absolute_excess < 0.0 {
let remaining = absolute_excess.abs();
if to_resize.value > 0 {
scale = scale.max(remaining / to_resize.value as f32);
} else {
return None; // we can never satisfy the constraint
}
}

Some(ResizeBound::Resize(cs, to_resize, scale))
}
290 changes: 290 additions & 0 deletions src/metrics/changeless_waste.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,290 @@
use crate::{bnb::BnbMetric, float::Ordf32, CoinSelector, Drain, DrainWeights, FeeRate, Target};
use alloc::vec::Vec;

/// Metric that minimizes the [waste metric] subject to the constraint that the selection produces
/// no change output.
///
/// For a changeless selection, waste reduces to:
///
/// > `input_weight * (feerate - long_term_feerate) + max(0, excess)`
///
/// Excess in a changeless transaction goes to the miner as fees and is therefore fully counted as
/// waste.
///
/// Restricting to changeless solutions removes the non-monotonic discontinuity that the general
/// (with-change) waste metric has when an input flips the change output on or off, which makes a
/// correct bound much easier to construct.
///
/// Like [`LowestFee`], `ChangelessWaste` decides for itself whether a selection *would* have a
/// change output (using the same rule: change is worthwhile when the recovered excess outweighs the
/// future cost of spending it and clears the dust threshold). Selections that would have change are
/// rejected, so only genuinely changeless selections are scored.
///
/// [waste metric]: https://bitcoin.stackexchange.com/questions/113622/what-does-waste-metric-mean-in-the-context-of-coin-selection
/// [`LowestFee`]: crate::metrics::LowestFee
#[derive(Clone, Copy, Debug)]
pub struct ChangelessWaste {
/// The estimated feerate needed to spend a change output later. This is used by the metric
/// even though the scored selections do not have a change output — the long-term feerate
/// defines the `feerate - long_term_feerate` weight cost of each input.
pub long_term_feerate: FeeRate,
/// The feerate used to determine the dust threshold of the change output.
pub dust_relay_feerate: FeeRate,
/// The weights of the change output that would be added.
pub drain_weights: DrainWeights,
}

impl ChangelessWaste {
/// The value the change output would have, or `None` if this selection should be changeless.
///
/// This is the same change decision as [`LowestFee`]: the metric owns its change policy instead
/// of taking one as input.
///
/// [`LowestFee`]: crate::metrics::LowestFee
fn drain_value(&self, cs: &CoinSelector<'_>, target: Target) -> Option<u64> {
let excess_with_drain_weight = self.excess_with_drain_weight(cs, target);

// Adding change is only worth it if the value we'd recover exceeds the future cost of
// spending it (i.e. it lowers the long-term fee).
if excess_with_drain_weight <= self.drain_spend_cost() as i64 {
return None;
}

// ...and only if the change output would not be dust.
if excess_with_drain_weight < self.dust_threshold() as i64 {
return None;
}

Some(excess_with_drain_weight.unsigned_abs())
}

/// The excess of `cs` after accounting for the weight (but not value) of a would-be change
/// output. This is the quantity the change decision (see [`drain_value`]) is made on.
///
/// [`drain_value`]: Self::drain_value
fn excess_with_drain_weight(&self, cs: &CoinSelector<'_>, target: Target) -> i64 {
// The change output pays for its own weight, so the value we'd actually recover is the
// excess remaining after accounting for that weight.
cs.excess(
target,
Drain {
weights: self.drain_weights,
value: 0,
},
)
}

/// The future fee of spending a would-be change output. Change below this is never worthwhile.
fn drain_spend_cost(&self) -> u64 {
self.long_term_feerate
.implied_fee_wu(self.drain_weights.spend_weight)
}

/// The dust threshold of a would-be change output. Change below this is never created.
fn dust_threshold(&self) -> u64 {
self.drain_weights.dust_threshold(self.dust_relay_feerate)
}

/// Whether every selection reachable down this branch (the current one and any superset of it)
/// would have a change output — so no changeless solution exists here and the branch can be
/// pruned.
///
/// The change decision is assumed monotone in the excess (see [`drain_value`]), so the
/// reachable selection least likely to have change is the one with the smallest excess: the
/// current selection plus every remaining negative-effective-value candidate. If even that
/// selection still has change, then so does every reachable selection.
///
/// NOTE: this relies on candidates being sorted so that all negative effective value candidates
/// are next to each other, which [`requires_ordering_by_descending_value_pwu`] guarantees.
///
/// [`drain_value`]: Self::drain_value
/// [`requires_ordering_by_descending_value_pwu`]: BnbMetric::requires_ordering_by_descending_value_pwu
fn change_unavoidable(&mut self, cs: &CoinSelector<'_>, target: Target) -> bool {
// The "least excess" construction below adds every negative-*rate*-effective-value
// candidate to minimise the excess, which is only sound when the rate feerate is the
// binding fee constraint. With an RBF replacement (`incremental_relay_feerate < feerate`) a
// candidate can be negative at the rate yet positive at the replacement rate, so adding it
// *raises* `replacement_excess`; the rate-driven construction then no longer minimises the
// true `min(rate, absolute, replacement)` excess and could wrongly conclude change is
// unavoidable. Never prune in that case — returning `false` is always safe, it only costs
// extra search. (An absolute fee is safe here: `absolute_excess` only grows as inputs are
// added, so it can never be the constraint that a *superset* brings back under threshold.)
if target.fee.replace.is_some() {
return false;
}

if self.drain_value(cs, target).is_none() {
return false;
}

let mut least_excess = cs.clone();
cs.unselected()
.rev()
.take_while(|(_, wv)| wv.effective_value(target.fee.rate) < 0.0)
.for_each(|(index, _)| {
least_excess.select(index);
});

self.drain_value(&least_excess, target).is_some()
}

/// LP-relaxed upper bound on `D.input_weight` for changeless `D ⊇ cs` (used by the
/// `rate_diff < 0` branch of [`bound`]).
///
/// Construct `D_all = cs ∪ all unselected`. If `D_all` itself is changeless, the UB is
/// `D_all.input_weight`. Otherwise we must exclude enough excess-contributing
/// (positive-`effective_value`) candidates to drop `excess_with_drain_weight` down to the
/// largest still-changeless excess. To MAXIMIZE the remaining `input_weight` we MINIMIZE the
/// excluded weight, sorting positive-`ev` candidates by `ev / weight` descending and removing
/// fractionally until the required `delta` is met.
///
/// The LP relaxation gives a value `>=` any integer solution's excluded weight, so
/// `D_all.input_weight - LP_min` is a safe UB for any feasible `D.input_weight`. The
/// `input_weight()` segwit/varint corrections only ever ADD weight to the parent, never
/// subtract from a subset — so the additive subtraction is safe in the UB direction.
///
/// The knapsack credits each removed candidate its *rate*-based `effective_value`, so it is
/// only valid when the rate feerate is the binding fee constraint. When an absolute fee or an
/// RBF replacement is present it falls back to the trivial (always valid) `D_all` bound — see
/// the guard below.
///
/// [`bound`]: BnbMetric::bound
fn ub_changeless_input_weight(&self, cs: &CoinSelector<'_>, target: Target) -> f32 {
let mut d_all = cs.clone();
d_all.select_all();
let d_all_iw = d_all.input_weight() as f32;

// The knapsack below credits each removed candidate its *rate*-based `effective_value`,
// which equals the true excess reduction only when the rate feerate is the binding fee
// constraint. But `excess` is `min(rate, absolute, replacement)`: with an absolute fee,
// removing a candidate drops `absolute_excess` by its full `value`; with an RBF replacement
// (`incremental_relay_feerate < feerate`) it drops `replacement_excess` by `value - weight *
// incremental_relay_feerate` — both larger than its rate-ev. Crediting the smaller rate-ev
// would over-remove weight and yield a *below*-true (invalid, too-tight) upper bound, so
// fall back to the trivial `D_all` upper bound whenever a non-rate constraint can bind.
if target.fee.absolute > 0 || target.fee.replace.is_some() {
return d_all_iw;
}

// The largest `excess_with_drain_weight` for which a selection is still changeless: it is
// changeless (see `drain_value`) when its excess is `<= drain_spend_cost` OR `<
// dust_threshold`, and the union of those two regions is `excess <= max(drain_spend_cost,
// dust_threshold - 1)`.
let changeless_max_excess =
(self.drain_spend_cost() as i64).max(self.dust_threshold() as i64 - 1);
let delta = self.excess_with_drain_weight(&d_all, target) - changeless_max_excess;
if delta <= 0 {
return d_all_iw;
}
let mut remaining = delta as f32;

let mut pos: Vec<(f32, f32)> = cs
.unselected()
.filter_map(|(_, c)| {
let ev = c.effective_value(target.fee.rate);
if ev > 0.0 {
Some((ev, c.weight as f32))
} else {
None
}
})
.collect();
pos.sort_by(|a, b| {
let r_a = a.0 / a.1;
let r_b = b.0 / b.1;
r_b.partial_cmp(&r_a).unwrap_or(core::cmp::Ordering::Equal)
});

let mut removed_weight = 0.0_f32;
for (ev, w) in pos {
if remaining <= 0.0 {
break;
}
if ev >= remaining {
removed_weight += w * (remaining / ev);
remaining = 0.0;
} else {
removed_weight += w;
remaining -= ev;
}
}
if remaining > 0.0 {
// Unreachable when `change_unavoidable = false` (which the caller already checked).
// Fall back to the loose `D_all`-based bound rather than fabricating a tight one.
return d_all_iw;
}
d_all_iw - removed_weight
}
}

impl BnbMetric for ChangelessWaste {
fn drain(&mut self, _cs: &CoinSelector<'_>, _target: Target) -> Drain {
// By definition a changeless selection never has a change output.
Drain::NONE
}

fn score(&mut self, cs: &CoinSelector<'_>, target: Target) -> Option<Ordf32> {
if !cs.is_target_met(target) {
return None;
}
// Reject selections that would have change — this metric only scores changeless solutions.
if self.drain_value(cs, target).is_some() {
return None;
}
let waste = cs.waste(target, self.long_term_feerate, Drain::NONE, 1.0);
Some(Ordf32(waste))
}

fn bound(&mut self, cs: &CoinSelector<'_>, target: Target) -> Option<Ordf32> {
// Prune branches where every descendant is forced to have a change output.
if self.change_unavoidable(cs, target) {
return None;
}

let rate_diff = target.fee.rate.spwu() - self.long_term_feerate.spwu();

// For any changeless target-meeting descendant D ⊇ cs:
// score(D) = D.input_weight * rate_diff + max(0, D.excess)
//
// and `D.excess >= 0` (target met), so `score(D) >= D.input_weight * rate_diff`. The
// bound therefore reduces to bounding `D.input_weight` in the right direction.

if rate_diff < 0.0 {
// rate_diff < 0: we want an UPPER bound on `D.input_weight`. `all_selected` is a
// safe but loose UB; we tighten by LP-relaxed knapsack over candidates that
// *must* be excluded to keep the selection changeless.
let ub = self.ub_changeless_input_weight(cs, target);
return Some(Ordf32(ub * rate_diff));
}

// rate_diff >= 0: we want a LOWER bound on `D.input_weight`. `cs.input_weight` is a
// safe baseline (input_weight is monotone non-decreasing). Tighten with the resize
// trick when target is not yet met.
if cs.is_target_met(target) {
return Some(Ordf32(cs.input_weight() as f32 * rate_diff));
}

// Target not met. Same resize trick as `LowestFee::bound`: walk the sorted unselected list
// until we cross the target, then pretend the crossing input was perfectly scaled so the
// target is hit with zero excess. The resize-scaled prefix is a valid lower bound on any
// target-meeting descendant's `input_weight` (see `resize_bound`).
match super::resize_bound(cs, target)? {
// The crossing selection is already the minimum-weight target-meeting subset; its bound
// is its own waste (with `Drain::NONE`).
super::ResizeBound::Exact(cs) => Some(Ordf32(cs.waste(
target,
self.long_term_feerate,
Drain::NONE,
1.0,
))),
super::ResizeBound::Resize(cs, to_resize, scale) => {
let ideal_input_weight = cs.input_weight() as f32 + scale * to_resize.weight as f32;
Some(Ordf32(ideal_input_weight * rate_diff))
}
}
}

fn requires_ordering_by_descending_value_pwu(&self) -> bool {
true
}
}
Loading
Loading