Skip to content

Commit

Permalink
Fix weight calculations for mixed legacy and segwit
Browse files Browse the repository at this point in the history
see:
#924 (comment)

Was a PITA since branch and bound is hard to do with this interference
between segiwt and legacy weights. It would find solutions that looked
good until you add the final input which was segwit and then the
solution would be suboptimal and fail the test.
  • Loading branch information
LLFourn authored and evanlinjin committed Aug 9, 2023
1 parent 30c6469 commit df70b71
Show file tree
Hide file tree
Showing 9 changed files with 247 additions and 57 deletions.
20 changes: 10 additions & 10 deletions crates/bdk/src/wallet/coin_selection.rs
Original file line number Diff line number Diff line change
Expand Up @@ -836,7 +836,7 @@ mod test {
let drain_script = ScriptBuf::default();
let target_amount = 250_000 + FEE_AMOUNT;

let result = LargestFirstCoinSelection::default()
let result = LargestFirstCoinSelection
.coin_select(
utxos,
vec![],
Expand All @@ -857,7 +857,7 @@ mod test {
let drain_script = ScriptBuf::default();
let target_amount = 20_000 + FEE_AMOUNT;

let result = LargestFirstCoinSelection::default()
let result = LargestFirstCoinSelection
.coin_select(
utxos,
vec![],
Expand All @@ -878,7 +878,7 @@ mod test {
let drain_script = ScriptBuf::default();
let target_amount = 20_000 + FEE_AMOUNT;

let result = LargestFirstCoinSelection::default()
let result = LargestFirstCoinSelection
.coin_select(
vec![],
utxos,
Expand All @@ -900,7 +900,7 @@ mod test {
let drain_script = ScriptBuf::default();
let target_amount = 500_000 + FEE_AMOUNT;

LargestFirstCoinSelection::default()
LargestFirstCoinSelection
.coin_select(
vec![],
utxos,
Expand All @@ -918,7 +918,7 @@ mod test {
let drain_script = ScriptBuf::default();
let target_amount = 250_000 + FEE_AMOUNT;

LargestFirstCoinSelection::default()
LargestFirstCoinSelection
.coin_select(
vec![],
utxos,
Expand All @@ -935,7 +935,7 @@ mod test {
let drain_script = ScriptBuf::default();
let target_amount = 180_000 + FEE_AMOUNT;

let result = OldestFirstCoinSelection::default()
let result = OldestFirstCoinSelection
.coin_select(
vec![],
utxos,
Expand All @@ -956,7 +956,7 @@ mod test {
let drain_script = ScriptBuf::default();
let target_amount = 20_000 + FEE_AMOUNT;

let result = OldestFirstCoinSelection::default()
let result = OldestFirstCoinSelection
.coin_select(
utxos,
vec![],
Expand All @@ -977,7 +977,7 @@ mod test {
let drain_script = ScriptBuf::default();
let target_amount = 20_000 + FEE_AMOUNT;

let result = OldestFirstCoinSelection::default()
let result = OldestFirstCoinSelection
.coin_select(
vec![],
utxos,
Expand All @@ -999,7 +999,7 @@ mod test {
let drain_script = ScriptBuf::default();
let target_amount = 600_000 + FEE_AMOUNT;

OldestFirstCoinSelection::default()
OldestFirstCoinSelection
.coin_select(
vec![],
utxos,
Expand All @@ -1018,7 +1018,7 @@ mod test {
let target_amount: u64 = utxos.iter().map(|wu| wu.utxo.txout().value).sum::<u64>() - 50;
let drain_script = ScriptBuf::default();

OldestFirstCoinSelection::default()
OldestFirstCoinSelection
.coin_select(
vec![],
utxos,
Expand Down
15 changes: 6 additions & 9 deletions nursery/coin_select/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,8 @@ use bdk_coin_select::{CoinSelector, Candidate, TXIN_BASE_WEIGHT};
use bitcoin::{ Transaction, TxIn };

// You should use miniscript to figure out the satisfaction weight for your coins!
const tr_satisfaction_weight: u32 = 66;
const tr_input_weight: u32 = txin_base_weight + tr_satisfaction_weight;
const TR_SATISFACTION_WEIGHT: u32 = 66;
const TR_INPUT_WEIGHT: u32 = TXIN_BASE_WEIGHT + TR_SATISFACTION_WEIGHT;


let candidates = vec![
Expand All @@ -21,17 +21,17 @@ let candidates = vec![
input_count: 1,
// the value of the input
value: 1_000_000,
// the total weight of the input(s). This doesn't include
// the total weight of the input(s). This doesn't include
weight: TR_INPUT_WEIGHT,
// wether it's a segwit input. Needed so we know whether to include the segwit header
// in total weight calculations.
is_segwit: true
},
Candidate {
// A candidate can represent multiple inputs in the case where you always want some inputs
// A candidate can represent multiple inputs in the case where you always want some inputs
// to be spent together.
input_count: 2,
weight: 2*tr_input_weight,
weight: 2*TR_INPUT_WEIGHT,
value: 3_000_000,
is_segwit: true
},
Expand All @@ -50,10 +50,7 @@ let base_weight = Transaction {
version: 1,
}.weight().to_wu() as u32;

panic!("{}", base_weight);
println!("base weight: {}", base_weight);

let mut coin_selector = CoinSelector::new(&candidates,base_weight);


```

43 changes: 22 additions & 21 deletions nursery/coin_select/src/coin_selector.rs
Original file line number Diff line number Diff line change
Expand Up @@ -167,28 +167,31 @@ impl<'a> CoinSelector<'a> {
pub fn is_empty(&self) -> bool {
self.selected.is_empty()
}
/// Weight sum of all selected inputs.
pub fn selected_weight(&self) -> u32 {
self.selected
.iter()
.map(|&index| self.candidates[index].weight)
.sum()
}

/// The weight of the inputs including the witness header and the varint for the number of
/// inputs.
fn input_weight(&self) -> u32 {
let witness_header_extra_weight = self
.selected()
.find(|(_, wv)| wv.is_segwit)
.map(|_| 2)
.unwrap_or(0);
pub fn input_weight(&self) -> u32 {
let is_segwit_tx = self.selected().any(|(_, wv)| wv.is_segwit);
let witness_header_extra_weight = is_segwit_tx as u32 * 2;
let vin_count_varint_extra_weight = {
let input_count = self.selected().map(|(_, wv)| wv.input_count).sum::<usize>();
(varint_size(input_count) - 1) * 4
};

self.selected_weight() + witness_header_extra_weight + vin_count_varint_extra_weight
let selected_weight: u32 = self
.selected()
.map(|(_, candidate)| {
let mut weight = candidate.weight;
if is_segwit_tx && !candidate.is_segwit {
// non-segwit candidates do not have the witness length field included in their
// weight field so we need to add 1 here if it's in a segwit tx.
weight += 1;
}
weight
})
.sum();

selected_weight + witness_header_extra_weight + vin_count_varint_extra_weight
}

/// Absolute value sum of all selected inputs.
Expand All @@ -202,8 +205,6 @@ impl<'a> CoinSelector<'a> {
/// Current weight of template tx + selected inputs.
pub fn weight(&self, drain_weight: u32) -> u32 {
// TODO take into account whether drain tips over varint for number of outputs
//
// TODO: take into account the witness stack length for each input
self.base_weight + self.input_weight() + drain_weight
}

Expand Down Expand Up @@ -235,8 +236,8 @@ impl<'a> CoinSelector<'a> {
- target.min_fee as i64
}

/// The feerate the transaction would have if we were to use this selection of inputs to achieve
/// the ???
/// The feerate the transaction would have if we were to use this selection of inputs to acheive
/// the `target_value`
pub fn implied_feerate(&self, target_value: u64, drain: Drain) -> FeeRate {
let numerator = self.selected_value() as i64 - target_value as i64 - drain.value as i64;
let denom = self.weight(drain.weight);
Expand All @@ -258,8 +259,8 @@ impl<'a> CoinSelector<'a> {
}

// /// Waste sum of all selected inputs.
fn selected_waste(&self, feerate: FeeRate, long_term_feerate: FeeRate) -> f32 {
self.selected_weight() as f32 * (feerate.spwu() - long_term_feerate.spwu())
fn input_waste(&self, feerate: FeeRate, long_term_feerate: FeeRate) -> f32 {
self.input_weight() as f32 * (feerate.spwu() - long_term_feerate.spwu())
}

/// Sorts the candidates by the comparision function.
Expand Down Expand Up @@ -315,7 +316,7 @@ impl<'a> CoinSelector<'a> {
excess_discount: f32,
) -> f32 {
debug_assert!((0.0..=1.0).contains(&excess_discount));
let mut waste = self.selected_waste(target.feerate, long_term_feerate);
let mut waste = self.input_waste(target.feerate, long_term_feerate);

if drain.is_none() {
// We don't allow negative excess waste since negative excess just means you haven't
Expand Down
3 changes: 2 additions & 1 deletion nursery/coin_select/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,8 @@ pub mod change_policy;
/// length.
pub const TXIN_BASE_WEIGHT: u32 = (32 + 4 + 4 + 1) * 4;

/// The weight of a TXOUT with a zero length `scriptPubkey`
/// The weight of a TXOUT with a zero length `scriptPubKey`
#[allow(clippy::identity_op)]
pub const TXOUT_BASE_WEIGHT: u32 =
// The value
4 * core::mem::size_of::<u64>() as u32
Expand Down
2 changes: 1 addition & 1 deletion nursery/coin_select/src/metrics/waste.rs
Original file line number Diff line number Diff line change
Expand Up @@ -154,7 +154,7 @@ where
debug_assert!(weight_to_satisfy <= to_slurp.weight as f32);
weight_to_satisfy
};
let weight_lower_bound = cs.selected_weight() as f32 + ideal_next_weight;
let weight_lower_bound = cs.input_weight() as f32 + ideal_next_weight;
let mut waste = weight_lower_bound * rate_diff;
waste += change_lower_bound.waste(self.target.feerate, self.long_term_feerate);

Expand Down
47 changes: 34 additions & 13 deletions nursery/coin_select/tests/bnb.rs
Original file line number Diff line number Diff line change
Expand Up @@ -12,12 +12,17 @@ use rand::{Rng, RngCore};
fn test_wv(mut rng: impl RngCore) -> impl Iterator<Item = Candidate> {
core::iter::repeat_with(move || {
let value = rng.gen_range(0..1_000);
Candidate {
let mut candidate = Candidate {
value,
weight: 100,
input_count: rng.gen_range(1..2),
is_segwit: rng.gen_bool(0.5),
}
};
// HACK: set is_segwit = true for all these tests because you can't actually lower bound
// things easily with how segwit inputs interfere with their weights. We can't modify the
// above since that would change what we pull from rng.
candidate.is_segwit = true;
candidate
})
}

Expand All @@ -28,21 +33,21 @@ struct MinExcessThenWeight {
impl BnBMetric for MinExcessThenWeight {
type Score = (i64, u32);

fn score<'a>(&mut self, cs: &CoinSelector<'a>) -> Option<Self::Score> {
fn score(&mut self, cs: &CoinSelector<'_>) -> Option<Self::Score> {
if cs.excess(self.target, Drain::none()) < 0 {
None
} else {
Some((cs.excess(self.target, Drain::none()), cs.selected_weight()))
Some((cs.excess(self.target, Drain::none()), cs.input_weight()))
}
}

fn bound<'a>(&mut self, cs: &CoinSelector<'a>) -> Option<Self::Score> {
fn bound(&mut self, cs: &CoinSelector<'_>) -> Option<Self::Score> {
let lower_bound_excess = cs.excess(self.target, Drain::none()).max(0);
let lower_bound_weight = {
let mut cs = cs.clone();
cs.select_until_target_met(self.target, Drain::none())
.ok()?;
cs.selected_weight()
cs.input_weight()
};
Some((lower_bound_excess, lower_bound_weight))
}
Expand All @@ -56,13 +61,21 @@ fn bnb_finds_an_exact_solution_in_n_iter() {
let num_additional_canidates = 50;

let mut rng = TestRng::deterministic_rng(RngAlgorithm::ChaCha);
let mut wv = test_wv(&mut rng);
let mut wv = test_wv(&mut rng).map(|mut candidate| {
candidate.is_segwit = true;
candidate
});

let solution: Vec<Candidate> = (0..solution_len).map(|_| wv.next().unwrap()).collect();
let solution_weight = solution.iter().map(|sol| sol.weight).sum();
let solution_weight = {
let mut cs = CoinSelector::new(&solution, 0);
cs.select_all();
cs.input_weight()
};

let target = solution.iter().map(|c| c.value).sum();

let mut candidates = solution.clone();
let mut candidates = solution;
candidates.extend(wv.take(num_additional_canidates));
candidates.sort_unstable_by_key(|wv| core::cmp::Reverse(wv.value));

Expand All @@ -86,7 +99,7 @@ fn bnb_finds_an_exact_solution_in_n_iter() {

assert_eq!(i, 806);

assert!(best.selected_weight() <= solution_weight);
assert!(best.input_weight() <= solution_weight);
assert_eq!(best.selected_value(), target.value);
}

Expand All @@ -97,6 +110,7 @@ fn bnb_finds_solution_if_possible_in_n_iter() {
let mut rng = TestRng::deterministic_rng(RngAlgorithm::ChaCha);
let wv = test_wv(&mut rng);
let candidates = wv.take(num_inputs).collect::<Vec<_>>();

let cs = CoinSelector::new(&candidates, 0);

let target = Target {
Expand Down Expand Up @@ -151,13 +165,20 @@ proptest! {
let mut wv = test_wv(&mut rng);

let solution: Vec<Candidate> = (0..solution_len).map(|_| wv.next().unwrap()).collect();
let solution_weight = {
let mut cs = CoinSelector::new(&solution, 0);
cs.select_all();
cs.input_weight()
};

let target = solution.iter().map(|c| c.value).sum();
let solution_weight = solution.iter().map(|sol| sol.weight).sum();

let mut candidates = solution.clone();
let mut candidates = solution;
candidates.extend(wv.take(num_additional_canidates));

let mut cs = CoinSelector::new(&candidates, 0);


for i in 0..num_preselected.min(solution_len) {
cs.select(i);
}
Expand All @@ -182,7 +203,7 @@ proptest! {



prop_assert!(best.selected_weight() <= solution_weight);
prop_assert!(best.input_weight() <= solution_weight);
prop_assert_eq!(best.selected_value(), target.value);
}
}
2 changes: 1 addition & 1 deletion nursery/coin_select/tests/changeless.rs
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ proptest! {
value: 0
};

let change_policy = crate::change_policy::min_waste(drain, long_term_feerate);
let change_policy = bdk_coin_select::change_policy::min_waste(drain, long_term_feerate);
let wv = test_wv(&mut rng);
let candidates = wv.take(num_inputs).collect::<Vec<_>>();

Expand Down
7 changes: 6 additions & 1 deletion nursery/coin_select/tests/waste.rs
Original file line number Diff line number Diff line change
Expand Up @@ -257,7 +257,12 @@ fn waste_low_but_non_negative_rate_diff_means_adding_more_inputs_might_reduce_ex

let change_policy = change_policy::min_waste(drain, long_term_feerate);
let wv = test_wv(&mut rng);
let candidates = wv.take(num_inputs).collect::<Vec<_>>();
let mut candidates = wv.take(num_inputs).collect::<Vec<_>>();
// HACK: for this test had to set segwit true to keep it working once we
// started properly accounting for legacy weight variations
candidates
.iter_mut()
.for_each(|candidate| candidate.is_segwit = true);

let cs = CoinSelector::new(&candidates, base_weight);

Expand Down
Loading

0 comments on commit df70b71

Please sign in to comment.