diff --git a/src/metrics.rs b/src/metrics.rs index 1da1163..86cea91 100644 --- a/src/metrics.rs +++ b/src/metrics.rs @@ -7,3 +7,111 @@ mod lowest_fee; pub use lowest_fee::*; mod changeless; pub use changeless::*; +mod changeless_waste; +pub use changeless_waste::*; + +use crate::{Candidate, CoinSelector, Drain, DrainWeights, 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), +} + +/// The "resize trick" behind [`LowestFee`]'s fee lower bound, 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 approximates the cheapest target-meeting descendant. +/// +/// Returns `None` if no target-meeting descendant exists (a fee constraint cannot be satisfied by +/// any available candidate). +/// +/// CAUTION: the crossing point and `scale` are computed from the greedy prefix's *actual* +/// (`input_weight()`-corrected, vbyte-rounded) fee and weight. A descendant that avoids the +/// prefix's segwit/varint corrections can undercut quantities derived from them by a few sats or +/// weight units, so this walk must NOT be used as a lower bound on a descendant's `input_weight` +/// — [`ChangelessWaste`]'s bound was migrated to raw-weight-linear arithmetic for exactly that +/// reason (see the derivation in `ChangelessWaste::bound`). The same caveat applies in fee space +/// to the remaining use here. +fn resize_bound<'a>(cs: &CoinSelector<'a>, target: Target) -> Option> { + // 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_funded(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 + } + } + + // 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 the current weight plus even that (fractional) minimum already busts the cap, + // no within-cap selection down this branch reaches the target -> prune. This is the + // fractional relaxation, so it never prunes a branch with an (integer) within-cap + // solution. + if let Some(max_weight) = target.max_weight { + if cs.weight(target.outputs, DrainWeights::NONE) as f32 + scale * to_resize.weight as f32 + > max_weight as f32 + { + return None; + } + } + + Some(ResizeBound::Resize(cs, to_resize, scale)) +} diff --git a/src/metrics/changeless_waste.rs b/src/metrics/changeless_waste.rs new file mode 100644 index 0000000..3f2bc3c --- /dev/null +++ b/src/metrics/changeless_waste.rs @@ -0,0 +1,416 @@ +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 { + 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; + } + + // ...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). + if !cs.is_within_max_weight(target, self.drain_weights) { + 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) + } + + /// 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)`. + /// + /// [`drain_value`]: Self::drain_value + fn changeless_max_excess(&self) -> i64 { + (self.drain_spend_cost() as i64).max(self.dust_threshold() as i64 - 1) + } + + /// 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 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's + /// excess clears the changeless edge by more than a conservative rounding/correction slack + /// (see the body), then every reachable selection has change. + /// + /// 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<'_>, + d_all: &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; + } + + // With a `max_weight` cap, `drain_value` can refuse change for a descendant whose change + // output would bust the cap (see the cap clause there) — that descendant is changeless no + // matter its excess, so the least-excess reasoning below does not cover it. Only prune + // when no descendant can trigger the refusal, i.e. when even the heaviest descendant + // still fits the cap with the drain added. + if !d_all.is_within_max_weight(target, self.drain_weights) { + 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); + }); + + // The construction above minimises the *weight-unit-linear* excess (a candidate's + // `effective_value` is `value - raw_weight * spwu`), but the true excess comes from the + // vbyte-rounded fee on the corrected weight: `fee(W) ∈ [W*spwu, W*spwu + sat_vb + 1]`, + // where `W` includes `input_weight()`'s additions on top of raw weights (segwit header + // +2, +1 per legacy input in a segwit tx, varint growth). A candidate with a small + // *positive* linear ev can therefore still lower a descendant's true excess, so comparing + // the linear-least construction against the edge is only sound with a slack covering both + // effects. Corrections are monotone under selection, so for every descendant D: + // + // excess(D) >= excess(least_excess) + // - (corrections(D_all) - corrections(cs)) * spwu - (sat_vb + 1) + // + // (f64 everywhere sat values appear: f32's 24-bit mantissa is coarser than a sat above + // ~0.17 BTC, and a cancellation-flipped comparison here would be an invalid prune. f64 is + // exact for all sat amounts.) + let corrections = |s: &CoinSelector<'_>| -> u64 { + s.input_weight() - s.selected().map(|(_, c)| c.weight).sum::() + }; + let slack = (corrections(d_all) - corrections(cs)) as f64 * target.fee.rate.spwu() as f64 + + target.fee.rate.as_sat_vb() as f64 + + 1.0; + + let least_excess_ewd = self.excess_with_drain_weight(&least_excess, target) as f64; + least_excess_ewd - slack > self.changeless_max_excess() as f64 + } + + /// 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<'_>, + d_all: &CoinSelector<'_>, + target: Target, + ) -> f64 { + let d_all_iw = d_all.input_weight() as f64; + + // With a `max_weight` cap, `drain_value` can refuse a heavy descendant's change (see the + // cap clause there): such a descendant is changeless while keeping every + // excess-contributing candidate, so the knapsack's premise below (changeless => must shed + // positive-ev weight) fails. Whenever the refusal is reachable, fall back to the `D_all` + // bound clamped by the cap (every scoreable descendant must fit the cap without a drain, + // and the non-input weight is the same for every descendant). + if let Some(max_weight) = target.max_weight { + if !d_all.is_within_max_weight(target, self.drain_weights) { + let non_input_weight = + cs.weight(target.outputs, DrainWeights::NONE) - cs.input_weight(); + return d_all_iw.min(max_weight.saturating_sub(non_input_weight) as f64); + } + } + + // 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; + } + + let delta = self.excess_with_drain_weight(d_all, target) - self.changeless_max_excess(); + + // The knapsack below reasons in weight-unit-linear effective values, but a descendant's + // true excess comes from the vbyte-rounded fee (`fee(W) ∈ [W*spwu, W*spwu + sat_vb + + // 1]`), so a descendant can become changeless while shedding up to `sat_vb + 1` sats less + // than `delta` in linear terms. Demanding the full `delta` would over-remove weight and + // yield a below-true (invalid) upper bound. (`input_weight()`'s corrections err in the + // safe direction here: a removal sheds *at most* its linear ev.) + // (f64 for the same reason as in `change_unavoidable`: sat-magnitude values must not lose + // whole sats to float rounding.) + let mut remaining = delta as f64 - (target.fee.rate.as_sat_vb() as f64 + 1.0); + if remaining <= 0.0 { + return d_all_iw; + } + + let spwu = target.fee.rate.spwu() as f64; + let mut pos: Vec<(f64, f64)> = cs + .unselected() + .filter_map(|(_, c)| { + let ev = c.value as f64 - c.weight as f64 * spwu; + if ev > 0.0 { + Some((ev, c.weight as f64)) + } 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_f64; + 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 { + if !cs.is_funded(target) { + return None; + } + if !cs.is_within_max_weight(target, DrainWeights::NONE) { + 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 { + if !cs.is_within_max_weight(target, DrainWeights::NONE) { + return None; + } + + // Both helpers below reason about the heaviest descendant; build it once, since `bound` + // runs at every BnB node. + let d_all = { + let mut d_all = cs.clone(); + d_all.select_all(); + d_all + }; + + // Prune branches where every descendant is forced to have a change output. + if self.change_unavoidable(cs, &d_all, 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, &d_all, target); + return Some(Ordf32((ub * rate_diff as f64) as f32)); + } + + // rate_diff >= 0: we want a LOWER bound on `D.input_weight`, i.e. on the *additional raw* + // input weight any target-meeting descendant must add on top of `cs`. Everything below is + // weight-unit-linear arithmetic on raw candidate weights: + // + // - any D pays at least `W(D) * spwu` in fee (the vbyte rounding only ever adds), and + // - `D.input_weight >= cs.input_weight + Σ raw(D∖cs)` (`cs`'s varint/segwit corrections + // are common to every descendant and corrections only grow under selection), + // + // so `target met => Σ ev_lin(D∖cs) >= gap` where `ev_lin = value - raw_weight * spwu` and + // `gap = target.value + W(cs)*spwu - selected_value(cs)`. The fractional knapsack over + // the highest-`value_pwu` candidates minimizes the added raw weight subject to that + // (`ev_lin / raw = value_pwu - spwu` is monotone in `value_pwu`, so the required sorted + // order is already the iteration order and positive-`ev_lin` candidates form a prefix). + // + // Note this deliberately does NOT reuse the `resize_bound` walk: that walk's crossing + // point and scale are computed from the greedy prefix's *actual* (corrected, rounded) + // fee and weight, which a descendant avoiding the prefix's segwit corrections can beat — + // an invalid (too-high) lower bound. Raw-linear arithmetic can't be beaten. + // Funded-ness at the boundary is decided in exact integer arithmetic, never by a float + // sign: for a funded `cs` the baseline is already the bound and no walk is needed. + if cs.is_funded(target) { + return Some(Ordf32(cs.input_weight() as f32 * rate_diff)); + } + + // f64 keeps every sat amount exact (f32's 24-bit mantissa is coarser than a sat above + // ~0.17 BTC, and this subtraction cancels catastrophically at the funding boundary). + let spwu = target.fee.rate.spwu() as f64; + let mut gap = target.value() as f64 + + cs.weight(target.outputs, DrainWeights::NONE) as f64 * spwu + - cs.selected_value() as f64; + let mut extra_raw = 0.0_f64; + for (_, candidate) in cs.unselected() { + if gap <= 0.0 { + break; + } + let ev = candidate.value as f64 - candidate.weight as f64 * spwu; + if ev <= 0.0 { + // Sorted order: no later candidate can contribute value towards the gap either. + break; + } + let raw = candidate.weight as f64; + if ev >= gap { + extra_raw += raw * (gap / ev); + gap = 0.0; + } else { + extra_raw += raw; + gap -= ev; + } + } + if gap > 0.5 { + // Even selecting every positive-ev candidate cannot reach the target's feerate: no + // descendant is fundable, so prune. A fundable descendant requires a *linear* gap + // `<= 0` (the true fee only rounds up from the linear fee), and the f64 arithmetic + // above is exact to well under a sat, so any residual gap over half a sat is a true + // shortfall. A sub-half-sat residual falls through and is bounded instead of pruned. + return None; + } + Some(Ordf32( + ((cs.input_weight() as f64 + extra_raw) * rate_diff as f64) as f32, + )) + } + + fn requires_ordering_by_descending_value_pwu(&self) -> bool { + true + } +} diff --git a/src/metrics/lowest_fee.rs b/src/metrics/lowest_fee.rs index 5499777..3fa6d4e 100644 --- a/src/metrics/lowest_fee.rs +++ b/src/metrics/lowest_fee.rs @@ -179,103 +179,24 @@ impl BnbMetric for LowestFee { Some(current_score) } else { - // Step 1: select everything up until the input that hits the target. - let (mut cs, resize_index, to_resize) = cs - .clone() - .select_iter() - .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.fee_score(&cs, target).unwrap().0); - }; - cs.deselect(resize_index); - - // We need to find the minimum fee we'd pay if we satisfy the feerate constraint. We do - // this by imagining we had a perfect input that perfectly hit the target. The sats per - // weight unit of this perfect input is that of `to_resize` but we'll do a scaled - // resize of it to fit perfectly. - // - // Here's the formaula: - // - // target_feerate = (current_input_value - current_output_value + scale * value_resized_input) / (current_weight + scale * weight_resized_input) - // - // Rearranging to find `scale` we find that: - // - // scale = remaining_value_to_reach_feerate / effective_value_of_resized_input - // - // This should be intutive since we're finding out how to scale the input we're resizing to get the effective value we need. - // - // In the perfect scenario, no additional fee would be required to pay for rounding up when converting from weight units to - // vbytes and so all fee calculations below are performed on weight units directly. - let rate_excess = cs.rate_excess_wu(target, Drain::NONE) as f32; - let mut scale = Ordf32(0.0); - - if rate_excess < 0.0 { - let remaining_value_to_reach_feerate = rate_excess.abs(); - let effective_value_of_resized_input = to_resize.effective_value(target.fee.rate); - if effective_value_of_resized_input > 0.0 { - let feerate_scale = - remaining_value_to_reach_feerate / effective_value_of_resized_input; - scale = scale.max(Ordf32(feerate_scale)); - } else { - return None; // we can never satisfy the constraint + // Walk the sorted unselected list until we cross the target, resizing the crossing + // input to hit the target with zero excess (see `resize_bound`). + match super::resize_bound(cs, target)? { + // The crossing selection is already perfect: return its fee directly. Use + // `fee_score` (not `score`): this selection is only a bound witness — it may bust + // `max_weight` (which makes `score` return `None`), but its fee is still a valid + // lower bound for every within-cap descendant. + super::ResizeBound::Exact(cs) => Some(self.fee_score(&cs, target).unwrap().0), + super::ResizeBound::Resize(cs, to_resize, scale) => { + // `scale` could be 0 even if `is_funded` is `false` due to the latter being + // based on rounded-up vbytes. + let ideal_fee = scale * to_resize.value as f32 + cs.selected_value() as f32 + - target.value() as f32; + assert!(ideal_fee >= 0.0); + + Some(Ordf32(ideal_fee)) } } - - // We can use the same approach for replacement we just have to use 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_value_to_reach_feerate = replace_excess.abs(); - let effective_value_of_resized_input = - to_resize.effective_value(replace.incremental_relay_feerate); - if effective_value_of_resized_input > 0.0 { - let replace_scale = - remaining_value_to_reach_feerate / effective_value_of_resized_input; - scale = scale.max(Ordf32(replace_scale)); - } else { - return None; // we can never satisfy the constraint - } - } - } - // Handle absolute fee constraint. Unlike feerate and replacement, 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 { - let absolute_scale = remaining / to_resize.value as f32; - scale = scale.max(Ordf32(absolute_scale)); - } else { - return None; // we can never satisfy the constraint - } - } - - // 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 the current weight plus even that (fractional) minimum already busts the cap, - // no within-cap selection down this branch reaches the target -> prune. This is the - // fractional relaxation, so it never prunes a branch with an (integer) within-cap - // solution. - if let Some(max_weight) = target.max_weight { - if cs.weight(target.outputs, DrainWeights::NONE) as f32 - + scale.0 * to_resize.weight as f32 - > max_weight 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; - assert!(ideal_fee >= 0.0); - - Some(Ordf32(ideal_fee)) } } diff --git a/tests/changeless_waste.rs b/tests/changeless_waste.rs new file mode 100644 index 0000000..5481a8a --- /dev/null +++ b/tests/changeless_waste.rs @@ -0,0 +1,746 @@ +#![allow(unused_imports)] + +mod common; +use bdk_coin_select::metrics::ChangelessWaste; +use bdk_coin_select::{ + BnbMetric, Candidate, CoinSelector, Drain, DrainWeights, FeeRate, Replace, Target, TargetFee, + TargetOutputs, TX_FIXED_FIELD_WEIGHT, +}; +use proptest::prelude::*; + +proptest! { + #![proptest_config(ProptestConfig { + ..Default::default() + })] + + #[test] + #[cfg(not(debug_assertions))] // too slow if compiling for debug + fn can_eventually_find_best_solution( + n_candidates in 1..15_usize, + target_value in 500..500_000_u64, + n_target_outputs in 1usize..150, + target_weight in 0..10_000_u32, + replace in common::maybe_replace(0u64..10_000), + feerate in 1.0..100.0_f32, + feerate_lt_diff in -5.0..50.0_f32, + drain_weight in 100..=500_u32, + drain_spend_weight in 1..=2000_u32, + drain_dust in 100..=1000_u64, + n_drain_outputs in 1usize..150, + max_weight in common::maybe_max_weight(500u64..4_000), // optional max tx weight cap (wu) + absolute in 0u64..20_000, + ) { + let params = common::StrategyParams { n_candidates, target_value, n_target_outputs, target_weight, replace, feerate, feerate_lt_diff, drain_weight, drain_spend_weight, drain_dust, n_drain_outputs, max_weight, absolute }; + let candidates = common::gen_candidates(params.n_candidates); + let metric = ChangelessWaste { + long_term_feerate: params.long_term_feerate(), + dust_relay_feerate: params.dust_relay_feerate(), + drain_weights: params.drain_weights(), + }; + common::can_eventually_find_best_solution(params, candidates, metric)?; + } + + #[test] + #[cfg(not(debug_assertions))] // too slow if compiling for debug + fn ensure_bound_is_not_too_tight( + n_candidates in 0..12_usize, + target_value in 500..500_000_u64, + n_target_outputs in 1usize..150, + target_weight in 0..10_000_u32, + replace in common::maybe_replace(0u64..10_000), + feerate in 1.0..100.0_f32, + feerate_lt_diff in -5.0..50.0_f32, + drain_weight in 100..=500_u32, + drain_spend_weight in 1..=2000_u32, + drain_dust in 100..=1000_u64, + n_drain_outputs in 1usize..150, + max_weight in common::maybe_max_weight(500u64..4_000), // optional max tx weight cap (wu) + absolute in 0u64..20_000, + ) { + let params = common::StrategyParams { n_candidates, target_value, n_target_outputs, target_weight, replace, feerate, feerate_lt_diff, drain_weight, drain_spend_weight, drain_dust, n_drain_outputs, max_weight, absolute }; + let candidates = common::gen_candidates(params.n_candidates); + let metric = ChangelessWaste { + long_term_feerate: params.long_term_feerate(), + dust_relay_feerate: params.dust_relay_feerate(), + drain_weights: params.drain_weights(), + }; + common::ensure_bound_is_not_too_tight(params, candidates, metric)?; + } +} + +/// Regression for the review finding: with a binding ABSOLUTE fee (so `excess` is bound by +/// `absolute_excess`, not `rate_excess`) and `rate_diff < 0`, the `ub_changeless_input_weight` +/// knapsack must not credit rate-based `effective_value` against an absolute-driven delta — doing +/// so over-removes weight and yields a too-tight (invalid) upper bound. +/// +/// The candidates are deliberately HIGH-WEIGHT relative to their value (so +/// `effective_value(rate) ≪ value`, opening the gap the bug lives in) — the default random pool +/// never generates these, which is why the proptest missed it. Low feerate + high absolute makes +/// `absolute_excess` the binding constraint, and mixed values let a subset land `absolute_excess` +/// inside the narrow changeless-and-target-met window with high `input_weight`. +#[test] +fn bound_is_valid_with_binding_absolute_fee() { + // rate feerate = 1 sat/vb (0.25 sat/wu); ev(rate) = value - weight*0.25. + let mut candidates: Vec = Vec::new(); + for _ in 0..8 { + candidates.push(Candidate { + value: 5_000, + weight: 10_000, // ev(rate) = 5000 - 2500 = 2500 (half the value) + input_count: 1, + is_segwit: false, + }); + } + for _ in 0..2 { + candidates.push(Candidate { + value: 1_000, + weight: 2_000, // ev(rate) = 1000 - 500 = 500 + input_count: 1, + is_segwit: false, + }); + } + + let params = common::StrategyParams { + n_candidates: candidates.len(), + target_value: 5_000, + n_target_outputs: 1, + target_weight: 0, + replace: None, + feerate: 1.0, + feerate_lt_diff: 9.0, // long_term_feerate = 10 > feerate = 1 (rate_diff < 0) + drain_weight: 200, + drain_spend_weight: 600, + drain_dust: 200, + n_drain_outputs: 1, + max_weight: None, + absolute: 27_000, // > rate-implied fee at d_all, so absolute_excess binds + }; + let metric = ChangelessWaste { + long_term_feerate: params.long_term_feerate(), + dust_relay_feerate: params.dust_relay_feerate(), + drain_weights: params.drain_weights(), + }; + common::ensure_bound_is_not_too_tight(params, candidates, metric).unwrap(); +} + +fn target(value: u64, rate_sat_vb: f32) -> Target { + Target { + fee: TargetFee { + rate: FeeRate::from_sat_per_vb(rate_sat_vb), + absolute: 0, + replace: None, + }, + outputs: TargetOutputs { + value_sum: value, + weight_sum: 100, + n_outputs: 1, + }, + max_weight: None, + } +} + +const DRAIN_WEIGHTS: DrainWeights = DrainWeights { + output_weight: 100, + spend_weight: 600, + n_outputs: 1, +}; + +fn metric(long_term_sat_vb: f32) -> ChangelessWaste { + ChangelessWaste { + long_term_feerate: FeeRate::from_sat_per_vb(long_term_sat_vb), + dust_relay_feerate: FeeRate::from_sat_per_vb(1.0), + drain_weights: DRAIN_WEIGHTS, + } +} + +/// Regression (review finding #3): the rate_diff > 0 resize lower bound includes the greedy prefix's segwit +/// corrections (+2 witness header, +1 per legacy input), which an all-legacy descendant avoids. +#[test] +fn bound_is_valid_with_segwit_prefix_corrections() { + // feerate 100 sat/vb (25 spwu), long-term 1 sat/vb -> rate_diff = 24.75 spwu. + let rate = FeeRate::from_sat_per_vb(100.0); + let candidates = vec![ + // SW: same raw weight as L1/L2 but 10 more sats -> best value_pwu, walked first by the + // greedy prefix, which then carries +2 (header) +1 (L1's witness-length byte) = 3 wu of + // corrections that the all-legacy descendant {L1, L2} avoids. + Candidate { + value: 20_010, + weight: 400, + input_count: 1, + is_segwit: true, + }, + Candidate { + value: 20_000, + weight: 400, + input_count: 1, + is_segwit: false, + }, + Candidate { + value: 20_000, + weight: 400, + input_count: 1, + is_segwit: false, + }, + ]; + + let mut cs = CoinSelector::new(&candidates); + cs.sort_candidates_by_descending_value_pwu(); + + // D = {L1, L2}. + let mut d = cs.clone(); + for (idx, c) in cs.candidates().collect::>() { + if !c.is_segwit { + d.select(idx); + } + } + + // Pad the target's output weight so W_D % 4 == 0: fee(W_D) then has no vbyte rounding, and + // the prefix's +3 wu of corrections cross a vbyte boundary (so the greedy walk cannot stop + // at {SW, L1} and its lower bound carries the corrected prefix weight). + let mut t = target(0, 100.0); + let w_d_unpadded = d.weight(t.outputs, DrainWeights::NONE); + t.outputs.weight_sum += (4 - (w_d_unpadded % 4)) % 4; + + // T such that D's excess is exactly 2 sats. + let fee_d = rate.implied_fee(d.weight(t.outputs, DrainWeights::NONE)); + t.outputs.value_sum = (40_000_u64).checked_sub(fee_d + 2).expect("no underflow"); + + println!( + "W_D={} fee_D={} T={} excess(D)={}", + d.weight(t.outputs, DrainWeights::NONE), + fee_d, + t.outputs.value_sum, + d.excess(t, Drain::NONE) + ); + assert_eq!( + d.excess(t, Drain::NONE), + 2, + "construction: D overshoots by 2" + ); + assert!(d.is_funded(t), "D must be funded"); + + assert_bound_admissible(metric(1.0), &cs, &d, t); +} + +/// Regression (review finding #1): change_unavoidable's least-excess construction skips a candidate with small +/// positive *linear* effective value whose *actual* marginal excess is negative (segwit +/// corrections + vbyte rounding), so it prunes a branch with a changeless descendant. +#[test] +fn bound_does_not_prune_changeless_reachable_only_via_corrections() { + // feerate 100 sat/vb (25 spwu), long-term 1 sat/vb -> spend_cost = ceil(600 * 0.25) = 150. + let rate = FeeRate::from_sat_per_vb(100.0); + let candidates = vec![ + // A, B: selected, legacy. + Candidate { + value: 20_000, + weight: 40, + input_count: 1, + is_segwit: false, + }, + Candidate { + value: 20_000, + weight: 40, + input_count: 1, + is_segwit: false, + }, + // C: linear ev = 1050 - 40*25 = +50, but adding it to a 2-legacy tx also adds + // +2 (header) + 2 (two legacy +1s) = 4 wu of corrections = 100 sats at 25 spwu, + // so its actual marginal excess is negative. + Candidate { + value: 1_050, + weight: 40, + input_count: 1, + is_segwit: true, + }, + ]; + + let mut cs = CoinSelector::new(&candidates); + cs.sort_candidates_by_descending_value_pwu(); + + // cs = {A, B}; D = {A, B, C}. + let mut sel = cs.clone(); + for (idx, c) in cs.candidates().collect::>() { + if !c.is_segwit { + sel.select(idx); + } + } + let mut d = sel.clone(); + for (idx, c) in cs.candidates().collect::>() { + if c.is_segwit { + d.select(idx); + } + } + + // T such that excess_with_drain_weight(sel) = 175: just above spend_cost (=150) so `sel` + // "has change", while D's ~50 sat lower actual excess drops below it (changeless). + let m = metric(1.0); + let drain = Drain { + weights: DRAIN_WEIGHTS, + value: 0, + }; + let fee_sel_with_drain = rate.implied_fee(sel.weight(target(0, 100.0).outputs, DRAIN_WEIGHTS)); + let mut t = target(0, 100.0); + t.outputs.value_sum = 40_000 - fee_sel_with_drain - 175; + + // Load-bearing preconditions: `sel` must sit above the spend_cost edge (so by excess alone it + // "has change" and change_unavoidable reaches the least-excess comparison being regression + // tested), while D's actual excess must have dropped below it (changeless). + assert_eq!(sel.excess(t, drain), 175, "sel must be past the edge (150)"); + assert!( + d.excess(t, drain) <= 150, + "D must be changeless: ewd(D) = {}", + d.excess(t, drain) + ); + assert!(d.is_funded(t), "D must be funded"); + + assert_bound_admissible(m, &sel, &d, t); +} + +/// Regression (review finding #2): ub_changeless_input_weight's knapsack demands the full vbyte-rounded `delta` in +/// linear-ev terms, but a descendant can become changeless shedding up to ~sat_vb+1 less (vbyte +/// rounding), so the LP over-removes weight (through a heavy low-ev candidate) and the upper +/// bound goes invalid. All-legacy candidates: this is purely a rounding repro. +#[test] +fn bound_is_valid_under_vbyte_rounding_slack() { + // feerate 4 sat/vb (1 spwu), long-term 50 sat/vb -> rate_diff = -11.5 spwu. + // spend_cost = ceil(600 * 12.5) = 7500 -> changeless edge = 7500. + let rate = FeeRate::from_sat_per_vb(4.0); + let candidates = vec![ + // A: the selected branch. + Candidate { + value: 29_000, + weight: 400, + input_count: 1, + is_segwit: false, + }, + // P: linear ev = 1000 - 399 = 601; its ACTUAL removal sheds 604 (the 399 wu removal + // saves 99 vb = 396 sats when W_all is 4-aligned). + Candidate { + value: 1_000, + weight: 399, + input_count: 1, + is_segwit: false, + }, + // Q: linear ev = 2, raw 1400 — the LP's fractional over-removal runs through it. + Candidate { + value: 1_402, + weight: 1_400, + input_count: 1, + is_segwit: false, + }, + ]; + + let mut cs = CoinSelector::new(&candidates); + cs.sort_candidates_by_descending_value_pwu(); + + let by_value = |v: u64| { + cs.candidates() + .collect::>() + .iter() + .find(|(_, c)| c.value == v) + .unwrap() + .0 + }; + // cs = {A}; D = {A, Q} (sheds P only). + let mut sel = cs.clone(); + sel.select(by_value(29_000)); + let mut d = sel.clone(); + d.select(by_value(1_402)); + + let m = metric(50.0); + let drain = Drain { + weights: DRAIN_WEIGHTS, + value: 0, + }; + + // Pad outputs so W(d_all)+drain is 4-aligned: removing P's 399 wu then saves only 99 vb. + let mut d_all = cs.clone(); + d_all.select_all(); + let mut t = target(0, 4.0); + let w_all_wd = d_all.weight(t.outputs, DRAIN_WEIGHTS); + t.outputs.weight_sum += (4 - (w_all_wd % 4)) % 4; + + // T such that ewd(d_all) = edge + 603: delta = 603 > ev_lin(P) = 601, so the LP runs into Q + // and fractionally removes (603-601)/2 * 1400 = 1400 wu. But D sheds P's ACTUAL 604 and is + // already changeless at ewd = 7499, keeping Q's 1400 wu. + let fee_all_wd = rate.implied_fee(d_all.weight(t.outputs, DRAIN_WEIGHTS)); + t.outputs.value_sum = (29_000 + 1_000 + 1_402) - fee_all_wd - (7_500 + 603); + + // Load-bearing preconditions: delta = ewd(d_all) - edge must be 603 — above ev_lin(P) = 601 + // so the knapsack runs into Q — while D (which sheds only P) must have landed changeless, + // i.e. the vbyte rounding covered the 2-sat linear shortfall being regression tested. + assert_eq!( + d_all.excess(t, drain), + 7_500 + 603, + "delta must be 603 (> ev_lin(P) = 601)" + ); + assert!( + d.excess(t, drain) <= 7_500, + "D must be changeless: ewd(D) = {}", + d.excess(t, drain) + ); + assert!(d.is_funded(t), "D must be funded"); + assert!( + sel.excess(t, drain) <= 7_500, + "cs itself must be changeless so change_unavoidable does not prune" + ); + + assert_bound_admissible(m, &sel, &d, t); +} + +/// Regression (review round 2, finding #1): the rate_diff >= 0 bound computed its funding `gap` +/// from f32 conversions of full-magnitude sat values. f32's 24-bit mantissa is coarser than a sat +/// above ~0.17 BTC (32 sats at 5 BTC), so an exactly-funded selection could produce a phantom +/// positive gap; with only negative-ev candidates left, the phantom gap became an invalid `None` +/// prune of a branch whose selection is itself funded, changeless and scoreable. Funded-ness must +/// be decided by exact integer arithmetic and the walk must run in f64. +#[test] +fn bound_is_valid_at_btc_scale_values() { + // feerate 2 sat/vb (0.5 spwu), long-term 1 sat/vb -> rate_diff > 0. + let rate = FeeRate::from_sat_per_vb(2.0); + let candidates = vec![ + // A: selected; value/target tuned below so integer excess is exactly 0. + Candidate { + value: 500_000_400, // 5 BTC: f32 ulp here is 32 sats + weight: 272, + input_count: 1, + is_segwit: false, + }, + // B: negative ev, so the bound's knapsack walk finds nothing to cover a phantom gap. + Candidate { + value: 1, + weight: 272, + input_count: 1, + is_segwit: false, + }, + ]; + let cs = CoinSelector::new(&candidates); + let mut sel = cs.clone(); + sel.select(0); + + let mut t = target(0, 2.0); + t.outputs.value_sum = 500_000_176; + t.outputs.weight_sum = 136; // W = 448 (incl. output-count varint) -> fee = 224 = A.value - target.value + + // The two load-bearing preconditions: the selection is funded with integer excess exactly 0, + // while the f32 rendition of the linear gap is phantom-positive (+32: both big values sit on + // f32 round-to-even ties that resolve away from each other). + assert_eq!(sel.excess(t, Drain::NONE), 0); + let w = sel.weight(t.outputs, DrainWeights::NONE); + let f32_gap = t.outputs.value_sum as f32 + w as f32 * rate.spwu() - sel.selected_value() as f32; + assert!( + f32_gap > 0.0, + "construction: the f32 gap must be phantom-positive (got {})", + f32_gap + ); + + // The funded selection is its own descendant: it scores, so its bound must exist and admit it. + assert_bound_admissible(metric(1.0), &sel, &sel, t); +} + +/// If some descendant `d` of `cs` has a score, then `bound(cs)` must be `Some` and `<= score(d)`. +/// +/// This is stronger than `common::ensure_bound_is_not_too_tight`, which skips branches where +/// `bound` returns `None` and therefore cannot catch invalid prunes. +fn assert_bound_admissible( + mut metric: ChangelessWaste, + cs: &CoinSelector<'_>, + descendant: &CoinSelector<'_>, + target: Target, +) { + let score = metric + .score(descendant, target) + .expect("test construction broken: descendant must be scoreable"); + let bound = metric.bound(cs, target).unwrap_or_else(|| { + panic!( + "bound pruned the branch but a descendant scores {:?}", + score + ) + }); + assert!( + bound <= score, + "bound {:?} > descendant score {:?}", + bound, + score + ); +} + +/// `drain_value` must refuse change that would push the tx over `target.max_weight` (the same +/// rule as `LowestFee`), making such selections changeless and scoreable — and the +/// `change_unavoidable` prune must account for that: a branch may look change-forced by excess +/// alone while a heavier descendant is changeless because the cap refuses its change output. +#[test] +fn cap_refused_change_is_changeless_and_not_pruned() { + // feerate 10 sat/vb (2.5 spwu), long-term 1 sat/vb -> spend_cost = ceil(600 * 0.25) = 150. + let rate = FeeRate::from_sat_per_vb(10.0); + let drain_weights = DrainWeights { + output_weight: 100, + spend_weight: 600, + n_outputs: 1, + }; + let metric = ChangelessWaste { + long_term_feerate: FeeRate::from_sat_per_vb(1.0), + dust_relay_feerate: FeeRate::from_sat_per_vb(1.0), + drain_weights, + }; + + let candidates = vec![ + Candidate { + value: 10_000, + weight: 400, + input_count: 1, + is_segwit: false, + }, + Candidate { + value: 10_000, + weight: 400, + input_count: 1, + is_segwit: false, + }, + ]; + let cs = CoinSelector::new(&candidates); + + // cs = {A}; s = {A, B}. + let mut sel = cs.clone(); + sel.select(0); + let mut s = sel.clone(); + s.select(1); + + let mut target = Target { + fee: TargetFee { + rate, + absolute: 0, + replace: None, + }, + outputs: TargetOutputs { + value_sum: 0, + weight_sum: 100, + n_outputs: 1, + }, + max_weight: None, + }; + // T such that excess({A}) = 500: its excess-with-drain (250 lower) sits above spend_cost, + // so by excess alone every superset of {A} "has change"... + target.outputs.value_sum = + 10_000 - rate.implied_fee(sel.weight(target.outputs, DrainWeights::NONE)) - 500; + // ...but the cap admits {A, B} only WITHOUT its change output: change is refused, so + // {A, B} is changeless and scoreable. + target.max_weight = Some(s.weight(target.outputs, DrainWeights::NONE)); + + let mut m = metric; + assert!( + m.score(&s, target).is_some(), + "cap-refused change must make the selection changeless and scoreable" + ); + assert_bound_admissible(metric, &sel, &s, target); +} + +/// Under a cap that refuses change, a changeless descendant can keep ALL its excess-contributing +/// candidates, so the `rate_diff < 0` knapsack (which assumes changeless descendants must shed +/// them) must fall back to a bound that covers the full selection. +#[test] +fn bound_is_valid_when_cap_refuses_change_rate_diff_neg() { + // feerate 1 sat/vb (0.25 spwu), long-term 5 sat/vb -> rate_diff = -1 spwu. + // spend_cost = ceil(600 * 1.25) = 750 -> changeless edge = 750. + let rate = FeeRate::from_sat_per_vb(1.0); + let drain_weights = DrainWeights { + output_weight: 100, + spend_weight: 600, + n_outputs: 1, + }; + let metric = ChangelessWaste { + long_term_feerate: FeeRate::from_sat_per_vb(5.0), + dust_relay_feerate: FeeRate::from_sat_per_vb(1.0), + drain_weights, + }; + + let candidates = vec![ + // A: the selected branch. + Candidate { + value: 20_000, + weight: 400, + input_count: 1, + is_segwit: false, + }, + // P: linear ev = 525 - 1500*0.25 = 150; heavy relative to its ev, so the knapsack + // removes a lot of weight through it. + Candidate { + value: 525, + weight: 1_500, + input_count: 1, + is_segwit: false, + }, + ]; + let cs = CoinSelector::new(&candidates); + + let mut sel = cs.clone(); + sel.select(0); + let mut d_all = sel.clone(); + d_all.select(1); + + let mut target = Target { + fee: TargetFee { + rate, + absolute: 0, + replace: None, + }, + outputs: TargetOutputs { + value_sum: 0, + weight_sum: 100, + n_outputs: 1, + }, + max_weight: None, + }; + // T such that excess_with_drain(d_all) = 840 — above the 750 edge, so by excess alone + // {A, P} "has change" and the knapsack believes changeless descendants must shed P... + let drain = Drain { + weights: drain_weights, + value: 0, + }; + target.outputs.value_sum = + 20_525 - rate.implied_fee(d_all.weight(target.outputs, drain_weights)) - 840; + // ...but the cap refuses {A, P}'s change output, so {A, P} itself is changeless with its + // full input weight intact. + target.max_weight = Some(d_all.weight(target.outputs, DrainWeights::NONE)); + + assert!( + d_all.excess(target, drain) > 750, + "d_all must look change-worthy by excess alone" + ); + assert_bound_admissible(metric, &sel, &d_all, target); +} + +/// Sanity-check: the BnB solution must never have a change output, and its waste must be +/// no greater than the waste of any changeless brute-force selection we try. +#[test] +fn solution_is_changeless_and_not_worse_than_naive() { + let params = common::StrategyParams { + n_candidates: 12, + target_value: 90_000, + n_target_outputs: 1, + target_weight: 200 - TX_FIXED_FIELD_WEIGHT as u32 - 1, + replace: None, + feerate: 10.0, + feerate_lt_diff: 2.0, // long_term_feerate < feerate (rate_diff > 0) + drain_weight: 200, + drain_spend_weight: 600, + drain_dust: 200, + n_drain_outputs: 1, + max_weight: None, + absolute: 0, + }; + + let candidates = common::gen_candidates(params.n_candidates); + let mut cs = CoinSelector::new(&candidates); + + let mut metric = ChangelessWaste { + long_term_feerate: params.long_term_feerate(), + dust_relay_feerate: params.dust_relay_feerate(), + drain_weights: params.drain_weights(), + }; + + match common::bnb_search(&mut cs, params.target(), metric, usize::MAX) { + Ok((_score, _rounds)) => { + // A scored solution is changeless by construction: `score` returns `None` for any + // selection the metric would give a change output, so a returned solution is one the + // metric was able to score. + assert!( + metric.score(&cs, params.target()).is_some(), + "BnB result must be changeless and meet the target" + ); + assert!(cs.is_funded(params.target())); + } + Err(_) => { + // No changeless solution exists for this combo — that's allowed. + } + } +} + +/// When `rate_diff < 0`, the metric will tend to consolidate (add inputs to reduce input_waste), +/// but only as long as it can keep the selection changeless. +#[test] +fn consolidation_regime_stays_changeless() { + let params = common::StrategyParams { + n_candidates: 10, + target_value: 50_000, + n_target_outputs: 1, + target_weight: 200 - TX_FIXED_FIELD_WEIGHT as u32 - 1, + replace: None, + feerate: 2.0, + feerate_lt_diff: 10.0, // long_term_feerate > feerate (rate_diff < 0) + drain_weight: 200, + drain_spend_weight: 600, + drain_dust: 200, + n_drain_outputs: 1, + max_weight: None, + absolute: 0, + }; + + let candidates = common::gen_candidates(params.n_candidates); + let mut cs = CoinSelector::new(&candidates); + + let mut metric = ChangelessWaste { + long_term_feerate: params.long_term_feerate(), + dust_relay_feerate: params.dust_relay_feerate(), + drain_weights: params.drain_weights(), + }; + + if common::bnb_search(&mut cs, params.target(), metric, usize::MAX).is_ok() { + assert!( + metric.score(&cs, params.target()).is_some(), + "result must be changeless" + ); + } +} + +/// A candidate pool with ~50% segwit and small values/weights, where the `input_weight()` +/// corrections and vbyte rounding are large relative to candidate effective values — the regime +/// the review findings live in (`gen_candidates` makes segwit candidates only 1% of the time). +#[cfg(not(debug_assertions))] // only used by the release-gated proptest below +fn small_candidate() -> impl Strategy { + (100..5_000_u64, 100..1_500_u64, prop::bool::ANY).prop_map(|(value, weight, is_segwit)| { + Candidate { + value, + weight, + input_count: 1, + is_segwit, + } + }) +} + +proptest! { + #![proptest_config(ProptestConfig { cases: 2048, ..Default::default() })] + + #[test] + #[cfg(not(debug_assertions))] + fn segwit_mixed_bound_is_not_too_tight( + candidates in prop::collection::vec(small_candidate(), 1..8), + target_value in 200..8_000_u64, + feerate in 1.0..100.0_f32, + feerate_lt_diff in -99.0..50.0_f32, + drain_weight in 100..=500_u32, + drain_spend_weight in 1..=2000_u32, + drain_dust in 100..=1000_u64, + ) { + let params = common::StrategyParams { + n_candidates: candidates.len(), + target_value, + n_target_outputs: 1, + target_weight: 100, + replace: None, + feerate, + feerate_lt_diff, + drain_weight, + drain_spend_weight, + drain_dust, + n_drain_outputs: 1, + max_weight: None, + absolute: 0, + }; + let metric = ChangelessWaste { + long_term_feerate: params.long_term_feerate(), + dust_relay_feerate: params.dust_relay_feerate(), + drain_weights: params.drain_weights(), + }; + common::ensure_bound_is_not_too_tight(params, candidates, metric)?; + } +} diff --git a/tests/common.rs b/tests/common.rs index 273b830..cc4ba8b 100644 --- a/tests/common.rs +++ b/tests/common.rs @@ -216,6 +216,7 @@ pub struct StrategyParams { pub drain_dust: u64, pub n_drain_outputs: usize, pub max_weight: Option, + pub absolute: u64, } impl StrategyParams { @@ -224,7 +225,7 @@ impl StrategyParams { fee: TargetFee { rate: FeeRate::from_sat_per_vb(self.feerate), replace: self.replace, - ..TargetFee::ZERO + absolute: self.absolute, }, outputs: TargetOutputs { value_sum: self.target_value, diff --git a/tests/lowest_fee.rs b/tests/lowest_fee.rs index d6b8cba..f7d9a9c 100644 --- a/tests/lowest_fee.rs +++ b/tests/lowest_fee.rs @@ -29,7 +29,7 @@ proptest! { n_drain_outputs in 1usize..150, // the number of drain outputs max_weight in common::maybe_max_weight(500u64..4_000), // optional max tx weight cap (wu) ) { - let params = common::StrategyParams { n_candidates, target_value, n_target_outputs, target_weight, replace, feerate, feerate_lt_diff, drain_weight, drain_spend_weight, drain_dust, n_drain_outputs , max_weight }; + let params = common::StrategyParams { n_candidates, target_value, n_target_outputs, target_weight, replace, feerate, feerate_lt_diff, drain_weight, drain_spend_weight, drain_dust, n_drain_outputs , max_weight, absolute: 0 }; let candidates = common::gen_candidates(params.n_candidates); let metric = params.lowest_fee_metric(); common::can_eventually_find_best_solution(params, candidates, metric)?; @@ -51,7 +51,7 @@ proptest! { n_drain_outputs in 1usize..150, // the number of drain outputs max_weight in common::maybe_max_weight(500u64..4_000), // optional max tx weight cap (wu) ) { - let params = common::StrategyParams { n_candidates, target_value, n_target_outputs, target_weight, replace, feerate, feerate_lt_diff, drain_weight, drain_spend_weight, drain_dust, n_drain_outputs , max_weight }; + let params = common::StrategyParams { n_candidates, target_value, n_target_outputs, target_weight, replace, feerate, feerate_lt_diff, drain_weight, drain_spend_weight, drain_dust, n_drain_outputs , max_weight, absolute: 0 }; let candidates = common::gen_candidates(params.n_candidates); let metric = params.lowest_fee_metric(); common::ensure_bound_is_not_too_tight(params, candidates, metric)?; @@ -78,7 +78,7 @@ proptest! { ) { println!("== TEST =="); - let params = common::StrategyParams { n_candidates, target_value, n_target_outputs, target_weight, replace, feerate, feerate_lt_diff, drain_weight, drain_spend_weight, drain_dust, n_drain_outputs, max_weight: None }; + let params = common::StrategyParams { n_candidates, target_value, n_target_outputs, target_weight, replace, feerate, feerate_lt_diff, drain_weight, drain_spend_weight, drain_dust, n_drain_outputs, max_weight: None, absolute: 0 }; println!("{:?}", params); let candidates = vec![ @@ -124,7 +124,7 @@ proptest! { max_weight in common::maybe_max_weight(500u64..4_000), // optional max tx weight cap (wu) ) { - let params = common::StrategyParams { n_candidates, target_value, n_target_outputs, target_weight, replace, feerate, feerate_lt_diff, drain_weight, drain_spend_weight, drain_dust, n_drain_outputs , max_weight }; + let params = common::StrategyParams { n_candidates, target_value, n_target_outputs, target_weight, replace, feerate, feerate_lt_diff, drain_weight, drain_spend_weight, drain_dust, n_drain_outputs , max_weight, absolute: 0 }; let candidates = common::gen_candidates(params.n_candidates); let metric = params.lowest_fee_metric(); common::compare_against_benchmarks(params, candidates, metric)?; @@ -157,7 +157,7 @@ proptest! { n_drain_outputs in 1usize..150, max_weight in common::maybe_max_weight(500u64..4_000), // TRUC-tight -> binds often, small DP ) { - let params = common::StrategyParams { n_candidates, target_value, n_target_outputs, target_weight, replace, feerate, feerate_lt_diff, drain_weight, drain_spend_weight, drain_dust, n_drain_outputs, max_weight }; + let params = common::StrategyParams { n_candidates, target_value, n_target_outputs, target_weight, replace, feerate, feerate_lt_diff, drain_weight, drain_spend_weight, drain_dust, n_drain_outputs, max_weight, absolute: 0 }; let candidates = common::gen_candidates(params.n_candidates); let target = params.target(); let metric = params.lowest_fee_metric(); @@ -192,6 +192,7 @@ fn combined_changeless_metric() { n_target_outputs: 1, n_drain_outputs: 1, max_weight: None, + absolute: 0, }; let candidates = common::gen_candidates(params.n_candidates);