Skip to content

Commit dcaa14f

Browse files
committed
Fix lowest_fee metric
1 parent 0aef6ff commit dcaa14f

File tree

8 files changed

+107
-139
lines changed

8 files changed

+107
-139
lines changed

README.md

Lines changed: 11 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -235,20 +235,23 @@ let candidates = candidate_txouts
235235
.collect::<Vec<_>>();
236236

237237
let mut selector = CoinSelector::new(&candidates, base_weight);
238-
let _result = selector
239-
.select_until_target_met(target, Drain::none());
240-
241-
// Determine what the drain output will be, based on our selection.
242-
let drain = selector.drain(target, change_policy);
243-
244-
// In theory the target must always still be met at this point
245-
assert!(selector.is_target_met(target, drain));
238+
selector
239+
.select_until_target_met(target, Drain::none())
240+
.expect("we've got enough coins");
246241

247242
// Get a list of coins that are selected.
248243
let selected_coins = selector
249244
.apply_selection(&candidate_txouts)
250245
.collect::<Vec<_>>();
251246
assert_eq!(selected_coins.len(), 1);
247+
248+
// Determine whether we should add a change output.
249+
let drain = selector.drain(target, change_policy);
250+
251+
if drain.is_some() {
252+
// add our change outupt to the transaction
253+
let change_value = drain.value;
254+
}
252255
```
253256

254257
# Minimum Supported Rust Version (MSRV)

src/coin_selector.rs

Lines changed: 11 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -319,7 +319,9 @@ impl<'a> CoinSelector<'a> {
319319

320320
/// Sorts the candidates by descending value per weight unit, tie-breaking with value.
321321
pub fn sort_candidates_by_descending_value_pwu(&mut self) {
322-
self.sort_candidates_by_key(|(_, wv)| core::cmp::Reverse((wv.value_pwu(), wv.value)));
322+
self.sort_candidates_by_key(|(_, wv)| {
323+
core::cmp::Reverse((Ordf32(wv.value_pwu()), wv.value))
324+
});
323325
}
324326

325327
/// The waste created by the current selection as measured by the [waste metric].
@@ -487,7 +489,7 @@ impl<'a> CoinSelector<'a> {
487489
for cand_index in self.candidate_order.iter() {
488490
if self.selected.contains(cand_index)
489491
|| self.banned.contains(cand_index)
490-
|| self.candidates[*cand_index].effective_value(feerate) <= Ordf32(0.0)
492+
|| self.candidates[*cand_index].effective_value(feerate) <= 0.0
491493
{
492494
continue;
493495
}
@@ -632,13 +634,13 @@ impl Candidate {
632634
}
633635

634636
/// Effective value of this input candidate: `actual_value - input_weight * feerate (sats/wu)`.
635-
pub fn effective_value(&self, feerate: FeeRate) -> Ordf32 {
636-
Ordf32(self.value as f32 - (self.weight as f32 * feerate.spwu()))
637+
pub fn effective_value(&self, feerate: FeeRate) -> f32 {
638+
self.value as f32 - (self.weight as f32 * feerate.spwu())
637639
}
638640

639641
/// Value per weight unit
640-
pub fn value_pwu(&self) -> Ordf32 {
641-
Ordf32(self.value as f32 / self.weight as f32)
642+
pub fn value_pwu(&self) -> f32 {
643+
self.value as f32 / self.weight as f32
642644
}
643645
}
644646

@@ -669,11 +671,12 @@ impl DrainWeights {
669671
(self.spend_weight as f32 * long_term_feerate.spwu()).ceil() as u64
670672
}
671673

672-
/// Create [`DrainWeights`] that represents a drain output with a taproot keyspend.
674+
/// Create [`DrainWeights`] that represents a drain output that will be spent with a taproot
675+
/// keyspend
673676
pub fn new_tr_keyspend() -> Self {
674677
Self {
675678
output_weight: TXOUT_BASE_WEIGHT + TR_SPK_WEIGHT,
676-
spend_weight: TXIN_BASE_WEIGHT + TR_KEYSPEND_SATISFACTION_WEIGHT,
679+
spend_weight: TR_KEYSPEND_TXIN_WEIGHT,
677680
}
678681
}
679682
}

src/metrics.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ fn change_lower_bound(cs: &CoinSelector, target: Target, change_policy: ChangePo
2525
let mut least_excess = cs.clone();
2626
cs.unselected()
2727
.rev()
28-
.take_while(|(_, wv)| wv.effective_value(target.feerate) < Ordf32(0.0))
28+
.take_while(|(_, wv)| wv.effective_value(target.feerate) < 0.0)
2929
.for_each(|(index, _)| {
3030
least_excess.select(index);
3131
});

src/metrics/lowest_fee.rs

Lines changed: 77 additions & 113 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
use crate::{
2-
change_policy::ChangePolicy, float::Ordf32, metrics::change_lower_bound, BnbMetric, Candidate,
3-
CoinSelector, Drain, DrainWeights, FeeRate, Target,
2+
change_policy::ChangePolicy, float::Ordf32, BnbMetric, Candidate, CoinSelector, Drain, FeeRate,
3+
Target,
44
};
55

66
/// Metric that aims to minimize transaction fees. The future fee for spending the change output is
@@ -25,41 +25,14 @@ pub struct LowestFee {
2525
pub change_policy: ChangePolicy,
2626
}
2727

28-
impl LowestFee {
29-
fn calc_metric(&self, cs: &CoinSelector<'_>, drain_weights: Option<DrainWeights>) -> f32 {
30-
self.calc_metric_lb(cs, drain_weights)
31-
+ match drain_weights {
32-
Some(_) => {
33-
let selected_value = cs.selected_value();
34-
assert!(selected_value >= self.target.value);
35-
(cs.selected_value() - self.target.value) as f32
36-
}
37-
None => 0.0,
38-
}
39-
}
40-
41-
fn calc_metric_lb(&self, cs: &CoinSelector<'_>, drain_weights: Option<DrainWeights>) -> f32 {
42-
match drain_weights {
43-
// with change
44-
Some(drain_weights) => {
45-
(cs.input_weight() + drain_weights.output_weight) as f32
46-
* self.target.feerate.spwu()
47-
+ drain_weights.spend_weight as f32 * self.long_term_feerate.spwu()
48-
}
49-
// changeless
50-
None => cs.input_weight() as f32 * self.target.feerate.spwu(),
51-
}
52-
}
53-
}
54-
5528
impl BnbMetric for LowestFee {
5629
fn score(&mut self, cs: &CoinSelector<'_>) -> Option<Ordf32> {
57-
let drain = cs.drain(self.target, self.change_policy);
58-
if !cs.is_target_met_with_drain(self.target, drain) {
30+
if !cs.is_target_met(self.target) {
5931
return None;
6032
}
6133

6234
let long_term_fee = {
35+
let drain = cs.drain(self.target, self.change_policy);
6336
let fee_for_the_tx = cs.fee(self.target.value, drain.value);
6437
assert!(
6538
fee_for_the_tx > 0,
@@ -75,98 +48,89 @@ impl BnbMetric for LowestFee {
7548
}
7649

7750
fn bound(&mut self, cs: &CoinSelector<'_>) -> Option<Ordf32> {
78-
// this either returns:
79-
// * None: change output may or may not exist
80-
// * Some: change output must exist from this branch onwards
81-
let change_lb = change_lower_bound(cs, self.target, self.change_policy);
82-
let change_lb_weights = if change_lb.is_some() {
83-
Some(change_lb.weights)
84-
} else {
85-
None
86-
};
87-
// println!("\tchange lb: {:?}", change_lb_weights);
88-
89-
if cs.is_target_met_with_drain(self.target, change_lb) {
90-
// Target is met, is it possible to add further inputs to remove drain output?
91-
// If we do, can we get a better score?
92-
93-
// First lower bound candidate is just the selection itself (include excess).
94-
let mut lower_bound = self.calc_metric(cs, change_lb_weights);
95-
96-
if change_lb_weights.is_none() {
97-
// Since a changeless solution may exist, we should try minimize the excess with by
98-
// adding as much -ev candidates as possible
99-
let selection_with_as_much_negative_ev_as_possible = cs
100-
.clone()
101-
.select_iter()
102-
.rev()
103-
.take_while(|(cs, _, candidate)| {
104-
candidate.effective_value(self.target.feerate).0 < 0.0
105-
&& cs.is_target_met_with_drain(self.target, Drain::none())
106-
})
107-
.last()
108-
.map(|(cs, _, _)| cs);
109-
110-
if let Some(cs) = selection_with_as_much_negative_ev_as_possible {
111-
// we have selected as much "real" inputs as possible, is it possible to select
112-
// one more with the perfect weight?
113-
let can_do_better_by_slurping =
114-
cs.unselected().next_back().and_then(|(_, candidate)| {
115-
if candidate.effective_value(self.target.feerate).0 < 0.0 {
116-
Some(candidate)
117-
} else {
118-
None
119-
}
120-
});
121-
let lower_bound_changeless = match can_do_better_by_slurping {
122-
Some(finishing_input) => {
123-
let excess = cs.rate_excess(self.target, Drain::none());
124-
125-
// change the input's weight to make it's effective value match the excess
126-
let perfect_input_weight = slurp(self.target, excess, finishing_input);
127-
128-
(cs.input_weight() as f32 + perfect_input_weight)
129-
* self.target.feerate.spwu()
51+
if cs.is_target_met(self.target) {
52+
let current_score = self.score(cs).unwrap();
53+
54+
let drain_value = cs.drain_value(self.target, self.change_policy);
55+
56+
if let Some(drain_value) = drain_value {
57+
// it's possible that adding another input might reduce your long term if it gets
58+
// rid of an expensive change output. Our strategy is to take the lowest sat per
59+
// value candidate we have and use it as a benchmark. We imagine it has the perfect
60+
// value (but the same sats per weight unit) to get rid of the change output by
61+
// adding negative effective value (i.e. perfectly reducing excess to the point
62+
// where change wouldn't be added according to the policy).
63+
let amount_above_change_threshold = drain_value - self.change_policy.min_value;
64+
65+
if let Some((_, low_sats_per_wu_candidate)) = cs.unselected().next_back() {
66+
let ev = low_sats_per_wu_candidate.effective_value(self.target.feerate);
67+
if ev < 0.0 {
68+
// we can only reduce excess if ev is negative
69+
let value_per_negative_effective_value =
70+
low_sats_per_wu_candidate.value_pwu() / ev.abs();
71+
let extra_value_needed_to_get_rid_of_change = amount_above_change_threshold
72+
as f32
73+
* value_per_negative_effective_value;
74+
let cost_of_getting_rid_of_change =
75+
extra_value_needed_to_get_rid_of_change + drain_value as f32;
76+
let cost_of_change = self
77+
.change_policy
78+
.drain_weights
79+
.waste(self.target.feerate, self.long_term_feerate);
80+
let best_score_without_change = Ordf32(
81+
current_score.0 - cost_of_change + cost_of_getting_rid_of_change,
82+
);
83+
if best_score_without_change < current_score {
84+
return Some(best_score_without_change);
13085
}
131-
None => self.calc_metric(&cs, None),
132-
};
133-
134-
lower_bound = lower_bound.min(lower_bound_changeless)
86+
}
13587
}
13688
}
13789

138-
return Some(Ordf32(lower_bound));
90+
Some(current_score)
91+
} else {
92+
// Step 1: select everything up until the input that hits the target.
93+
let (mut cs, slurp_index, to_slurp) = cs
94+
.clone()
95+
.select_iter()
96+
.find(|(cs, _, _)| cs.is_target_met(self.target))?;
97+
98+
cs.deselect(slurp_index);
99+
100+
// Step 2: We pretend that the final input exactly cancels out the remaining excess
101+
// by taking whatever value we want from it but at the value per weight of the real
102+
// input.
103+
let ideal_next_weight = {
104+
let remaining_rate = cs.rate_excess(self.target, Drain::none());
105+
106+
slurp_wv(to_slurp, remaining_rate.min(0), self.target.feerate)
107+
};
108+
let input_weight_lower_bound = cs.input_weight() as f32 + ideal_next_weight;
109+
let ideal_fee_by_feerate =
110+
(cs.base_weight() as f32 + input_weight_lower_bound) * self.target.feerate.spwu();
111+
let ideal_fee = ideal_fee_by_feerate.max(self.target.min_fee as f32);
112+
113+
Some(Ordf32(ideal_fee))
139114
}
140-
141-
// target is not met yet
142-
// select until we just exceed target, then we slurp the last selection
143-
let (mut cs, slurp_index, candidate_to_slurp) = cs
144-
.clone()
145-
.select_iter()
146-
.find(|(cs, _, _)| cs.is_target_met_with_drain(self.target, change_lb))?;
147-
cs.deselect(slurp_index);
148-
149-
let mut lower_bound = self.calc_metric_lb(&cs, change_lb_weights);
150-
151-
// find the max excess we need to rid of
152-
let perfect_excess = i64::max(
153-
cs.rate_excess(self.target, Drain::none()),
154-
cs.absolute_excess(self.target, Drain::none()),
155-
);
156-
// use the highest excess to find "perfect candidate weight"
157-
let perfect_input_weight = slurp(self.target, perfect_excess, candidate_to_slurp);
158-
lower_bound += perfect_input_weight * self.target.feerate.spwu();
159-
160-
Some(Ordf32(lower_bound))
161115
}
162116

163117
fn requires_ordering_by_descending_value_pwu(&self) -> bool {
164118
true
165119
}
166120
}
167121

168-
fn slurp(target: Target, excess: i64, candidate: Candidate) -> f32 {
169-
let vpw = candidate.value_pwu().0;
170-
let perfect_weight = -excess as f32 / (vpw - target.feerate.spwu());
171-
perfect_weight.max(0.0)
122+
/// Returns the "perfect weight" for this candidate to slurp up a given value with `feerate` while
123+
/// not changing the candidate's value/weight ratio.
124+
///
125+
/// Used to pretend that a candidate had precisely `value_to_slurp` + fee needed to include it. It
126+
/// tells you how much weight such a perfect candidate would have if it had the same value per
127+
/// weight unit as `candidate`. This is useful for estimating a lower weight bound for a perfect
128+
/// match.
129+
fn slurp_wv(candidate: Candidate, value_to_slurp: i64, feerate: FeeRate) -> f32 {
130+
// the value per weight unit this candidate offers at feerate
131+
let value_per_wu = (candidate.value as f32 / candidate.weight as f32) - feerate.spwu();
132+
// return how much weight we need
133+
let weight_needed = value_to_slurp as f32 / value_per_wu;
134+
debug_assert!(weight_needed <= candidate.weight as f32);
135+
weight_needed.min(0.0)
172136
}

src/metrics/waste.rs

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -80,15 +80,15 @@ impl BnbMetric for Waste {
8080
.select_iter()
8181
.rev()
8282
.take_while(|(cs, _, wv)| {
83-
wv.effective_value(self.target.feerate) < Ordf32(0.0)
83+
wv.effective_value(self.target.feerate) < 0.0
8484
&& cs.is_target_met(self.target)
8585
})
8686
.last();
8787

8888
if let Some((cs, _, _)) = selection_with_as_much_negative_ev_as_possible {
8989
let can_do_better_by_slurping =
9090
cs.unselected().next_back().and_then(|(_, wv)| {
91-
if wv.effective_value(self.target.feerate).0 < 0.0 {
91+
if wv.effective_value(self.target.feerate) < 0.0 {
9292
Some(wv)
9393
} else {
9494
None
@@ -149,8 +149,7 @@ impl BnbMetric for Waste {
149149
let remaining_rate = cs.rate_excess(self.target, change_lower_bound);
150150
let remaining_abs = cs.absolute_excess(self.target, change_lower_bound);
151151

152-
let weight_to_satisfy_abs =
153-
remaining_abs.min(0) as f32 / to_slurp.value_pwu().0;
152+
let weight_to_satisfy_abs = remaining_abs.min(0) as f32 / to_slurp.value_pwu();
154153

155154
let weight_to_satisfy_rate =
156155
slurp_wv(to_slurp, remaining_rate.min(0), self.target.feerate);
@@ -201,7 +200,7 @@ impl BnbMetric for Waste {
201200
.select_iter()
202201
.rev()
203202
.take_while(|(cs, _, wv)| {
204-
wv.effective_value(self.target.feerate).0 < 0.0
203+
wv.effective_value(self.target.feerate) < 0.0
205204
|| cs.drain_value(self.target, self.change_policy).is_none()
206205
})
207206
.last();

tests/changeless.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -77,7 +77,7 @@ proptest! {
7777
let mut cmp_benchmarks = vec![
7878
{
7979
let mut naive_select = cs.clone();
80-
naive_select.sort_candidates_by_key(|(_, wv)| core::cmp::Reverse(wv.effective_value(target.feerate)));
80+
naive_select.sort_candidates_by_key(|(_, wv)| core::cmp::Reverse(Ordf32(wv.effective_value(target.feerate))));
8181
// we filter out failing onces below
8282
let _ = naive_select.select_until_target_met(target, Drain { weights: drain, value: 0 });
8383
naive_select

tests/lowest_fee.rs

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -158,6 +158,5 @@ fn combined_changeless_metric() {
158158
common::bnb_search(&mut cs_b, metric_combined, usize::MAX).expect("must find solution");
159159
println!("score={:?} rounds={}", combined_score, combined_rounds);
160160

161-
// [todo] shouldn't rounds be less since we are only considering changeless branches?
162-
assert!(combined_rounds <= rounds);
161+
assert!(combined_rounds >= rounds);
163162
}

tests/waste.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -116,7 +116,7 @@ fn waste_naive_effective_value_shouldnt_be_better() {
116116
.expect("should find solution");
117117

118118
let mut naive_select = cs.clone();
119-
naive_select.sort_candidates_by_key(|(_, wv)| core::cmp::Reverse(wv.value_pwu()));
119+
naive_select.sort_candidates_by_key(|(_, wv)| core::cmp::Reverse(Ordf32(wv.value_pwu())));
120120
// we filter out failing onces below
121121
let _ = naive_select.select_until_target_met(target, drain);
122122

0 commit comments

Comments
 (0)