diff --git a/Cargo.1.48.0.toml b/Cargo.1.48.0.toml new file mode 100644 index 000000000..922862700 --- /dev/null +++ b/Cargo.1.48.0.toml @@ -0,0 +1,4 @@ +[workspace] +members = [ + "nursery/coin_select" +] diff --git a/build-msrv-crates.sh b/build-msrv-crates.sh new file mode 100755 index 000000000..4402dd1ee --- /dev/null +++ b/build-msrv-crates.sh @@ -0,0 +1,16 @@ +#!/usr/bin/env sh +trap ' + signal=$?; + cleanup + exit $signal; +' INT + +cleanup() { + mv Cargo.tmp.toml Cargo.toml 2>/dev/null +} + +cp Cargo.toml Cargo.tmp.toml +cp Cargo.1.48.0.toml Cargo.toml +cat Cargo.toml +cargo build --release +cleanup diff --git a/crates/bdk/src/wallet/coin_selection.rs b/crates/bdk/src/wallet/coin_selection.rs index c3e84af2b..a0179d31b 100644 --- a/crates/bdk/src/wallet/coin_selection.rs +++ b/crates/bdk/src/wallet/coin_selection.rs @@ -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![], @@ -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![], @@ -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, @@ -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, @@ -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, @@ -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, @@ -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![], @@ -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, @@ -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, @@ -1018,7 +1018,7 @@ mod test { let target_amount: u64 = utxos.iter().map(|wu| wu.utxo.txout().value).sum::() - 50; let drain_script = ScriptBuf::default(); - OldestFirstCoinSelection::default() + OldestFirstCoinSelection .coin_select( vec![], utxos, diff --git a/example-crates/example_cli/src/lib.rs b/example-crates/example_cli/src/lib.rs index 9e572a892..9d8a58441 100644 --- a/example-crates/example_cli/src/lib.rs +++ b/example-crates/example_cli/src/lib.rs @@ -1,9 +1,9 @@ pub use anyhow; use anyhow::Context; -use bdk_coin_select::{coin_select_bnb, CoinSelector, CoinSelectorOpt, WeightedValue}; +use bdk_coin_select::{Candidate, CoinSelector}; use bdk_file_store::Store; use serde::{de::DeserializeOwned, Serialize}; -use std::{cmp::Reverse, collections::HashMap, path::PathBuf, sync::Mutex, time::Duration}; +use std::{cmp::Reverse, collections::HashMap, path::PathBuf, sync::Mutex}; use bdk_chain::{ bitcoin::{ @@ -17,7 +17,7 @@ use bdk_chain::{ descriptor::{DescriptorSecretKey, KeyMap}, Descriptor, DescriptorPublicKey, }, - Anchor, Append, ChainOracle, DescriptorExt, FullTxOut, Persist, PersistBackend, + Anchor, Append, ChainOracle, FullTxOut, Persist, PersistBackend, }; pub use bdk_file_store; pub use clap; @@ -208,39 +208,18 @@ where }; // TODO use planning module - let mut candidates = planned_utxos(graph, chain, &assets)?; - - // apply coin selection algorithm - match cs_algorithm { - CoinSelectionAlgo::LargestFirst => { - candidates.sort_by_key(|(_, utxo)| Reverse(utxo.txout.value)) - } - CoinSelectionAlgo::SmallestFirst => candidates.sort_by_key(|(_, utxo)| utxo.txout.value), - CoinSelectionAlgo::OldestFirst => { - candidates.sort_by_key(|(_, utxo)| utxo.chain_position.clone()) - } - CoinSelectionAlgo::NewestFirst => { - candidates.sort_by_key(|(_, utxo)| Reverse(utxo.chain_position.clone())) - } - CoinSelectionAlgo::BranchAndBound => {} - } - + let raw_candidates = planned_utxos(graph, chain, &assets)?; // turn the txos we chose into weight and value - let wv_candidates = candidates + let candidates = raw_candidates .iter() .map(|(plan, utxo)| { - WeightedValue::new( + Candidate::new( utxo.txout.value, plan.expected_weight() as _, plan.witness_version().is_some(), ) }) - .collect(); - - let mut outputs = vec![TxOut { - value, - script_pubkey: address.script_pubkey(), - }]; + .collect::>(); let internal_keychain = if graph.index.keychains().get(&Keychain::Internal).is_some() { Keychain::Internal @@ -253,7 +232,7 @@ where changeset.append(change_changeset); // Clone to drop the immutable reference. - let change_script = change_script.into(); + let change_script = change_script.to_owned(); let change_plan = bdk_tmp_plan::plan_satisfaction( &graph @@ -267,68 +246,103 @@ where ) .expect("failed to obtain change plan"); - let mut change_output = TxOut { - value: 0, - script_pubkey: change_script, + let mut transaction = Transaction { + version: 0x02, + // because the temporary planning module does not support timelocks, we can use the chain + // tip as the `lock_time` for anti-fee-sniping purposes + lock_time: chain + .get_chain_tip()? + .and_then(|block_id| absolute::LockTime::from_height(block_id.height).ok()) + .unwrap_or(absolute::LockTime::ZERO), + input: vec![], + output: vec![TxOut { + value, + script_pubkey: address.script_pubkey(), + }], }; - let cs_opts = CoinSelectorOpt { - target_feerate: 0.5, - min_drain_value: graph - .index - .keychains() - .get(&internal_keychain) - .expect("must exist") - .dust_value(), - ..CoinSelectorOpt::fund_outputs( - &outputs, - &change_output, - change_plan.expected_weight() as u32, - ) + let target = bdk_coin_select::Target { + feerate: bdk_coin_select::FeeRate::from_sat_per_vb(2.0), + min_fee: 0, + value: transaction.output.iter().map(|txo| txo.value).sum(), }; - // TODO: How can we make it easy to shuffle in order of inputs and outputs here? - // apply coin selection by saying we need to fund these outputs - let mut coin_selector = CoinSelector::new(&wv_candidates, &cs_opts); - - // just select coins in the order provided until we have enough - // only use the first result (least waste) - let selection = match cs_algorithm { + let drain_weights = bdk_coin_select::DrainWeights { + output_weight: { + // we calculate the weight difference of including the drain output in the base tx + // this method will detect varint size changes of txout count + let tx_weight = transaction.weight(); + let tx_weight_with_drain = { + let mut tx = transaction.clone(); + tx.output.push(TxOut { + script_pubkey: change_script.clone(), + ..Default::default() + }); + tx.weight() + }; + (tx_weight_with_drain - tx_weight).to_wu() as u32 - 1 + }, + spend_weight: change_plan.expected_weight() as u32, + }; + let long_term_feerate = bdk_coin_select::FeeRate::from_sat_per_vb(5.0); + let drain_policy = bdk_coin_select::change_policy::min_value_and_waste( + drain_weights, + change_script.dust_value().to_sat(), + long_term_feerate, + ); + + let mut selector = CoinSelector::new(&candidates, transaction.weight().to_wu() as u32); + match cs_algorithm { CoinSelectionAlgo::BranchAndBound => { - coin_select_bnb(Duration::from_secs(10), coin_selector.clone()) - .map_or_else(|| coin_selector.select_until_finished(), |cs| cs.finish())? + let metric = bdk_coin_select::metrics::Waste { + target, + long_term_feerate, + change_policy: &drain_policy, + }; + if let Err(bnb_err) = selector.run_bnb(metric, 100_000) { + selector.sort_candidates_by_descending_value_pwu(); + println!( + "Error: {} Falling back to select until target met.", + bnb_err + ); + }; } - _ => coin_selector.select_until_finished()?, + CoinSelectionAlgo::LargestFirst => { + selector.sort_candidates_by_key(|(_, c)| Reverse(c.value)) + } + CoinSelectionAlgo::SmallestFirst => selector.sort_candidates_by_key(|(_, c)| c.value), + CoinSelectionAlgo::OldestFirst => { + selector.sort_candidates_by_key(|(i, _)| raw_candidates[i].1.chain_position.clone()) + } + CoinSelectionAlgo::NewestFirst => selector + .sort_candidates_by_key(|(i, _)| Reverse(raw_candidates[i].1.chain_position.clone())), }; - let (_, selection_meta) = selection.best_strategy(); + + // ensure target is met + selector.select_until_target_met(target, drain_policy(&selector, target))?; // get the selected utxos - let selected_txos = selection.apply_selection(&candidates).collect::>(); + let selected_txos = selector + .apply_selection(&raw_candidates) + .collect::>(); - if let Some(drain_value) = selection_meta.drain_value { - change_output.value = drain_value; - // if the selection tells us to use change and the change value is sufficient, we add it as an output - outputs.push(change_output) + let drain = drain_policy(&selector, target); + if drain.is_some() { + transaction.output.push(TxOut { + value: drain.value, + script_pubkey: change_script, + }); } - let mut transaction = Transaction { - version: 0x02, - // because the temporary planning module does not support timelocks, we can use the chain - // tip as the `lock_time` for anti-fee-sniping purposes - lock_time: chain - .get_chain_tip()? - .and_then(|block_id| absolute::LockTime::from_height(block_id.height).ok()) - .unwrap_or(absolute::LockTime::ZERO), - input: selected_txos - .iter() - .map(|(_, utxo)| TxIn { - previous_output: utxo.outpoint, - sequence: Sequence::ENABLE_RBF_NO_LOCKTIME, - ..Default::default() - }) - .collect(), - output: outputs, - }; + // fill transaction inputs + transaction.input = selected_txos + .iter() + .map(|(_, utxo)| TxIn { + previous_output: utxo.outpoint, + sequence: Sequence::ENABLE_RBF_NO_LOCKTIME, + ..Default::default() + }) + .collect(); let prevouts = selected_txos .iter() @@ -389,7 +403,7 @@ where } } - let change_info = if selection_meta.drain_value.is_some() { + let change_info = if drain.is_some() { Some((changeset, (internal_keychain, change_index))) } else { None diff --git a/nursery/coin_select/Cargo.toml b/nursery/coin_select/Cargo.toml index 0830ad93e..8d2e0eaa5 100644 --- a/nursery/coin_select/Cargo.toml +++ b/nursery/coin_select/Cargo.toml @@ -1,10 +1,22 @@ [package] name = "bdk_coin_select" -version = "0.0.1" -authors = [ "LLFourn " ] +version = "0.1.0" +edition = "2021" +rust-version = "1.57" +homepage = "https://bitcoindevkit.org" +repository = "https://github.com/bitcoindevkit/bdk" +documentation = "https://docs.rs/bdk_coin_select" +description = "Tools for input selection for making Bitcoin transactions." +license = "MIT OR Apache-2.0" +readme = "README.md" [dependencies] -bdk_chain = { path = "../../crates/chain" } +# No dependencies! Don't add any please! + +[dev-dependencies] +rand = "0.7" +proptest = "0.10" +bitcoin = "0.30" [features] default = ["std"] diff --git a/nursery/coin_select/README.md b/nursery/coin_select/README.md new file mode 100644 index 000000000..8867f68b2 --- /dev/null +++ b/nursery/coin_select/README.md @@ -0,0 +1,189 @@ +# BDK Coin Selection + +`bdk_coin_select` is a tool to help you select inputs for making Bitcoin (ticker: BTC) transactions. +It's got zero dependencies so you can paste it into your project without concern. + +## Constructing the `CoinSelector` + +The main structure is [`CoinSelector`](crate::CoinSelector). To construct it, we specify a list of +candidate UTXOs and a transaction `base_weight`. The `base_weight` includes the recipient outputs +and mandatory inputs (if any). + +```rust +use std::str::FromStr; +use bdk_coin_select::{ CoinSelector, Candidate, TXIN_BASE_WEIGHT }; +use bitcoin::{ Address, Network, Transaction, TxIn, TxOut }; + +// 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; + +// The address where we want to send our coins. +let recipient_addr = + Address::from_str("tb1pvjf9t34fznr53u5tqhejz4nr69luzkhlvsdsdfq9pglutrpve2xq7hps46") + .expect("address must be valid") + .require_network(Network::Testnet) + .expect("network must match"); + +let candidates = vec![ + Candidate { + // How many inputs does this candidate represent. Needed so we can + // figure out the weight of the varint that encodes the number of inputs. + input_count: 1, + // the value of the input + value: 1_000_000, + // 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 to be spent together. + input_count: 2, + weight: 2*TR_INPUT_WEIGHT, + value: 3_000_000, + is_segwit: true + } +]; + +let base_tx = Transaction { + input: vec![], + // include your recipient outputs here + output: vec![TxOut { + value: 900_000, + script_pubkey: recipient_addr.script_pubkey(), + }], + lock_time: bitcoin::absolute::LockTime::from_height(0).unwrap(), + version: 0x02, +}; +let base_weight = base_tx.weight().to_wu() as u32; +println!("base weight: {}", base_weight); + +// You can now select coins! +let mut coin_selector = CoinSelector::new(&candidates, base_weight); +coin_selector.select(0); +``` + +## Change Policy + +A change policy determines whether the drain output(s) should be in the final solution. A change +policy is represented by a closure of signature `Fn(&CoinSelector, Target) -> Drain`. We provide 3 +built-in change policies; `min_value`, `min_waste` and `min_value_and_waste` (refer to the +[module-level docs](crate::change_policy) for more). + +Typically, to construct a change policy, the [`DrainWeights`] need to be provided. `DrainWeights` +includes two weights. One is the weight of the drain output(s). The other is the weight of spending +the drain output later on (the input weight). + +```rust +# use std::str::FromStr; +# use bdk_coin_select::{ CoinSelector, Candidate, DrainWeights, TXIN_BASE_WEIGHT }; +# use bitcoin::{ Address, Network, Transaction, TxIn, TxOut }; +use bdk_coin_select::change_policy::min_value; +# const TR_SATISFACTION_WEIGHT: u32 = 66; +# const TR_INPUT_WEIGHT: u32 = TXIN_BASE_WEIGHT + TR_SATISFACTION_WEIGHT; +# let base_tx = Transaction { +# input: vec![], +# // include your recipient outputs here +# output: vec![], +# lock_time: bitcoin::absolute::LockTime::from_height(0).unwrap(), +# version: 1, +# }; +# let base_weight = base_tx.weight().to_wu() as u32; + +// The change output that may or may not be included in the final transaction. +let drain_addr = + Address::from_str("tb1pvjf9t34fznr53u5tqhejz4nr69luzkhlvsdsdfq9pglutrpve2xq7hps46") + .expect("address must be valid") + .require_network(Network::Testnet) + .expect("network must match"); + +// The drain output(s) may or may not be included in the final tx. We calculate +// the drain weight to include the output length varint weight changes from +// including the drain output(s). +let drain_output_weight = { + let mut tx_with_drain = base_tx.clone(); + tx_with_drain.output.push(TxOut { + script_pubkey: drain_addr.script_pubkey(), + ..Default::default() + }); + tx_with_drain.weight().to_wu() as u32 - base_weight +}; +println!("drain output weight: {}", drain_output_weight); + +let drain_weights = DrainWeights { + output_weight: drain_output_weight, + spend_weight: TR_INPUT_WEIGHT, +}; + +// This constructs a change policy that creates change when the change value is +// greater than or equal to the dust limit. +let change_policy = min_value( + drain_weights, + drain_addr.script_pubkey().dust_value().to_sat(), +); +``` + +## Branch and Bound + +You can use methods such as [`CoinSelector::select`] to manually select coins, or methods such as +[`CoinSelector::select_until_target_met`] for a rudimentary automatic selection. However, if you +wish to automatically select coins to optimize for a given metric, [`CoinSelector::run_bnb`] can be +used. + +Built-in metrics are provided in the [`metrics`] submodule. Currently, only the +[`LowestFee`](metrics::LowestFee) metric is considered stable. + +```rust +use bdk_coin_select::{ Candidate, CoinSelector, FeeRate, Target }; +use bdk_coin_select::metrics::LowestFee; +use bdk_coin_select::change_policy::min_value_and_waste; +# let candidates = []; +# let base_weight = 0; +# let drain_weights = bdk_coin_select::DrainWeights::default(); +# let dust_limit = 0; +# let long_term_feerate = FeeRate::default_min_relay_fee(); + +let mut coin_selector = CoinSelector::new(&candidates, base_weight); + +let target = Target { + feerate: FeeRate::default_min_relay_fee(), + min_fee: 0, + value: 210_000, +}; + +// We use a change policy that introduces a change output if doing so reduces +// the "waste" and that the change output's value is at least that of the +// `dust_limit`. +let change_policy = min_value_and_waste( + drain_weights, + dust_limit, + long_term_feerate, +); + +// This metric minimizes transaction fee. The `long_term_feerate` is used to +// calculate the additional fee from spending the change output in the future. +let metric = LowestFee { + target, + long_term_feerate, + change_policy: &change_policy, +}; + +// We run the branch and bound algorithm with a max round limit of 100,000. +match coin_selector.run_bnb(metric, 100_000) { + Err(err) => println!("failed to find a solution: {}", err), + Ok(score) => { + println!("we found a solution with score {}", score); + + let selection = coin_selector + .apply_selection(&candidates) + .collect::>(); + let change = change_policy(&coin_selector, target); + + println!("we selected {} inputs", selection.len()); + println!("are we including the change output? {}", change.is_some()); + } +}; +``` diff --git a/nursery/coin_select/src/bnb.rs b/nursery/coin_select/src/bnb.rs index 6938185b9..ee55e473f 100644 --- a/nursery/coin_select/src/bnb.rs +++ b/nursery/coin_select/src/bnb.rs @@ -1,645 +1,218 @@ -use super::*; +use core::cmp::Reverse; -/// Strategy in which we should branch. -pub enum BranchStrategy { - /// We continue exploring subtrees of this node, starting with the inclusion branch. - Continue, - /// We continue exploring ONLY the omission branch of this node, skipping the inclusion branch. - SkipInclusion, - /// We skip both the inclusion and omission branches of this node. - SkipBoth, -} - -impl BranchStrategy { - pub fn will_continue(&self) -> bool { - matches!(self, Self::Continue | Self::SkipInclusion) - } -} - -/// Closure to decide the branching strategy, alongside a score (if the current selection is a -/// candidate solution). -pub type DecideStrategy<'c, S> = dyn Fn(&Bnb<'c, S>) -> (BranchStrategy, Option); - -/// [`Bnb`] represents the current state of the BnB algorithm. -pub struct Bnb<'c, S> { - pub pool: Vec<(usize, &'c WeightedValue)>, - pub pool_pos: usize, - pub best_score: S, - - pub selection: CoinSelector<'c>, - pub rem_abs: u64, - pub rem_eff: i64, -} - -impl<'c, S: Ord> Bnb<'c, S> { - /// Creates a new [`Bnb`]. - pub fn new(selector: CoinSelector<'c>, pool: Vec<(usize, &'c WeightedValue)>, max: S) -> Self { - let (rem_abs, rem_eff) = pool.iter().fold((0, 0), |(abs, eff), (_, c)| { - ( - abs + c.value, - eff + c.effective_value(selector.opts.target_feerate), - ) - }); - - Self { - pool, - pool_pos: 0, - best_score: max, - selection: selector, - rem_abs, - rem_eff, - } - } - - /// Turns our [`Bnb`] state into an iterator. - /// - /// `strategy` should assess our current selection/node and determine the branching strategy and - /// whether this selection is a candidate solution (if so, return the selection score). - pub fn into_iter<'f>(self, strategy: &'f DecideStrategy<'c, S>) -> BnbIter<'c, 'f, S> { - BnbIter { - state: self, - done: false, - strategy, - } - } - - /// Attempt to backtrack to the previously selected node's omission branch, return false - /// otherwise (no more solutions). - pub fn backtrack(&mut self) -> bool { - (0..self.pool_pos).rev().any(|pos| { - let (index, candidate) = self.pool[pos]; +use crate::float::Ordf32; - if self.selection.is_selected(index) { - // deselect the last `pos`, so the next round will check the omission branch - self.pool_pos = pos; - self.selection.deselect(index); - true - } else { - self.rem_abs += candidate.value; - self.rem_eff += candidate.effective_value(self.selection.opts.target_feerate); - false - } - }) - } +use super::CoinSelector; +use alloc::collections::BinaryHeap; - /// Continue down this branch and skip the inclusion branch if specified. - pub fn forward(&mut self, skip: bool) { - let (index, candidate) = self.pool[self.pool_pos]; - self.rem_abs -= candidate.value; - self.rem_eff -= candidate.effective_value(self.selection.opts.target_feerate); - - if !skip { - self.selection.select(index); - } - } - - /// Compare the advertised score with the current best. The new best will be the smaller value. Return true - /// if best is replaced. - pub fn advertise_new_score(&mut self, score: S) -> bool { - if score <= self.best_score { - self.best_score = score; - return true; - } - false - } -} - -pub struct BnbIter<'c, 'f, S> { - state: Bnb<'c, S>, - done: bool, - - /// Check our current selection (node) and returns the branching strategy alongside a score - /// (if the current selection is a candidate solution). - strategy: &'f DecideStrategy<'c, S>, +/// An [`Iterator`] that iterates over rounds of branch and bound to minimize the score of the +/// provided [`BnbMetric`]. +#[derive(Debug)] +pub(crate) struct BnbIter<'a, M: BnbMetric> { + queue: BinaryHeap>, + best: Option, + /// The `BnBMetric` that will score each selection + metric: M, } -impl<'c, 'f, S: Ord + Copy + Display> Iterator for BnbIter<'c, 'f, S> { - type Item = Option>; +impl<'a, M: BnbMetric> Iterator for BnbIter<'a, M> { + type Item = Option<(CoinSelector<'a>, Ordf32)>; fn next(&mut self) -> Option { - if self.done { - return None; + // { + // println!("=========================== {:?}", self.best); + // for thing in self.queue.iter() { + // println!("{} {:?}", &thing.selector, thing.lower_bound); + // } + // let _ = std::io::stdin().read_line(&mut alloc::string::String::new()); + // } + + let branch = self.queue.pop()?; + if let Some(best) = &self.best { + // If the next thing in queue is not better than our best we're done. + if *best < branch.lower_bound { + // println!( + // "\t\t(SKIP) branch={} inclusion={} lb={:?}, score={:?}", + // branch.selector, + // !branch.is_exclusion, + // branch.lower_bound, + // self.metric.score(&branch.selector), + // ); + return None; + } } + // println!( + // "\t\t( POP) branch={} inclusion={} lb={:?}, score={:?}", + // branch.selector, + // !branch.is_exclusion, + // branch.lower_bound, + // self.metric.score(&branch.selector), + // ); - let (strategy, score) = (self.strategy)(&self.state); + let selector = branch.selector; - let mut found_best = Option::::None; + self.insert_new_branches(&selector); - if let Some(score) = score { - if self.state.advertise_new_score(score) { - found_best = Some(self.state.selection.clone()); - } + if branch.is_exclusion { + return Some(None); } - debug_assert!( - !strategy.will_continue() || self.state.pool_pos < self.state.pool.len(), - "Faulty strategy implementation! Strategy suggested that we continue traversing, however, we have already reached the end of the candidates pool! pool_len={}, pool_pos={}", - self.state.pool.len(), self.state.pool_pos, - ); - - match strategy { - BranchStrategy::Continue => { - self.state.forward(false); - } - BranchStrategy::SkipInclusion => { - self.state.forward(true); - } - BranchStrategy::SkipBoth => { - if !self.state.backtrack() { - self.done = true; - } - } + let score = match self.metric.score(&selector) { + Some(score) => score, + None => return Some(None), }; - // increment selection pool position for next round - self.state.pool_pos += 1; - - if found_best.is_some() || !self.done { - Some(found_best) - } else { - // we have traversed all branches - None + if let Some(best_score) = &self.best { + if score >= *best_score { + return Some(None); + } } + self.best = Some(score); + Some(Some((selector, score))) } } -/// Determines how we should limit rounds of branch and bound. -pub enum BnbLimit { - Rounds(usize), - #[cfg(feature = "std")] - Duration(core::time::Duration), -} - -impl From for BnbLimit { - fn from(v: usize) -> Self { - Self::Rounds(v) - } -} - -#[cfg(feature = "std")] -impl From for BnbLimit { - fn from(v: core::time::Duration) -> Self { - Self::Duration(v) - } -} - -/// This is a variation of the Branch and Bound Coin Selection algorithm designed by Murch (as seen -/// in Bitcoin Core). -/// -/// The differences are as follows: -/// * In addition to working with effective values, we also work with absolute values. -/// This way, we can use bounds of the absolute values to enforce `min_absolute_fee` (which is used by -/// RBF), and `max_extra_target` (which can be used to increase the possible solution set, given -/// that the sender is okay with sending extra to the receiver). -/// -/// Murch's Master Thesis: -/// Bitcoin Core Implementation: -/// -/// TODO: Another optimization we could do is figure out candidates with the smallest waste, and -/// if we find a result with waste equal to this, we can just break. -pub fn coin_select_bnb(limit: L, selector: CoinSelector) -> Option -where - L: Into, -{ - let opts = selector.opts; - - // prepare the pool of candidates to select from: - // * filter out candidates with negative/zero effective values - // * sort candidates by descending effective value - let pool = { - let mut pool = selector - .unselected() - .filter(|(_, c)| c.effective_value(opts.target_feerate) > 0) - .collect::>(); - pool.sort_unstable_by(|(_, a), (_, b)| { - let a = a.effective_value(opts.target_feerate); - let b = b.effective_value(opts.target_feerate); - b.cmp(&a) - }); - pool - }; - - let feerate_decreases = opts.target_feerate > opts.long_term_feerate(); - - let target_abs = opts.target_value.unwrap_or(0) + opts.min_absolute_fee; - let target_eff = selector.effective_target(); - - let upper_bound_abs = target_abs + (opts.drain_weight as f32 * opts.target_feerate) as u64; - let upper_bound_eff = target_eff + opts.drain_waste(); - - let strategy = move |bnb: &Bnb| -> (BranchStrategy, Option) { - let selected_abs = bnb.selection.selected_absolute_value(); - let selected_eff = bnb.selection.selected_effective_value(); - - // backtrack if the remaining value is not enough to reach the target - if selected_abs + bnb.rem_abs < target_abs || selected_eff + bnb.rem_eff < target_eff { - return (BranchStrategy::SkipBoth, None); - } - - // backtrack if the selected value has already surpassed upper bounds - if selected_abs > upper_bound_abs && selected_eff > upper_bound_eff { - return (BranchStrategy::SkipBoth, None); - } - - let selected_waste = bnb.selection.selected_waste(); - - // when feerate decreases, waste without excess is guaranteed to increase with each - // selection. So if we have already surpassed the best score, we can backtrack. - if feerate_decreases && selected_waste > bnb.best_score { - return (BranchStrategy::SkipBoth, None); - } +impl<'a, M: BnbMetric> BnbIter<'a, M> { + pub(crate) fn new(mut selector: CoinSelector<'a>, metric: M) -> Self { + let mut iter = BnbIter { + queue: BinaryHeap::default(), + best: None, + metric, + }; - // solution? - if selected_abs >= target_abs && selected_eff >= target_eff { - let waste = selected_waste + bnb.selection.current_excess(); - return (BranchStrategy::SkipBoth, Some(waste)); + if iter.metric.requires_ordering_by_descending_value_pwu() { + selector.sort_candidates_by_descending_value_pwu(); } - // early bailout optimization: - // If the candidate at the previous position is NOT selected and has the same weight and - // value as the current candidate, we can skip selecting the current candidate. - if bnb.pool_pos > 0 && !bnb.selection.is_empty() { - let (_, candidate) = bnb.pool[bnb.pool_pos]; - let (prev_index, prev_candidate) = bnb.pool[bnb.pool_pos - 1]; - - if !bnb.selection.is_selected(prev_index) - && candidate.value == prev_candidate.value - && candidate.weight == prev_candidate.weight - { - return (BranchStrategy::SkipInclusion, None); + iter.consider_adding_to_queue(&selector, false); + + iter + } + + fn consider_adding_to_queue(&mut self, cs: &CoinSelector<'a>, is_exclusion: bool) { + let bound = self.metric.bound(cs); + if let Some(bound) = bound { + if self.best.is_none() || self.best.as_ref().unwrap() >= &bound { + let branch = Branch { + lower_bound: bound, + selector: cs.clone(), + is_exclusion, + }; + println!( + "\t\t(PUSH) branch={} inclusion={} lb={:?} score={:?}", + branch.selector, + !branch.is_exclusion, + branch.lower_bound, + self.metric.score(&branch.selector), + ); + self.queue.push(branch); + } else { + println!( + "\t\t( REJ) branch={} inclusion={} lb={:?} score={:?}", + cs, + !is_exclusion, + bound, + self.metric.score(cs), + ); } + } else { + println!( + "\t\t(NO B) branch={} inclusion={} score={:?}", + cs, + !is_exclusion, + self.metric.score(cs), + ); } - - // check out the inclusion branch first - (BranchStrategy::Continue, None) - }; - - // determine the sum of absolute and effective values for the current selection - let (selected_abs, selected_eff) = selector.selected().fold((0, 0), |(abs, eff), (_, c)| { - ( - abs + c.value, - eff + c.effective_value(selector.opts.target_feerate), - ) - }); - - let bnb = Bnb::new(selector, pool, i64::MAX); - - // not enough to select anyway - if selected_abs + bnb.rem_abs < target_abs || selected_eff + bnb.rem_eff < target_eff { - return None; - } - - match limit.into() { - BnbLimit::Rounds(rounds) => { - bnb.into_iter(&strategy) - .take(rounds) - .reduce(|b, c| if c.is_some() { c } else { b }) - } - #[cfg(feature = "std")] - BnbLimit::Duration(duration) => { - let start = std::time::SystemTime::now(); - bnb.into_iter(&strategy) - .take_while(|_| start.elapsed().expect("failed to get system time") <= duration) - .reduce(|b, c| if c.is_some() { c } else { b }) - } - }? -} - -#[cfg(all(test, feature = "miniscript"))] -mod test { - use bitcoin::secp256k1::Secp256k1; - - use crate::coin_select::{evaluate_cs::evaluate, ExcessStrategyKind}; - - use super::{ - coin_select_bnb, - evaluate_cs::{Evaluation, EvaluationError}, - tester::Tester, - CoinSelector, CoinSelectorOpt, Vec, WeightedValue, - }; - - fn tester() -> Tester { - const DESC_STR: &str = "tr(xprv9uBuvtdjghkz8D1qzsSXS9Vs64mqrUnXqzNccj2xcvnCHPpXKYE1U2Gbh9CDHk8UPyF2VuXpVkDA7fk5ZP4Hd9KnhUmTscKmhee9Dp5sBMK)"; - Tester::new(&Secp256k1::default(), DESC_STR) - } - - fn evaluate_bnb( - initial_selector: CoinSelector, - max_tries: usize, - ) -> Result { - evaluate(initial_selector, |cs| { - coin_select_bnb(max_tries, cs.clone()).map_or(false, |new_cs| { - *cs = new_cs; - true - }) - }) - } - - #[test] - fn not_enough_coins() { - let t = tester(); - let candidates: Vec = vec![ - t.gen_candidate(0, 100_000).into(), - t.gen_candidate(1, 100_000).into(), - ]; - let opts = t.gen_opts(200_000); - let selector = CoinSelector::new(&candidates, &opts); - assert!(!coin_select_bnb(10_000, selector).is_some()); } - #[test] - fn exactly_enough_coins_preselected() { - let t = tester(); - let candidates: Vec = vec![ - t.gen_candidate(0, 100_000).into(), // to preselect - t.gen_candidate(1, 100_000).into(), // to preselect - t.gen_candidate(2, 100_000).into(), - ]; - let opts = CoinSelectorOpt { - target_feerate: 0.0, - ..t.gen_opts(200_000) + fn insert_new_branches(&mut self, cs: &CoinSelector<'a>) { + let (next_index, next) = match cs.unselected().next() { + Some(c) => c, + None => return, // exhausted }; - let selector = { - let mut selector = CoinSelector::new(&candidates, &opts); - selector.select(0); // preselect - selector.select(1); // preselect - selector - }; - - let evaluation = evaluate_bnb(selector, 10_000).expect("eval failed"); - println!("{}", evaluation); - assert_eq!(evaluation.solution.selected, (0..=1).collect()); - assert_eq!(evaluation.solution.excess_strategies.len(), 1); - assert_eq!( - evaluation.feerate_offset(ExcessStrategyKind::ToFee).floor(), - 0.0 - ); - } - - /// `cost_of_change` acts as the upper-bound in Bnb; we check whether these boundaries are - /// enforced in code - #[test] - fn cost_of_change() { - let t = tester(); - let candidates: Vec = vec![ - t.gen_candidate(0, 200_000).into(), - t.gen_candidate(1, 200_000).into(), - t.gen_candidate(2, 200_000).into(), - ]; - - // lowest and highest possible `recipient_value` opts for derived `drain_waste`, assuming - // that we want 2 candidates selected - let (lowest_opts, highest_opts) = { - let opts = t.gen_opts(0); - - let fee_from_inputs = - (candidates[0].weight as f32 * opts.target_feerate).ceil() as u64 * 2; - let fee_from_template = - ((opts.base_weight + 2) as f32 * opts.target_feerate).ceil() as u64; - - let lowest_opts = CoinSelectorOpt { - target_value: Some( - 400_000 - fee_from_inputs - fee_from_template - opts.drain_waste() as u64, - ), - ..opts - }; - - let highest_opts = CoinSelectorOpt { - target_value: Some(400_000 - fee_from_inputs - fee_from_template), - ..opts - }; - - (lowest_opts, highest_opts) - }; - - // test lowest possible target we can select - let lowest_eval = evaluate_bnb(CoinSelector::new(&candidates, &lowest_opts), 10_000); - assert!(lowest_eval.is_ok()); - let lowest_eval = lowest_eval.unwrap(); - println!("LB {}", lowest_eval); - assert_eq!(lowest_eval.solution.selected.len(), 2); - assert_eq!(lowest_eval.solution.excess_strategies.len(), 1); - assert_eq!( - lowest_eval - .feerate_offset(ExcessStrategyKind::ToFee) - .floor(), - 0.0 - ); - - // test the highest possible target we can select - let highest_eval = evaluate_bnb(CoinSelector::new(&candidates, &highest_opts), 10_000); - assert!(highest_eval.is_ok()); - let highest_eval = highest_eval.unwrap(); - println!("UB {}", highest_eval); - assert_eq!(highest_eval.solution.selected.len(), 2); - assert_eq!(highest_eval.solution.excess_strategies.len(), 1); - assert_eq!( - highest_eval - .feerate_offset(ExcessStrategyKind::ToFee) - .floor(), - 0.0 - ); - - // test lower out of bounds - let loob_opts = CoinSelectorOpt { - target_value: lowest_opts.target_value.map(|v| v - 1), - ..lowest_opts - }; - let loob_eval = evaluate_bnb(CoinSelector::new(&candidates, &loob_opts), 10_000); - assert!(loob_eval.is_err()); - println!("Lower OOB: {}", loob_eval.unwrap_err()); - - // test upper out of bounds - let uoob_opts = CoinSelectorOpt { - target_value: highest_opts.target_value.map(|v| v + 1), - ..highest_opts - }; - let uoob_eval = evaluate_bnb(CoinSelector::new(&candidates, &uoob_opts), 10_000); - assert!(uoob_eval.is_err()); - println!("Upper OOB: {}", uoob_eval.unwrap_err()); - } - #[test] - fn try_select() { - let t = tester(); - let candidates: Vec = vec![ - t.gen_candidate(0, 300_000).into(), - t.gen_candidate(1, 300_000).into(), - t.gen_candidate(2, 300_000).into(), - t.gen_candidate(3, 200_000).into(), - t.gen_candidate(4, 200_000).into(), - ]; - let make_opts = |v: u64| -> CoinSelectorOpt { - CoinSelectorOpt { - target_feerate: 0.0, - ..t.gen_opts(v) + let mut inclusion_cs = cs.clone(); + inclusion_cs.select(next_index); + self.consider_adding_to_queue(&inclusion_cs, false); + + // for the exclusion branch, we keep banning if candidates have the same weight and value + let mut is_first_ban = true; + let mut exclusion_cs = cs.clone(); + let to_ban = (next.value, next.weight); + for (next_index, next) in cs.unselected() { + if (next.value, next.weight) != to_ban { + break; } - }; - - let test_cases = vec![ - (make_opts(100_000), false, 0), - (make_opts(200_000), true, 1), - (make_opts(300_000), true, 1), - (make_opts(500_000), true, 2), - (make_opts(1_000_000), true, 4), - (make_opts(1_200_000), false, 0), - (make_opts(1_300_000), true, 5), - (make_opts(1_400_000), false, 0), - ]; - - for (opts, expect_solution, expect_selected) in test_cases { - let res = evaluate_bnb(CoinSelector::new(&candidates, &opts), 10_000); - assert_eq!(res.is_ok(), expect_solution); - - match res { - Ok(eval) => { - println!("{}", eval); - assert_eq!(eval.feerate_offset(ExcessStrategyKind::ToFee), 0.0); - assert_eq!(eval.solution.selected.len(), expect_selected as _); - } - Err(err) => println!("expected failure: {}", err), + let (index, candidate) = exclusion_cs + .candidates() + .find(|(i, _)| *i == next_index) + .expect("must have index since we are planning to ban it"); + if is_first_ban { + is_first_ban = false; + } else { + println!("banning: [{}] {:?}", index, candidate); } + exclusion_cs.ban(next_index); } + self.consider_adding_to_queue(&exclusion_cs, true); } +} - #[test] - fn early_bailout_optimization() { - let t = tester(); - - // target: 300_000 - // candidates: 2x of 125_000, 1000x of 100_000, 1x of 50_000 - // expected solution: 2x 125_000, 1x 50_000 - // set bnb max tries: 1100, should succeed - let candidates = { - let mut candidates: Vec = vec![ - t.gen_candidate(0, 125_000).into(), - t.gen_candidate(1, 125_000).into(), - t.gen_candidate(2, 50_000).into(), - ]; - (3..3 + 1000_u32) - .for_each(|index| candidates.push(t.gen_candidate(index, 100_000).into())); - candidates - }; - let opts = CoinSelectorOpt { - target_feerate: 0.0, - ..t.gen_opts(300_000) - }; - - let result = evaluate_bnb(CoinSelector::new(&candidates, &opts), 1100); - assert!(result.is_ok()); +#[derive(Debug, Clone)] +struct Branch<'a> { + lower_bound: Ordf32, + selector: CoinSelector<'a>, + is_exclusion: bool, +} - let eval = result.unwrap(); - println!("{}", eval); - assert_eq!(eval.solution.selected, (0..=2).collect()); +impl<'a> Ord for Branch<'a> { + fn cmp(&self, other: &Self) -> core::cmp::Ordering { + // NOTE: Reverse comparision `lower_bound` because we want a min-heap (by default BinaryHeap + // is a max-heap). + // NOTE: We tiebreak equal scores based on whether it's exlusion or not (preferring + // inclusion). We do this because we want to try and get to evaluating complete selection + // returning actual scores as soon as possible. + core::cmp::Ord::cmp( + &(Reverse(&self.lower_bound), !self.is_exclusion), + &(Reverse(&other.lower_bound), !other.is_exclusion), + ) } +} - #[test] - fn should_exhaust_iteration() { - static MAX_TRIES: usize = 1000; - let t = tester(); - let candidates = (0..MAX_TRIES + 1) - .map(|index| t.gen_candidate(index as _, 10_000).into()) - .collect::>(); - let opts = t.gen_opts(10_001 * MAX_TRIES as u64); - let result = evaluate_bnb(CoinSelector::new(&candidates, &opts), MAX_TRIES); - assert!(result.is_err()); - println!("error as expected: {}", result.unwrap_err()); +impl<'a> PartialOrd for Branch<'a> { + fn partial_cmp(&self, other: &Self) -> Option { + Some(self.cmp(other)) } +} - /// Solution should have fee >= min_absolute_fee (or no solution at all) - #[test] - fn min_absolute_fee() { - let t = tester(); - let candidates = { - let mut candidates = Vec::new(); - t.gen_weighted_values(&mut candidates, 5, 10_000); - t.gen_weighted_values(&mut candidates, 5, 20_000); - t.gen_weighted_values(&mut candidates, 5, 30_000); - t.gen_weighted_values(&mut candidates, 10, 10_300); - t.gen_weighted_values(&mut candidates, 10, 10_500); - t.gen_weighted_values(&mut candidates, 10, 10_700); - t.gen_weighted_values(&mut candidates, 10, 10_900); - t.gen_weighted_values(&mut candidates, 10, 11_000); - t.gen_weighted_values(&mut candidates, 10, 12_000); - t.gen_weighted_values(&mut candidates, 10, 13_000); - candidates - }; - let mut opts = CoinSelectorOpt { - min_absolute_fee: 1, - ..t.gen_opts(100_000) - }; - - (1..=120_u64).for_each(|fee_factor| { - opts.min_absolute_fee = fee_factor * 31; - - let result = evaluate_bnb(CoinSelector::new(&candidates, &opts), 21_000); - match result { - Ok(result) => { - println!("Solution {}", result); - let fee = result.solution.excess_strategies[&ExcessStrategyKind::ToFee].fee; - assert!(fee >= opts.min_absolute_fee); - assert_eq!(result.solution.excess_strategies.len(), 1); - } - Err(err) => { - println!("No Solution: {}", err); - } - } - }); +impl<'a> PartialEq for Branch<'a> { + fn eq(&self, other: &Self) -> bool { + self.lower_bound == other.lower_bound } +} - /// For a decreasing feerate (long-term feerate is lower than effective feerate), we should - /// select less. For increasing feerate (long-term feerate is higher than effective feerate), we - /// should select more. - #[test] - fn feerate_difference() { - let t = tester(); - let candidates = { - let mut candidates = Vec::new(); - t.gen_weighted_values(&mut candidates, 10, 2_000); - t.gen_weighted_values(&mut candidates, 10, 5_000); - t.gen_weighted_values(&mut candidates, 10, 20_000); - candidates - }; - - let decreasing_feerate_opts = CoinSelectorOpt { - target_feerate: 1.25, - long_term_feerate: Some(0.25), - ..t.gen_opts(100_000) - }; - - let increasing_feerate_opts = CoinSelectorOpt { - target_feerate: 0.25, - long_term_feerate: Some(1.25), - ..t.gen_opts(100_000) - }; +impl<'a> Eq for Branch<'a> {} - let decreasing_res = evaluate_bnb( - CoinSelector::new(&candidates, &decreasing_feerate_opts), - 21_000, - ) - .expect("no result"); - let decreasing_len = decreasing_res.solution.selected.len(); +/// A branch and bound metric where we minimize the [`Ordf32`] score. +/// +/// This is to be used as input for [`CoinSelector::run_bnb`] or [`CoinSelector::bnb_solutions`]. +pub trait BnbMetric { + /// Get the score of a given selection. + /// + /// If this returns `None`, the selection is invalid. + fn score(&mut self, cs: &CoinSelector<'_>) -> Option; - let increasing_res = evaluate_bnb( - CoinSelector::new(&candidates, &increasing_feerate_opts), - 21_000, - ) - .expect("no result"); - let increasing_len = increasing_res.solution.selected.len(); + /// Get the lower bound score using a heuristic. + /// + /// This represents the best possible score of all descendant branches (according to the + /// heuristic). + /// + /// If this returns `None`, the current branch and all descendant branches will not have valid + /// solutions. + fn bound(&mut self, cs: &CoinSelector<'_>) -> Option; - println!("decreasing_len: {}", decreasing_len); - println!("increasing_len: {}", increasing_len); - assert!(decreasing_len < increasing_len); + /// Returns whether the metric requies we order candidates by descending value per weight unit. + fn requires_ordering_by_descending_value_pwu(&self) -> bool { + false } - - /// TODO: UNIMPLEMENTED TESTS: - /// * Excess strategies: - /// * We should always have `ExcessStrategy::ToFee`. - /// * We should only have `ExcessStrategy::ToRecipient` when `max_extra_target > 0`. - /// * We should only have `ExcessStrategy::ToDrain` when `drain_value >= min_drain_value`. - /// * Fuzz - /// * Solution feerate should never be lower than target feerate - /// * Solution fee should never be lower than `min_absolute_fee`. - /// * Preselected should always remain selected - fn _todo() {} } diff --git a/nursery/coin_select/src/change_policy.rs b/nursery/coin_select/src/change_policy.rs new file mode 100644 index 000000000..17e859164 --- /dev/null +++ b/nursery/coin_select/src/change_policy.rs @@ -0,0 +1,89 @@ +//! This module contains a collection of change policies. +//! +//! A change policy determines whether a given coin selection (presented by [`CoinSelector`]) should +//! construct a transaction with a change output. A change policy is represented as a function of +//! type `Fn(&CoinSelector, Target) -> Drain`. + +#[allow(unused)] // some bug in <= 1.48.0 sees this as unused when it isn't +use crate::float::FloatExt; +use crate::{CoinSelector, Drain, DrainWeights, FeeRate, Target}; +use core::convert::TryInto; + +/// Construct a change policy that creates change when the change value is greater than `min_value`. +pub fn min_value( + drain_weights: DrainWeights, + min_value: u64, +) -> impl Fn(&CoinSelector, Target) -> Drain { + let min_value: i64 = min_value + .try_into() + .expect("min_value is ridiculously large"); + + move |cs, target| { + let mut drain = Drain { + weights: drain_weights, + ..Default::default() + }; + + let excess = cs.excess(target, drain); + if excess < min_value { + return Drain::none(); + } + + drain.value = excess + .try_into() + .expect("must be positive since it is greater than min_value (which is positive)"); + drain + } +} + +/// Construct a change policy that creates change when it would reduce the transaction waste. +/// +/// **WARNING:** This may result in a change value that is below dust limit. [`min_value_and_waste`] +/// is a more sensible default. +pub fn min_waste( + drain_weights: DrainWeights, + long_term_feerate: FeeRate, +) -> impl Fn(&CoinSelector, Target) -> Drain { + move |cs, target| { + // The output waste of a changeless solution is the excess. + let waste_changeless = cs.excess(target, Drain::none()); + let waste_with_change = drain_weights + .waste(target.feerate, long_term_feerate) + .ceil() as i64; + + if waste_changeless <= waste_with_change { + return Drain::none(); + } + + let mut drain = Drain { + weights: drain_weights, + value: 0, + }; + drain.value = cs + .excess(target, drain) + .try_into() + .expect("the excess must be positive because drain free excess was > waste"); + drain + } +} + +/// Construct a change policy that creates change when it would reduce the transaction waste given +/// that `min_value` is respected. +/// +/// This is equivalent to combining [`min_value`] with [`min_waste`], and including change when both +/// policies have change. +pub fn min_value_and_waste( + drain_weights: DrainWeights, + min_value: u64, + long_term_feerate: FeeRate, +) -> impl Fn(&CoinSelector, Target) -> Drain { + let min_waste_policy = crate::change_policy::min_waste(drain_weights, long_term_feerate); + + move |cs, target| { + let drain = min_waste_policy(cs, target); + if drain.value < min_value { + return Drain::none(); + } + drain + } +} diff --git a/nursery/coin_select/src/coin_selector.rs b/nursery/coin_select/src/coin_selector.rs index 281992a96..9490ec521 100644 --- a/nursery/coin_select/src/coin_selector.rs +++ b/nursery/coin_select/src/coin_selector.rs @@ -1,617 +1,715 @@ use super::*; +#[allow(unused)] // some bug in <= 1.48.0 sees this as unused when it isn't +use crate::float::FloatExt; +use crate::{bnb::BnbMetric, float::Ordf32, FeeRate}; +use alloc::{borrow::Cow, collections::BTreeSet, vec::Vec}; + +/// [`CoinSelector`] selects/deselects coins from a set of canididate coins. +/// +/// You can manually select coins using methods like [`select`], or automatically with methods such +/// as [`bnb_solutions`]. +/// +/// [`select`]: CoinSelector::select +/// [`bnb_solutions`]: CoinSelector::bnb_solutions +#[derive(Debug, Clone)] +pub struct CoinSelector<'a> { + base_weight: u32, + candidates: &'a [Candidate], + selected: Cow<'a, BTreeSet>, + banned: Cow<'a, BTreeSet>, + candidate_order: Cow<'a, Vec>, +} -/// A [`WeightedValue`] represents an input candidate for [`CoinSelector`]. This can either be a -/// single UTXO, or a group of UTXOs that should be spent together. +/// A target value to select for along with feerate constraints. #[derive(Debug, Clone, Copy)] -pub struct WeightedValue { - /// Total value of the UTXO(s) that this [`WeightedValue`] represents. +pub struct Target { + /// The minimum feerate that the selection must have + pub feerate: FeeRate, + /// The minimum fee the selection must have + pub min_fee: u64, + /// The minmum value that should be left for the output pub value: u64, - /// Total weight of including this/these UTXO(s). - /// `txin` fields: `prevout`, `nSequence`, `scriptSigLen`, `scriptSig`, `scriptWitnessLen`, - /// `scriptWitness` should all be included. - pub weight: u32, - /// The total number of inputs; so we can calculate extra `varint` weight due to `vin` length changes. - pub input_count: usize, - /// Whether this [`WeightedValue`] contains at least one segwit spend. - pub is_segwit: bool, } -impl WeightedValue { - /// Create a new [`WeightedValue`] that represents a single input. - /// - /// `satisfaction_weight` is the weight of `scriptSigLen + scriptSig + scriptWitnessLen + - /// scriptWitness`. - pub fn new(value: u64, satisfaction_weight: u32, is_segwit: bool) -> WeightedValue { - let weight = TXIN_BASE_WEIGHT + satisfaction_weight; - WeightedValue { - value, - weight, - input_count: 1, - is_segwit, +impl Default for Target { + fn default() -> Self { + Self { + feerate: FeeRate::default_min_relay_fee(), + // https://bitcoin.stackexchange.com/questions/69282/what-is-the-min-relay-min-fee-code-26 + min_fee: 1000, + value: 0, } } - - /// Effective value of this input candidate: `actual_value - input_weight * feerate (sats/wu)`. - pub fn effective_value(&self, effective_feerate: f32) -> i64 { - // We prefer undershooting the candidate's effective value (so we over-estimate the fee of a - // candidate). If we overshoot the candidate's effective value, it may be possible to find a - // solution which does not meet the target feerate. - self.value as i64 - (self.weight as f32 * effective_feerate).ceil() as i64 - } } -#[derive(Debug, Clone, Copy)] -pub struct CoinSelectorOpt { - /// The value we need to select. - /// If the value is `None`, then the selection will be complete if it can pay for the drain - /// output and satisfy the other constraints (e.g., minimum fees). - pub target_value: Option, - /// Additional leeway for the target value. - pub max_extra_target: u64, // TODO: Maybe out of scope here? - - /// The feerate we should try and achieve in sats per weight unit. - pub target_feerate: f32, - /// The feerate - pub long_term_feerate: Option, // TODO: Maybe out of scope? (waste) - /// The minimum absolute fee. I.e., needed for RBF. - pub min_absolute_fee: u64, - - /// The weight of the template transaction, including fixed fields and outputs. - pub base_weight: u32, - /// Additional weight if we include the drain (change) output. - pub drain_weight: u32, - /// Weight of spending the drain (change) output in the future. - pub spend_drain_weight: u32, // TODO: Maybe out of scope? (waste) - - /// Minimum value allowed for a drain (change) output. - pub min_drain_value: u64, -} - -impl CoinSelectorOpt { - fn from_weights(base_weight: u32, drain_weight: u32, spend_drain_weight: u32) -> Self { - // 0.25 sats/wu == 1 sat/vb - let target_feerate = 0.25_f32; - - // set `min_drain_value` to dust limit - let min_drain_value = - 3 * ((drain_weight + spend_drain_weight) as f32 * target_feerate) as u64; - +impl<'a> CoinSelector<'a> { + /// Creates a new coin selector from some candidate inputs and a `base_weight`. + /// + /// The `base_weight` is the weight of the transaction without any inputs and without a change + /// output. + /// + /// The `CoinSelector` does not keep track of the final transaction's output count. The caller + /// is responsible for including the potential output-count varint weight change in the + /// corresponding [`DrainWeights`]. + /// + /// Note that methods in `CoinSelector` will refer to inputs by the index in the `candidates` + /// slice you pass in. + pub fn new(candidates: &'a [Candidate], base_weight: u32) -> Self { Self { - target_value: None, - max_extra_target: 0, - target_feerate, - long_term_feerate: None, - min_absolute_fee: 0, base_weight, - drain_weight, - spend_drain_weight, - min_drain_value, + candidates, + selected: Cow::Owned(Default::default()), + banned: Cow::Owned(Default::default()), + candidate_order: Cow::Owned((0..candidates.len()).collect()), } } + /// Creates a new coin selector from some candidate inputs and a list of `output_weights`. + /// + /// This is a convenience method to calculate the `base_weight` from a set of recipient output + /// weights. This is equivalent to calculating the `base_weight` yourself and calling + /// [`CoinSelector::new`]. pub fn fund_outputs( - txouts: &[TxOut], - drain_output: &TxOut, - drain_satisfaction_weight: u32, + candidates: &'a [Candidate], + output_weights: impl IntoIterator, ) -> Self { - let mut tx = Transaction { - input: vec![], - version: 1, - lock_time: absolute::LockTime::ZERO, - output: txouts.to_vec(), - }; - let base_weight = tx.weight(); - // Calculating drain_weight like this instead of using .weight() - // allows us to take into account the output len varint increase that - // might happen when adding a new output - let drain_weight = { - tx.output.push(drain_output.clone()); - tx.weight() - base_weight - }; - Self { - target_value: if txouts.is_empty() { - None - } else { - Some(txouts.iter().map(|txout| txout.value).sum()) - }, - ..Self::from_weights( - base_weight.to_wu() as u32, - drain_weight.to_wu() as u32, - TXIN_BASE_WEIGHT + drain_satisfaction_weight, - ) - } + let (output_count, output_weight_total) = output_weights + .into_iter() + .fold((0_usize, 0_u32), |(n, w), a| (n + 1, w + a)); + + let base_weight = (4 /* nVersion */ + + 4 /* nLockTime */ + + varint_size(0) /* inputs varint */ + + varint_size(output_count)/* outputs varint */) + * 4 + + output_weight_total; + + Self::new(candidates, base_weight) } - pub fn long_term_feerate(&self) -> f32 { - self.long_term_feerate.unwrap_or(self.target_feerate) + /// The weight of the transaction without any inputs and without a change output. + pub fn base_weight(&self) -> u32 { + self.base_weight } - pub fn drain_waste(&self) -> i64 { - (self.drain_weight as f32 * self.target_feerate - + self.spend_drain_weight as f32 * self.long_term_feerate()) as i64 + /// Iterate over all the candidates in their currently sorted order. Each item has the original + /// index with the candidate. + pub fn candidates( + &self, + ) -> impl DoubleEndedIterator + ExactSizeIterator + '_ { + self.candidate_order + .iter() + .map(move |i| (*i, self.candidates[*i])) } -} -/// [`CoinSelector`] selects and deselects from a set of candidates. -#[derive(Debug, Clone)] -pub struct CoinSelector<'a> { - pub opts: &'a CoinSelectorOpt, - pub candidates: &'a Vec, - selected: BTreeSet, -} + /// Get the candidate at `index`. `index` refers to its position in the original `candidates` + /// slice passed into [`CoinSelector::new`]. + pub fn candidate(&self, index: usize) -> Candidate { + self.candidates[index] + } -impl<'a> CoinSelector<'a> { - pub fn candidate(&self, index: usize) -> &WeightedValue { - &self.candidates[index] + /// Deselect a candidate at `index`. `index` refers to its position in the original `candidates` + /// slice passed into [`CoinSelector::new`]. + pub fn deselect(&mut self, index: usize) -> bool { + self.selected.to_mut().remove(&index) } - pub fn new(candidates: &'a Vec, opts: &'a CoinSelectorOpt) -> Self { - Self { - candidates, - selected: Default::default(), - opts, - } + /// Convienince method to pick elements of a slice by the indexes that are currently selected. + /// Obviously the slice must represent the inputs ordered in the same way as when they were + /// passed to `Candidates::new`. + pub fn apply_selection(&self, candidates: &'a [T]) -> impl Iterator + '_ { + self.selected.iter().map(move |i| &candidates[*i]) } + /// Select the input at `index`. `index` refers to its position in the original `candidates` + /// slice passed into [`CoinSelector::new`]. pub fn select(&mut self, index: usize) -> bool { assert!(index < self.candidates.len()); - self.selected.insert(index) + self.selected.to_mut().insert(index) } - pub fn deselect(&mut self, index: usize) -> bool { - self.selected.remove(&index) + /// Select the next unselected candidate in the sorted order fo the candidates. + pub fn select_next(&mut self) -> bool { + let next = self.unselected_indices().next(); + if let Some(next) = next { + self.select(next); + true + } else { + false + } + } + + /// Ban an input from being selected. Banning the input means it won't show up in [`unselected`] + /// or [`unselected_indices`]. Note it can still be manually selected. + /// + /// `index` refers to its position in the original `candidates` slice passed into [`CoinSelector::new`]. + /// + /// [`unselected`]: Self::unselected + /// [`unselected_indices`]: Self::unselected_indices + pub fn ban(&mut self, index: usize) { + self.banned.to_mut().insert(index); } + /// Gets the list of inputs that have been banned by [`ban`]. + /// + /// [`ban`]: Self::ban + pub fn banned(&self) -> &BTreeSet { + &self.banned + } + + /// Is the input at `index` selected. `index` refers to its position in the original + /// `candidates` slice passed into [`CoinSelector::new`]. pub fn is_selected(&self, index: usize) -> bool { self.selected.contains(&index) } + /// Is meeting this `target` possible with the current selection with this `drain` (i.e. change output). + /// Note this will respect [`ban`]ned candidates. + /// + /// This simply selects all effective inputs at the target's feerate and checks whether we have + /// enough value. + /// + /// [`ban`]: Self::ban + pub fn is_selection_possible(&self, target: Target, drain: Drain) -> bool { + let mut test = self.clone(); + test.select_all_effective(target.feerate); + test.is_target_met(target, drain) + } + + /// Is meeting the target *plausible* with this `change_policy`. + /// Note this will respect [`ban`]ned candidates. + /// + /// This is very similar to [`is_selection_possible`] except that you pass in a change policy. + /// This method will give the right answer as long as `change_policy` is monotone but otherwise + /// can it can give false negatives. + /// + /// [`ban`]: Self::ban + /// [`is_selection_possible`]: Self::is_selection_possible + pub fn is_selection_plausible_with_change_policy( + &self, + target: Target, + change_policy: &impl Fn(&CoinSelector<'a>, Target) -> Drain, + ) -> bool { + let mut test = self.clone(); + test.select_all_effective(target.feerate); + test.is_target_met(target, change_policy(&test, target)) + } + + /// Returns true if no candidates have been selected. 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. + 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::(); + (varint_size(input_count) - 1) * 4 + }; - /// Effective value sum of all selected inputs. - pub fn selected_effective_value(&self) -> i64 { - self.selected - .iter() - .map(|&index| self.candidates[index].effective_value(self.opts.target_feerate)) - .sum() + 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. - pub fn selected_absolute_value(&self) -> u64 { + pub fn selected_value(&self) -> u64 { self.selected .iter() .map(|&index| self.candidates[index].value) .sum() } - /// Waste sum of all selected inputs. - pub fn selected_waste(&self) -> i64 { - (self.selected_weight() as f32 * (self.opts.target_feerate - self.opts.long_term_feerate())) - as i64 + /// Current weight of template tx + selected inputs. + pub fn weight(&self, drain_weight: u32) -> u32 { + self.base_weight + self.input_weight() + drain_weight } - /// Current weight of template tx + selected inputs. - pub fn current_weight(&self) -> u32 { - let witness_header_extra_weight = self - .selected() - .find(|(_, wv)| wv.is_segwit) - .map(|_| 2) - .unwrap_or(0); - let vin_count_varint_extra_weight = { - let input_count = self.selected().map(|(_, wv)| wv.input_count).sum::(); - (varint_size(input_count) - 1) * 4 - }; - self.opts.base_weight - + self.selected_weight() - + witness_header_extra_weight - + vin_count_varint_extra_weight + /// How much the current selection overshoots the value needed to achieve `target`. + /// + /// In order for the resulting transaction to be valid this must be 0. + pub fn excess(&self, target: Target, drain: Drain) -> i64 { + self.selected_value() as i64 + - target.value as i64 + - drain.value as i64 + - self.implied_fee(target.feerate, target.min_fee, drain.weights.output_weight) as i64 } - /// Current excess. - pub fn current_excess(&self) -> i64 { - self.selected_effective_value() - self.effective_target() + /// How much the current selection overshoots the value need to satisfy `target.feerate` and + /// `target.value` (while ignoring `target.min_fee`). + pub fn rate_excess(&self, target: Target, drain: Drain) -> i64 { + self.selected_value() as i64 + - target.value as i64 + - drain.value as i64 + - self.implied_fee_from_feerate(target.feerate, drain.weights.output_weight) as i64 } - /// This is the effective target value. - pub fn effective_target(&self) -> i64 { - let (has_segwit, max_input_count) = self - .candidates - .iter() - .fold((false, 0_usize), |(is_segwit, input_count), c| { - (is_segwit || c.is_segwit, input_count + c.input_count) - }); + /// How much the current selection overshoots the value needed to satisfy `target.min_fee` and + /// `target.value` (while ignoring `target.feerate`). + pub fn absolute_excess(&self, target: Target, drain: Drain) -> i64 { + self.selected_value() as i64 + - target.value as i64 + - drain.value as i64 + - target.min_fee as i64 + } - let effective_base_weight = self.opts.base_weight - + if has_segwit { 2_u32 } else { 0_u32 } - + (varint_size(max_input_count) - 1) * 4; + /// The feerate the transaction would have if we were to use this selection of inputs to achieve + /// 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.weights.output_weight); + FeeRate::from_sat_per_wu(numerator as f32 / denom as f32) + } - self.opts.target_value.unwrap_or(0) as i64 - + (effective_base_weight as f32 * self.opts.target_feerate).ceil() as i64 + /// The fee the current selection should pay to reach `feerate` and provide `min_fee` + fn implied_fee(&self, feerate: FeeRate, min_fee: u64, drain_weight: u32) -> u64 { + (self.implied_fee_from_feerate(feerate, drain_weight)).max(min_fee) } - pub fn selected_count(&self) -> usize { - self.selected.len() + fn implied_fee_from_feerate(&self, feerate: FeeRate, drain_weight: u32) -> u64 { + (self.weight(drain_weight) as f32 * feerate.spwu()).ceil() as u64 } - pub fn selected(&self) -> impl Iterator + '_ { - self.selected - .iter() - .map(move |&index| (index, &self.candidates[index])) + /// The value of the current selected inputs minus the fee needed to pay for the selected inputs + pub fn effective_value(&self, feerate: FeeRate) -> i64 { + self.selected_value() as i64 - (self.input_weight() as f32 * feerate.spwu()).ceil() as i64 } - pub fn unselected(&self) -> impl Iterator + '_ { - self.candidates - .iter() - .enumerate() - .filter(move |(index, _)| !self.selected.contains(index)) + // /// Waste sum of all selected inputs. + fn input_waste(&self, feerate: FeeRate, long_term_feerate: FeeRate) -> f32 { + self.input_weight() as f32 * (feerate.spwu() - long_term_feerate.spwu()) } - pub fn selected_indexes(&self) -> impl Iterator + '_ { - self.selected.iter().cloned() + /// Sorts the candidates by the comparision function. + /// + /// The comparision function takes the candidates's index and the [`Candidate`]. + /// + /// Note this function does not change the index of the candidates after sorting, just the order + /// in which they will be returned when interating over them in [`candidates`] and [`unselected`]. + /// + /// [`candidates`]: CoinSelector::candidates + /// [`unselected`]: CoinSelector::unselected + pub fn sort_candidates_by(&mut self, mut cmp: F) + where + F: FnMut((usize, Candidate), (usize, Candidate)) -> core::cmp::Ordering, + { + let order = self.candidate_order.to_mut(); + let candidates = &self.candidates; + order.sort_by(|a, b| cmp((*a, candidates[*a]), (*b, candidates[*b]))) + } + + /// Sorts the candidates by the key function. + /// + /// The key function takes the candidates's index and the [`Candidate`]. + /// + /// Note this function does not change the index of the candidates after sorting, just the order + /// in which they will be returned when interating over them in [`candidates`] and [`unselected`]. + /// + /// [`candidates`]: CoinSelector::candidates + /// [`unselected`]: CoinSelector::unselected + pub fn sort_candidates_by_key(&mut self, mut key_fn: F) + where + F: FnMut((usize, Candidate)) -> K, + K: Ord, + { + self.sort_candidates_by(|a, b| key_fn(a).cmp(&key_fn(b))) } - pub fn unselected_indexes(&self) -> impl Iterator + '_ { - (0..self.candidates.len()).filter(move |index| !self.selected.contains(index)) + /// Sorts the candidates by descending value per weight unit, tie-breaking with value. + pub fn sort_candidates_by_descending_value_pwu(&mut self) { + self.sort_candidates_by_key(|(_, wv)| core::cmp::Reverse((wv.value_pwu(), wv.value))); } - pub fn all_selected(&self) -> bool { - self.selected.len() == self.candidates.len() + /// The waste created by the current selection as measured by the [waste metric]. + /// + /// You can pass in an `excess_discount` which must be between `0.0..1.0`. Passing in `1.0` gives you no discount + /// + /// [waste metric]: https://bitcoin.stackexchange.com/questions/113622/what-does-waste-metric-mean-in-the-context-of-coin-selection + pub fn waste( + &self, + target: Target, + long_term_feerate: FeeRate, + drain: Drain, + excess_discount: f32, + ) -> f32 { + debug_assert!((0.0..=1.0).contains(&excess_discount)); + 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 + // satisified target yet in which case you probably shouldn't be calling this function. + let mut excess_waste = self.excess(target, drain).max(0) as f32; + // we allow caller to discount this waste depending on how wasteful excess actually is + // to them. + excess_waste *= excess_discount.max(0.0).min(1.0); + waste += excess_waste; + } else { + waste += drain.weights.output_weight as f32 * target.feerate.spwu() + + drain.weights.spend_weight as f32 * long_term_feerate.spwu(); + } + + waste } - pub fn select_all(&mut self) { - self.selected = (0..self.candidates.len()).collect(); + /// The selected candidates with their index. + pub fn selected( + &self, + ) -> impl ExactSizeIterator + DoubleEndedIterator + '_ { + self.selected + .iter() + .map(move |&index| (index, self.candidates[index])) + } + + /// The unselected candidates with their index. + /// + /// The candidates are returned in sorted order. See [`sort_candidates_by`]. + /// + /// [`sort_candidates_by`]: Self::sort_candidates_by + pub fn unselected(&self) -> impl DoubleEndedIterator + '_ { + self.unselected_indices() + .map(move |i| (i, self.candidates[i])) } - pub fn select_until_finished(&mut self) -> Result { - let mut selection = self.finish(); + /// The indices of the selelcted candidates. + pub fn selected_indices(&self) -> &BTreeSet { + &self.selected + } - if selection.is_ok() { - return selection; - } + /// The indices of the unselected candidates. + /// + /// This excludes candidates that have been selected or [`banned`]. + /// + /// [`banned`]: Self::ban + pub fn unselected_indices(&self) -> impl DoubleEndedIterator + '_ { + self.candidate_order + .iter() + .filter(move |index| !(self.selected.contains(index) || self.banned.contains(index))) + .copied() + } - let unselected = self.unselected_indexes().collect::>(); + /// Whether there are any unselected candidates left. + pub fn is_exhausted(&self) -> bool { + self.unselected_indices().next().is_none() + } - for index in unselected { - self.select(index); - selection = self.finish(); + /// Whether the constraints of `Target` have been met if we include the `drain` ouput. + pub fn is_target_met(&self, target: Target, drain: Drain) -> bool { + self.excess(target, drain) >= 0 + } - if selection.is_ok() { + /// Select all unselected candidates + pub fn select_all(&mut self) { + loop { + if !self.select_next() { break; } } + } - selection - } - - pub fn finish(&self) -> Result { - let weight_without_drain = self.current_weight(); - let weight_with_drain = weight_without_drain + self.opts.drain_weight; - - let fee_without_drain = - (weight_without_drain as f32 * self.opts.target_feerate).ceil() as u64; - let fee_with_drain = (weight_with_drain as f32 * self.opts.target_feerate).ceil() as u64; - - let inputs_minus_outputs = { - let target_value = self.opts.target_value.unwrap_or(0); - let selected = self.selected_absolute_value(); - - // find the largest unsatisfied constraint (if any), and return the error of that constraint - // "selected" should always be greater than or equal to these selected values - [ - ( - SelectionConstraint::TargetValue, - target_value.saturating_sub(selected), - ), - ( - SelectionConstraint::TargetFee, - (target_value + fee_without_drain).saturating_sub(selected), - ), - ( - SelectionConstraint::MinAbsoluteFee, - (target_value + self.opts.min_absolute_fee).saturating_sub(selected), - ), - ( - SelectionConstraint::MinDrainValue, - // when we have no target value (hence no recipient txouts), we need to ensure - // the selected amount can satisfy requirements for a drain output (so we at least have one txout) - if self.opts.target_value.is_none() { - (fee_with_drain + self.opts.min_drain_value).saturating_sub(selected) - } else { - 0 - }, - ), - ] - .iter() - .filter(|&(_, v)| v > &0) - .max_by_key(|&(_, v)| v) - .map_or(Ok(()), |(constraint, missing)| { - Err(SelectionError { - selected, - missing: *missing, - constraint: *constraint, - }) - })?; - - selected - target_value - }; - - let fee_without_drain = fee_without_drain.max(self.opts.min_absolute_fee); - let fee_with_drain = fee_with_drain.max(self.opts.min_absolute_fee); - - let excess_without_drain = inputs_minus_outputs - fee_without_drain; - let input_waste = self.selected_waste(); - - // begin preparing excess strategies for final selection - let mut excess_strategies = HashMap::new(); - - // only allow `ToFee` and `ToRecipient` excess strategies when we have a `target_value`, - // otherwise, we will result in a result with no txouts, or attempt to add value to an output - // that does not exist. - if self.opts.target_value.is_some() { - // no drain, excess to fee - excess_strategies.insert( - ExcessStrategyKind::ToFee, - ExcessStrategy { - recipient_value: self.opts.target_value, - drain_value: None, - fee: fee_without_drain + excess_without_drain, - weight: weight_without_drain, - waste: input_waste + excess_without_drain as i64, - }, - ); - - // no drain, send the excess to the recipient - // if `excess == 0`, this result will be the same as the previous, so don't consider it - // if `max_extra_target == 0`, there is no leeway for this strategy - if excess_without_drain > 0 && self.opts.max_extra_target > 0 { - let extra_recipient_value = - core::cmp::min(self.opts.max_extra_target, excess_without_drain); - let extra_fee = excess_without_drain - extra_recipient_value; - excess_strategies.insert( - ExcessStrategyKind::ToRecipient, - ExcessStrategy { - recipient_value: self.opts.target_value.map(|v| v + extra_recipient_value), - drain_value: None, - fee: fee_without_drain + extra_fee, - weight: weight_without_drain, - waste: input_waste + extra_fee as i64, - }, - ); + /// Select all candidates with an *effective value* greater than 0 at the provided `feerate`. + /// + /// A candidate if effective if it provides more value than it takes to pay for at `feerate`. + pub fn select_all_effective(&mut self, feerate: FeeRate) { + for cand_index in self.candidate_order.iter() { + if self.selected.contains(cand_index) + || self.banned.contains(cand_index) + || self.candidates[*cand_index].effective_value(feerate) <= Ordf32(0.0) + { + continue; } + self.selected.to_mut().insert(*cand_index); } + } - // with drain - if fee_with_drain >= self.opts.min_absolute_fee - && inputs_minus_outputs >= fee_with_drain + self.opts.min_drain_value - { - excess_strategies.insert( - ExcessStrategyKind::ToDrain, - ExcessStrategy { - recipient_value: self.opts.target_value, - drain_value: Some(inputs_minus_outputs.saturating_sub(fee_with_drain)), - fee: fee_with_drain, - weight: weight_with_drain, - waste: input_waste + self.opts.drain_waste(), - }, - ); + /// Select candidates until `target` has been met assuming the `drain` output is attached. + /// + /// Returns an `Some(_)` if it was able to meet the target. + pub fn select_until_target_met( + &mut self, + target: Target, + drain: Drain, + ) -> Result<(), InsufficientFunds> { + self.select_until(|cs| cs.is_target_met(target, drain)) + .ok_or_else(|| InsufficientFunds { + missing: self.excess(target, drain).unsigned_abs(), + }) + } + + /// Select candidates until some predicate has been satisfied. + #[must_use] + pub fn select_until( + &mut self, + mut predicate: impl FnMut(&CoinSelector<'a>) -> bool, + ) -> Option<()> { + loop { + if predicate(&*self) { + break Some(()); + } + + if !self.select_next() { + break None; + } } + } - debug_assert!( - !excess_strategies.is_empty(), - "should have at least one excess strategy." - ); + /// Return an iterator that can be used to select candidates. + pub fn select_iter(self) -> SelectIter<'a> { + SelectIter { cs: self.clone() } + } - Ok(Selection { - selected: self.selected.clone(), - excess: excess_without_drain, - excess_strategies, - }) + /// Iterates over rounds of branch and bound to minimize the score of the provided + /// [`BnbMetric`]. + /// + /// Not every iteration will return a solution. If a solution is found, we return the selection + /// and score. Each subsequent solution of the iterator guarantees a higher score than the last. + /// + /// Most of the time, you would want to use [`CoinSelector::run_bnb`] instead. + pub fn bnb_solutions( + &self, + metric: M, + ) -> impl Iterator, Ordf32)>> { + crate::bnb::BnbIter::new(self.clone(), metric) } -} -#[derive(Clone, Debug)] -pub struct SelectionError { - selected: u64, - missing: u64, - constraint: SelectionConstraint, + /// Run branch and bound to minimize the score of the provided [`BnbMetric`]. + /// + /// The method keeps trying until no better solution can be found, or we reach `max_rounds`. If + /// a solution is found, the score is returned. Otherwise, we error with [`NoBnbSolution`]. + /// + /// Use [`CoinSelector::bnb_solutions`] to access the branch and bound iterator directly. + pub fn run_bnb( + &mut self, + metric: M, + max_rounds: usize, + ) -> Result { + let mut rounds = 0_usize; + let (selector, score) = self + .bnb_solutions(metric) + .inspect(|_| rounds += 1) + .take(max_rounds) + .flatten() + .last() + .ok_or(NoBnbSolution { max_rounds, rounds })?; + *self = selector; + Ok(score) + } } -impl core::fmt::Display for SelectionError { +impl<'a> core::fmt::Display for CoinSelector<'a> { fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { - let SelectionError { - selected, - missing, - constraint, - } = self; - write!( - f, - "insufficient coins selected; selected={}, missing={}, unsatisfied_constraint={:?}", - selected, missing, constraint - ) + write!(f, "[")?; + let mut candidates = self.candidates().peekable(); + + while let Some((i, _)) = candidates.next() { + write!(f, "{}", i)?; + if self.is_selected(i) { + write!(f, "✔")?; + } else if self.banned().contains(&i) { + write!(f, "✘")? + } else { + write!(f, "☐")?; + } + + if candidates.peek().is_some() { + write!(f, ", ")?; + } + } + + write!(f, "]") } } -#[cfg(feature = "std")] -impl std::error::Error for SelectionError {} - -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub enum SelectionConstraint { - /// The target is not met - TargetValue, - /// The target fee (given the feerate) is not met - TargetFee, - /// Min absolute fee is not met - MinAbsoluteFee, - /// Min drain value is not met - MinDrainValue, +/// A `Candidate` represents an input candidate for [`CoinSelector`]. +/// +/// This can either be a single UTXO, or a group of UTXOs that should be spent together. +#[derive(Debug, Clone, Copy)] +pub struct Candidate { + /// Total value of the UTXO(s) that this [`Candidate`] represents. + pub value: u64, + /// Total weight of including this/these UTXO(s). + /// `txin` fields: `prevout`, `nSequence`, `scriptSigLen`, `scriptSig`, `scriptWitnessLen`, + /// `scriptWitness` should all be included. + pub weight: u32, + /// Total number of inputs; so we can calculate extra `varint` weight due to `vin` len changes. + pub input_count: usize, + /// Whether this [`Candidate`] contains at least one segwit spend. + pub is_segwit: bool, } -impl core::fmt::Display for SelectionConstraint { - fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { - match self { - SelectionConstraint::TargetValue => core::write!(f, "target_value"), - SelectionConstraint::TargetFee => core::write!(f, "target_fee"), - SelectionConstraint::MinAbsoluteFee => core::write!(f, "min_absolute_fee"), - SelectionConstraint::MinDrainValue => core::write!(f, "min_drain_value"), +impl Candidate { + /// Create a [`Candidate`] input that spends a single taproot keyspend output. + pub fn new_tr_keyspend(value: u64) -> Self { + let weight = TXIN_BASE_WEIGHT + TR_KEYSPEND_SATISFACTION_WEIGHT; + Self::new(value, weight, true) + } + + /// Create a new [`Candidate`] that represents a single input. + /// + /// `satisfaction_weight` is the weight of `scriptSigLen + scriptSig + scriptWitnessLen + + /// scriptWitness`. + pub fn new(value: u64, satisfaction_weight: u32, is_segwit: bool) -> Candidate { + let weight = TXIN_BASE_WEIGHT + satisfaction_weight; + Candidate { + value, + weight, + input_count: 1, + is_segwit, } } -} -#[derive(Clone, Debug)] -pub struct Selection { - pub selected: BTreeSet, - pub excess: u64, - pub excess_strategies: HashMap, -} + /// Effective value of this input candidate: `actual_value - input_weight * feerate (sats/wu)`. + pub fn effective_value(&self, feerate: FeeRate) -> Ordf32 { + Ordf32(self.value as f32 - (self.weight as f32 * feerate.spwu())) + } -#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, core::hash::Hash)] -pub enum ExcessStrategyKind { - ToFee, - ToRecipient, - ToDrain, + /// Value per weight unit + pub fn value_pwu(&self) -> Ordf32 { + Ordf32(self.value as f32 / self.weight as f32) + } } -#[derive(Clone, Copy, Debug)] -pub struct ExcessStrategy { - pub recipient_value: Option, - pub drain_value: Option, - pub fee: u64, - pub weight: u32, - pub waste: i64, +/// A structure that represents the weight costs of a drain (a.k.a. change) output. +/// +/// This structure can also represent multiple outputs. +#[derive(Default, Debug, Clone, Copy, Hash, PartialEq, Eq)] +pub struct DrainWeights { + /// The weight of including this drain output. + /// + /// This must take into account the weight change from varint output count. + pub output_weight: u32, + /// The weight of spending this drain output (in the future). + pub spend_weight: u32, } -impl Selection { - pub fn apply_selection<'a, T>( - &'a self, - candidates: &'a [T], - ) -> impl Iterator + 'a { - self.selected.iter().map(move |i| &candidates[*i]) +impl DrainWeights { + /// The waste of adding this drain to a transaction according to the [waste metric]. + /// + /// [waste metric]: https://bitcoin.stackexchange.com/questions/113622/what-does-waste-metric-mean-in-the-context-of-coin-selection + pub fn waste(&self, feerate: FeeRate, long_term_feerate: FeeRate) -> f32 { + self.output_weight as f32 * feerate.spwu() + + self.spend_weight as f32 * long_term_feerate.spwu() } - /// Returns the [`ExcessStrategy`] that results in the least waste. - pub fn best_strategy(&self) -> (&ExcessStrategyKind, &ExcessStrategy) { - self.excess_strategies - .iter() - .min_by_key(|&(_, a)| a.waste) - .expect("selection has no excess strategy") + /// Create [`DrainWeights`] that represents a drain output with a taproot keyspend. + pub fn new_tr_keyspend() -> Self { + Self { + output_weight: TXOUT_BASE_WEIGHT + TR_SPK_WEIGHT, + spend_weight: TXIN_BASE_WEIGHT + TR_KEYSPEND_SATISFACTION_WEIGHT, + } } } -impl core::fmt::Display for ExcessStrategyKind { - fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { - match self { - ExcessStrategyKind::ToFee => core::write!(f, "to_fee"), - ExcessStrategyKind::ToRecipient => core::write!(f, "to_recipient"), - ExcessStrategyKind::ToDrain => core::write!(f, "to_drain"), - } - } +/// A drain (A.K.A. change) output. +/// Technically it could represent multiple outputs. +/// +/// These are usually created by a [`change_policy`]. +/// +/// [`change_policy`]: crate::change_policy +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Default)] +pub struct Drain { + /// Weight of adding drain output and spending the drain output. + pub weights: DrainWeights, + /// The value that should be assigned to the drain. + pub value: u64, } -impl ExcessStrategy { - /// Returns feerate in sats/wu. - pub fn feerate(&self) -> f32 { - self.fee as f32 / self.weight as f32 +impl Drain { + /// A drian representing no drain at all. + pub fn none() -> Self { + Self::default() + } + + /// is the "none" drain + pub fn is_none(&self) -> bool { + self == &Drain::none() } -} -#[cfg(test)] -mod test { - use crate::{ExcessStrategyKind, SelectionConstraint}; + /// Is not the "none" drain + pub fn is_some(&self) -> bool { + !self.is_none() + } - use super::{CoinSelector, CoinSelectorOpt, WeightedValue}; + /// The waste of adding this drain to a transaction according to the [waste metric]. + /// + /// [waste metric]: https://bitcoin.stackexchange.com/questions/113622/what-does-waste-metric-mean-in-the-context-of-coin-selection + pub fn waste(&self, feerate: FeeRate, long_term_feerate: FeeRate) -> f32 { + self.weights.waste(feerate, long_term_feerate) + } +} - /// Ensure `target_value` is respected. Can't have any disrespect. - #[test] - fn target_value_respected() { - let target_value = 1000_u64; +/// The `SelectIter` allows you to select candidates by calling [`Iterator::next`]. +/// +/// The [`Iterator::Item`] is a tuple of `(selector, last_selected_index, last_selected_candidate)`. +pub struct SelectIter<'a> { + cs: CoinSelector<'a>, +} - let candidates = (500..1500_u64) - .map(|value| WeightedValue { - value, - weight: 100, - input_count: 1, - is_segwit: false, - }) - .collect::>(); - - let opts = CoinSelectorOpt { - target_value: Some(target_value), - max_extra_target: 0, - target_feerate: 0.00, - long_term_feerate: None, - min_absolute_fee: 0, - base_weight: 10, - drain_weight: 10, - spend_drain_weight: 10, - min_drain_value: 10, - }; +impl<'a> Iterator for SelectIter<'a> { + type Item = (CoinSelector<'a>, usize, Candidate); - for (index, v) in candidates.iter().enumerate() { - let mut selector = CoinSelector::new(&candidates, &opts); - assert!(selector.select(index)); + fn next(&mut self) -> Option { + let (index, wv) = self.cs.unselected().next()?; + self.cs.select(index); + Some((self.cs.clone(), index, wv)) + } +} - let res = selector.finish(); - if v.value < opts.target_value.unwrap_or(0) { - let err = res.expect_err("should have failed"); - assert_eq!(err.selected, v.value); - assert_eq!(err.missing, target_value - v.value); - assert_eq!(err.constraint, SelectionConstraint::MinAbsoluteFee); - } else { - let sel = res.expect("should have succeeded"); - assert_eq!(sel.excess, v.value - opts.target_value.unwrap_or(0)); - } - } +impl<'a> DoubleEndedIterator for SelectIter<'a> { + fn next_back(&mut self) -> Option { + let (index, wv) = self.cs.unselected().next_back()?; + self.cs.select(index); + Some((self.cs.clone(), index, wv)) } +} - #[test] - fn drain_all() { - let candidates = (0..100) - .map(|_| WeightedValue { - value: 666, - weight: 166, - input_count: 1, - is_segwit: false, - }) - .collect::>(); - - let opts = CoinSelectorOpt { - target_value: None, - max_extra_target: 0, - target_feerate: 0.25, - long_term_feerate: None, - min_absolute_fee: 0, - base_weight: 10, - drain_weight: 100, - spend_drain_weight: 66, - min_drain_value: 1000, - }; +/// Error type that occurs when the target amount cannot be met. +#[derive(Clone, Debug, Copy, PartialEq, Eq)] +pub struct InsufficientFunds { + /// The missing amount in satoshis. + pub missing: u64, +} - let selection = CoinSelector::new(&candidates, &opts) - .select_until_finished() - .expect("should succeed"); +impl core::fmt::Display for InsufficientFunds { + fn fmt(&self, f: &mut core::fmt::Formatter) -> core::fmt::Result { + write!(f, "Insufficient funds. Missing {} sats.", self.missing) + } +} - assert!(selection.selected.len() > 1); - assert_eq!(selection.excess_strategies.len(), 1); +#[cfg(feature = "std")] +impl std::error::Error for InsufficientFunds {} + +/// Error type for when a solution cannot be found by branch-and-bound. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct NoBnbSolution { + /// Maximum rounds set by the caller. + pub max_rounds: usize, + /// Number of branch-and-bound rounds performed. + pub rounds: usize, +} - let (kind, strategy) = selection.best_strategy(); - assert_eq!(*kind, ExcessStrategyKind::ToDrain); - assert!(strategy.recipient_value.is_none()); - assert!(strategy.drain_value.is_some()); +impl core::fmt::Display for NoBnbSolution { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + write!( + f, + "No bnb solution found after {} rounds (max rounds is {}).", + self.rounds, self.max_rounds + ) } - - /// TODO: Tests to add: - /// * `finish` should ensure at least `target_value` is selected. - /// * actual feerate should be equal or higher than `target_feerate`. - /// * actual drain value should be equal to or higher than `min_drain_value` (or else no drain). - fn _todo() {} } + +#[cfg(feature = "std")] +impl std::error::Error for NoBnbSolution {} diff --git a/nursery/coin_select/src/feerate.rs b/nursery/coin_select/src/feerate.rs new file mode 100644 index 000000000..474c8c612 --- /dev/null +++ b/nursery/coin_select/src/feerate.rs @@ -0,0 +1,91 @@ +use crate::float::Ordf32; +use core::ops::{Add, Sub}; + +/// Fee rate +#[derive(Debug, Copy, Clone, PartialEq, Eq, PartialOrd, Ord)] +// Internally stored as satoshi/weight unit +pub struct FeeRate(Ordf32); + +impl FeeRate { + /// Create a new instance checking the value provided + /// + /// ## Panics + /// + /// Panics if the value is not [normal](https://doc.rust-lang.org/std/primitive.f32.html#method.is_normal) (except if it's a positive zero) or negative. + fn new_checked(value: f32) -> Self { + assert!(value.is_normal() || value == 0.0); + assert!(value.is_sign_positive()); + + Self(Ordf32(value)) + } + + /// Create a new instance of [`FeeRate`] given a float fee rate in btc/kvbytes + /// + /// ## Panics + /// + /// Panics if the value is not [normal](https://doc.rust-lang.org/std/primitive.f32.html#method.is_normal) (except if it's a positive zero) or negative. + pub fn from_btc_per_kvb(btc_per_kvb: f32) -> Self { + Self::new_checked(btc_per_kvb * 1e5 / 4.0) + } + + /// A feerate of zero + pub fn zero() -> Self { + Self(Ordf32(0.0)) + } + + /// Create a new instance of [`FeeRate`] given a float fee rate in satoshi/vbyte + /// + /// ## Panics + /// + /// Panics if the value is not [normal](https://doc.rust-lang.org/std/primitive.f32.html#method.is_normal) (except if it's a positive zero) or negative. + pub fn from_sat_per_vb(sat_per_vb: f32) -> Self { + Self::new_checked(sat_per_vb / 4.0) + } + + /// Create a new [`FeeRate`] with the default min relay fee value + pub const fn default_min_relay_fee() -> Self { + Self(Ordf32(0.25)) + } + + /// Calculate fee rate from `fee` and weight units (`wu`). + pub fn from_wu(fee: u64, wu: usize) -> Self { + Self::from_sat_per_wu(fee as f32 / wu as f32) + } + + /// Calculate feerate from `satoshi/wu`. + pub fn from_sat_per_wu(sats_per_wu: f32) -> Self { + Self::new_checked(sats_per_wu) + } + + /// Calculate fee rate from `fee` and `vbytes`. + pub fn from_vb(fee: u64, vbytes: usize) -> Self { + let rate = fee as f32 / vbytes as f32; + Self::from_sat_per_vb(rate) + } + + /// Return the value as satoshi/vbyte. + pub fn as_sat_vb(&self) -> f32 { + self.0 .0 * 4.0 + } + + /// Return the value as satoshi/wu. + pub fn spwu(&self) -> f32 { + self.0 .0 + } +} + +impl Add for FeeRate { + type Output = Self; + + fn add(self, rhs: FeeRate) -> Self::Output { + Self(Ordf32(self.0 .0 + rhs.0 .0)) + } +} + +impl Sub for FeeRate { + type Output = Self; + + fn sub(self, rhs: FeeRate) -> Self::Output { + Self(Ordf32(self.0 .0 - rhs.0 .0)) + } +} diff --git a/nursery/coin_select/src/float.rs b/nursery/coin_select/src/float.rs new file mode 100644 index 000000000..61b756fee --- /dev/null +++ b/nursery/coin_select/src/float.rs @@ -0,0 +1,96 @@ +//! Newtypes around `f32` and `f64` that implement `Ord`. +//! +//! Backported from rust std lib [`total_cmp`] in version 1.62.0. Hopefully some day rust has this +//! in core: +//! +//! [`total_cmp`]: https://doc.rust-lang.org/core/primitive.f32.html#method.total_cmp + +/// Wrapper for `f32` that implements `Ord`. +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct Ordf32(pub f32); +/// Wrapper for `f64` that implements `Ord`. +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct Ordf64(pub f64); + +impl Ord for Ordf32 { + #[inline] + fn cmp(&self, other: &Self) -> core::cmp::Ordering { + let mut left = self.0.to_bits() as i32; + let mut right = other.0.to_bits() as i32; + left ^= (((left >> 31) as u32) >> 1) as i32; + right ^= (((right >> 31) as u32) >> 1) as i32; + left.cmp(&right) + } +} + +impl Ord for Ordf64 { + #[inline] + fn cmp(&self, other: &Self) -> core::cmp::Ordering { + let mut left = self.0.to_bits() as i64; + let mut right = other.0.to_bits() as i64; + left ^= (((left >> 63) as u64) >> 1) as i64; + right ^= (((right >> 63) as u64) >> 1) as i64; + left.cmp(&right) + } +} + +impl Eq for Ordf64 {} +impl Eq for Ordf32 {} + +impl PartialOrd for Ordf32 { + fn partial_cmp(&self, other: &Self) -> Option { + Some(self.cmp(other)) + } +} + +impl PartialOrd for Ordf64 { + fn partial_cmp(&self, other: &Self) -> Option { + Some(self.cmp(other)) + } +} + +impl core::fmt::Display for Ordf32 { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + self.0.fmt(f) + } +} + +impl core::fmt::Display for Ordf64 { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + self.0.fmt(f) + } +} + +/// Extension trait for adding basic float ops to f32 that don't exist in core for reasons. +pub trait FloatExt { + /// Adds the ceil method to `f32` + fn ceil(self) -> Self; +} + +impl FloatExt for f32 { + fn ceil(self) -> Self { + // From https://doc.rust-lang.org/reference/expressions/operator-expr.html#type-cast-expressions + // > Casting from a float to an integer will round the float towards zero + // > Casting from an integer to float will produce the closest possible float + let floored_towards_zero = (self as i32) as f32; + if self < 0.0 || floored_towards_zero == self { + floored_towards_zero + } else { + floored_towards_zero + 1.0 + } + } +} + +#[cfg(test)] +mod test { + use super::*; + #[test] + fn ceil32() { + assert_eq!((-1.1).ceil(), -1.0); + assert_eq!((-0.1).ceil(), 0.0); + assert_eq!((0.0).ceil(), 0.0); + assert_eq!((1.0).ceil(), 1.0); + assert_eq!((1.1).ceil(), 2.0); + assert_eq!((2.9).ceil(), 3.0); + } +} diff --git a/nursery/coin_select/src/lib.rs b/nursery/coin_select/src/lib.rs index dc38c676d..197b5f9d1 100644 --- a/nursery/coin_select/src/lib.rs +++ b/nursery/coin_select/src/lib.rs @@ -1,33 +1,63 @@ #![no_std] +#![warn(missing_docs)] +#![doc = include_str!("../README.md")] +#![deny(unsafe_code)] -#[cfg(feature = "std")] -extern crate std; - +#[allow(unused_imports)] #[macro_use] extern crate alloc; -extern crate bdk_chain; -use alloc::vec::Vec; -use bdk_chain::{ - bitcoin, - collections::{BTreeSet, HashMap}, -}; -use bitcoin::{absolute, Transaction, TxOut}; -use core::fmt::{Debug, Display}; +#[cfg(feature = "std")] +#[macro_use] +extern crate std; mod coin_selector; +pub mod float; pub use coin_selector::*; mod bnb; pub use bnb::*; -/// Txin "base" fields include `outpoint` (32+4) and `nSequence` (4). This does not include -/// `scriptSigLen` or `scriptSig`. -pub const TXIN_BASE_WEIGHT: u32 = (32 + 4 + 4) * 4; +pub mod metrics; + +mod feerate; +pub use feerate::*; +pub mod change_policy; + +/// Txin "base" fields include `outpoint` (32+4) and `nSequence` (4) and 1 byte for the scriptSig +/// length. +pub const TXIN_BASE_WEIGHT: u32 = (32 + 4 + 4 + 1) * 4; + +/// 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::() as u32 + // The spk length + + (4 * 1); + +/// The additional weight over [`TXIN_BASE_WEIGHT`] incurred by satisfying an input with a keyspend +/// and the default sighash. +pub const TR_KEYSPEND_SATISFACTION_WEIGHT: u32 = 66; + +/// The additional weight of an output with segwit `v1` (taproot) script pubkey over a blank output (i.e. with weight [`TXOUT_BASE_WEIGHT`]). +pub const TR_SPK_WEIGHT: u32 = (1 + 1 + 32) * 4; // version + push + key /// Helper to calculate varint size. `v` is the value the varint represents. -// Shamelessly copied from -// https://github.com/rust-bitcoin/rust-miniscript/blob/d5615acda1a7fdc4041a11c1736af139b8c7ebe8/src/util.rs#L8 -pub(crate) fn varint_size(v: usize) -> u32 { - bitcoin::VarInt(v as u64).len() as u32 +fn varint_size(v: usize) -> u32 { + if v <= 0xfc { + return 1; + } + if v <= 0xffff { + return 3; + } + if v <= 0xffff_ffff { + return 5; + } + 9 +} + +#[allow(unused)] +fn txout_weight_from_spk_len(spk_len: usize) -> u32 { + (TXOUT_BASE_WEIGHT + varint_size(spk_len) + (spk_len as u32)) * 4 } diff --git a/nursery/coin_select/src/metrics.rs b/nursery/coin_select/src/metrics.rs new file mode 100644 index 000000000..e1dcabd35 --- /dev/null +++ b/nursery/coin_select/src/metrics.rs @@ -0,0 +1,84 @@ +//! Branch and bound metrics that can be passed to [`CoinSelector::bnb_solutions`] or +//! [`CoinSelector::run_bnb`]. +use crate::{bnb::BnbMetric, float::Ordf32, CoinSelector, Drain, Target}; +mod waste; +pub use waste::*; +mod lowest_fee; +pub use lowest_fee::*; +mod changeless; +pub use changeless::*; + +// Returns a drain if the current selection and every possible future selection would have a change +// output (otherwise Drain::none()) by using the heurisitic that if it has change with the current +// selection and it has one when we select every negative effective value candidate then it will +// always have change. We are essentially assuming that the change_policy is monotone with respect +// to the excess of the selection. +// +// NOTE: this should stay private because it requires cs to be sorted such that all negative +// effective value candidates are next to each other. +fn change_lower_bound<'a>( + cs: &CoinSelector<'a>, + target: Target, + change_policy: &impl Fn(&CoinSelector<'a>, Target) -> Drain, +) -> Drain { + let has_change_now = change_policy(cs, target).is_some(); + + if has_change_now { + let mut least_excess = cs.clone(); + cs.unselected() + .rev() + .take_while(|(_, wv)| wv.effective_value(target.feerate) < Ordf32(0.0)) + .for_each(|(index, _)| { + least_excess.select(index); + }); + + change_policy(&least_excess, target) + } else { + Drain::none() + } +} + +macro_rules! impl_for_tuple { + ($($a:ident $b:tt)*) => { + impl<$($a),*> BnbMetric for ($(($a, f32)),*) + where $($a: BnbMetric),* + { + #[allow(unused)] + fn score(&mut self, cs: &CoinSelector<'_>) -> Option { + let mut acc = Option::::None; + for (score, ratio) in [$((self.$b.0.score(cs)?, self.$b.1)),*] { + let score: Ordf32 = score; + let ratio: f32 = ratio; + match &mut acc { + Some(acc) => *acc += score.0 * ratio, + acc => *acc = Some(score.0 * ratio), + } + } + acc.map(Ordf32) + } + #[allow(unused)] + fn bound(&mut self, cs: &CoinSelector<'_>) -> Option { + let mut acc = Option::::None; + for (score, ratio) in [$((self.$b.0.bound(cs)?, self.$b.1)),*] { + let score: Ordf32 = score; + let ratio: f32 = ratio; + match &mut acc { + Some(acc) => *acc += score.0 * ratio, + acc => *acc = Some(score.0 * ratio), + } + } + acc.map(Ordf32) + } + #[allow(unused)] + fn requires_ordering_by_descending_value_pwu(&self) -> bool { + [$(self.$b.0.requires_ordering_by_descending_value_pwu()),*].iter().all(|x| *x) + } + } + }; +} + +impl_for_tuple!(); +impl_for_tuple!(A 0 B 1); +impl_for_tuple!(A 0 B 1 C 2); +impl_for_tuple!(A 0 B 1 C 2 D 3); +impl_for_tuple!(A 0 B 1 C 2 D 3 E 4); diff --git a/nursery/coin_select/src/metrics/changeless.rs b/nursery/coin_select/src/metrics/changeless.rs new file mode 100644 index 000000000..c2cec09e5 --- /dev/null +++ b/nursery/coin_select/src/metrics/changeless.rs @@ -0,0 +1,42 @@ +use super::change_lower_bound; +use crate::{bnb::BnbMetric, float::Ordf32, CoinSelector, Drain, Target}; + +/// Metric for finding changeless solutions only. +pub struct Changeless<'c, C> { + /// The target parameters for the resultant selection. + pub target: Target, + /// Policy to determine whether a selection requires a change output. + pub change_policy: &'c C, +} + +impl<'c, C> BnbMetric for Changeless<'c, C> +where + for<'a, 'b> C: Fn(&'b CoinSelector<'a>, Target) -> Drain, +{ + fn score(&mut self, cs: &CoinSelector<'_>) -> Option { + let drain = (self.change_policy)(cs, self.target); + if cs.is_target_met(self.target, drain) && (*self.change_policy)(cs, self.target).is_none() + { + Some(Ordf32(0.0)) + } else { + None + } + // if !cs.is_target_met(self.target, drain) { + // None + // } else { + // Some(Ordf32(0.0)) + // } + } + + fn bound(&mut self, cs: &CoinSelector<'_>) -> Option { + if change_lower_bound(cs, self.target, &self.change_policy).is_some() { + None + } else { + Some(Ordf32(0.0)) + } + } + + fn requires_ordering_by_descending_value_pwu(&self) -> bool { + true + } +} diff --git a/nursery/coin_select/src/metrics/lowest_fee.rs b/nursery/coin_select/src/metrics/lowest_fee.rs new file mode 100644 index 000000000..ca2d77ada --- /dev/null +++ b/nursery/coin_select/src/metrics/lowest_fee.rs @@ -0,0 +1,175 @@ +use crate::{ + float::Ordf32, metrics::change_lower_bound, BnbMetric, Candidate, CoinSelector, Drain, + DrainWeights, FeeRate, Target, +}; + +/// Metric that aims to minimize transaction fees. The future fee for spending the change output is +/// included in this calculation. +/// +/// The scoring function for changeless solutions is: +/// > input_weight * feerate + excess +/// +/// The scoring function for solutions with change: +/// > (input_weight + change_output_weight) * feerate + change_spend_weight * long_term_feerate +pub struct LowestFee<'c, C> { + /// The target parameters for the resultant selection. + pub target: Target, + /// The estimated feerate needed to spend our change output later. + pub long_term_feerate: FeeRate, + /// Policy to determine the change output (if any) of a given selection. + pub change_policy: &'c C, +} + +impl<'c, C> Clone for LowestFee<'c, C> { + fn clone(&self) -> Self { + *self + } +} + +impl<'c, C> Copy for LowestFee<'c, C> {} + +impl<'c, C> LowestFee<'c, C> +where + for<'a, 'b> C: Fn(&'b CoinSelector<'a>, Target) -> Drain, +{ + fn calc_metric(&self, cs: &CoinSelector<'_>, drain_weights: Option) -> f32 { + self.calc_metric_lb(cs, drain_weights) + + match drain_weights { + Some(_) => { + let selected_value = cs.selected_value(); + assert!(selected_value >= self.target.value); + (cs.selected_value() - self.target.value) as f32 + } + None => 0.0, + } + } + + fn calc_metric_lb(&self, cs: &CoinSelector<'_>, drain_weights: Option) -> f32 { + match drain_weights { + // with change + Some(drain_weights) => { + (cs.input_weight() + drain_weights.output_weight) as f32 + * self.target.feerate.spwu() + + drain_weights.spend_weight as f32 * self.long_term_feerate.spwu() + } + // changeless + None => cs.input_weight() as f32 * self.target.feerate.spwu(), + } + } +} + +impl<'c, C> BnbMetric for LowestFee<'c, C> +where + for<'a, 'b> C: Fn(&'b CoinSelector<'a>, Target) -> Drain, +{ + fn score(&mut self, cs: &CoinSelector<'_>) -> Option { + let drain = (self.change_policy)(cs, self.target); + if !cs.is_target_met(self.target, drain) { + return None; + } + + let drain_weights = if drain.is_some() { + Some(drain.weights) + } else { + None + }; + + Some(Ordf32(self.calc_metric(cs, drain_weights))) + } + + fn bound(&mut self, cs: &CoinSelector<'_>) -> Option { + // this either returns: + // * None: change output may or may not exist + // * Some: change output must exist from this branch onwards + let change_lb = change_lower_bound(cs, self.target, &self.change_policy); + let change_lb_weights = if change_lb.is_some() { + Some(change_lb.weights) + } else { + None + }; + // println!("\tchange lb: {:?}", change_lb_weights); + + if cs.is_target_met(self.target, change_lb) { + // Target is met, is it possible to add further inputs to remove drain output? + // If we do, can we get a better score? + + // First lower bound candidate is just the selection itself (include excess). + let mut lower_bound = self.calc_metric(cs, change_lb_weights); + + if change_lb_weights.is_none() { + // Since a changeless solution may exist, we should try minimize the excess with by + // adding as much -ev candidates as possible + let selection_with_as_much_negative_ev_as_possible = cs + .clone() + .select_iter() + .rev() + .take_while(|(cs, _, candidate)| { + candidate.effective_value(self.target.feerate).0 < 0.0 + && cs.is_target_met(self.target, Drain::none()) + }) + .last() + .map(|(cs, _, _)| cs); + + if let Some(cs) = selection_with_as_much_negative_ev_as_possible { + // we have selected as much "real" inputs as possible, is it possible to select + // one more with the perfect weight? + let can_do_better_by_slurping = + cs.unselected().next_back().and_then(|(_, candidate)| { + if candidate.effective_value(self.target.feerate).0 < 0.0 { + Some(candidate) + } else { + None + } + }); + let lower_bound_changeless = match can_do_better_by_slurping { + Some(finishing_input) => { + let excess = cs.rate_excess(self.target, Drain::none()); + + // change the input's weight to make it's effective value match the excess + let perfect_input_weight = slurp(self.target, excess, finishing_input); + + (cs.input_weight() as f32 + perfect_input_weight) + * self.target.feerate.spwu() + } + None => self.calc_metric(&cs, None), + }; + + lower_bound = lower_bound.min(lower_bound_changeless) + } + } + + return Some(Ordf32(lower_bound)); + } + + // target is not met yet + // select until we just exceed target, then we slurp the last selection + let (mut cs, slurp_index, candidate_to_slurp) = cs + .clone() + .select_iter() + .find(|(cs, _, _)| cs.is_target_met(self.target, change_lb))?; + cs.deselect(slurp_index); + + let mut lower_bound = self.calc_metric_lb(&cs, change_lb_weights); + + // find the max excess we need to rid of + let perfect_excess = i64::max( + cs.rate_excess(self.target, Drain::none()), + cs.absolute_excess(self.target, Drain::none()), + ); + // use the highest excess to find "perfect candidate weight" + let perfect_input_weight = slurp(self.target, perfect_excess, candidate_to_slurp); + lower_bound += perfect_input_weight * self.target.feerate.spwu(); + + Some(Ordf32(lower_bound)) + } + + fn requires_ordering_by_descending_value_pwu(&self) -> bool { + true + } +} + +fn slurp(target: Target, excess: i64, candidate: Candidate) -> f32 { + let vpw = candidate.value_pwu().0; + let perfect_weight = -excess as f32 / (vpw - target.feerate.spwu()); + perfect_weight.max(0.0) +} diff --git a/nursery/coin_select/src/metrics/waste.rs b/nursery/coin_select/src/metrics/waste.rs new file mode 100644 index 000000000..088ee2243 --- /dev/null +++ b/nursery/coin_select/src/metrics/waste.rs @@ -0,0 +1,252 @@ +use super::change_lower_bound; +use crate::{bnb::BnbMetric, float::Ordf32, Candidate, CoinSelector, Drain, FeeRate, Target}; + +/// The "waste" metric used by bitcoin core. +/// +/// See this [great explanation](https://bitcoin.stackexchange.com/questions/113622/what-does-waste-metric-mean-in-the-context-of-coin-selection) +/// for an understanding of the waste metric. +/// +/// ## WARNING: Waste metric considered wasteful +/// +/// Note that bitcoin core at the time of writing use the waste metric to +/// +/// 1. minimise the waste while searching for changeless solutions. +/// 2. It tiebreaks multiple valid selections from different algorithms (which do not try and +/// minimise waste) with waste. +/// +/// This is **very** different from minimising waste in general which is what this metric will do +/// when used in [`CoinSelector::bnb_solutions`]. The waste metric tends to over consolidate funds. +/// If the `long_term_feerate` is even slightly higher than the current feerate (specified in +/// `target`) it will select all your coins! +pub struct Waste<'c, C> { + /// The target parameters of the resultant selection. + pub target: Target, + /// The longterm feerate as part of the waste metric. + pub long_term_feerate: FeeRate, + /// Policy to determine the change output (if any) of a given selection. + pub change_policy: &'c C, +} + +impl<'c, C> Clone for Waste<'c, C> { + fn clone(&self) -> Self { + *self + } +} + +impl<'c, C> Copy for Waste<'c, C> {} + +impl<'c, C> BnbMetric for Waste<'c, C> +where + for<'a, 'b> C: Fn(&'b CoinSelector<'a>, Target) -> Drain, +{ + fn score(&mut self, cs: &CoinSelector<'_>) -> Option { + let drain = (self.change_policy)(cs, self.target); + if !cs.is_target_met(self.target, drain) { + return None; + } + let score = cs.waste(self.target, self.long_term_feerate, drain, 1.0); + Some(Ordf32(score)) + } + + fn bound(&mut self, cs: &CoinSelector<'_>) -> Option { + // Welcome my bretheren. This dungeon was authored by Lloyd Fournier A.K.A "LLFourn" with + // the assistance of chat GPT and the developers of the IOTA cryptocurrency. There are + // comments trying to make sense of the logic here but it's really just me pretending I know + // what's going on. I have tried to simplify the logic here many times but always end up + // making it fail proptests. + // + // Don't be afraid. This function is a "heuristic" lower bound. It doesn't need to be super + // duper correct. In testing it seems to come up with pretty good results pretty fast. + let rate_diff = self.target.feerate.spwu() - self.long_term_feerate.spwu(); + // whether from this coin selection it's possible to avoid change + let change_lower_bound = change_lower_bound(cs, self.target, &self.change_policy); + const IGNORE_EXCESS: f32 = 0.0; + const INCLUDE_EXCESS: f32 = 1.0; + + if rate_diff >= 0.0 { + // Our lower bound algorithms differ depending on whether we have already met the target or not. + if cs.is_target_met(self.target, change_lower_bound) { + let current_change = (self.change_policy)(cs, self.target); + + // first lower bound candidate is just the selection itself + let mut lower_bound = cs.waste( + self.target, + self.long_term_feerate, + current_change, + INCLUDE_EXCESS, + ); + + // But don't stop there we might be able to select negative value inputs which might + // lower excess and reduce waste either by: + // - removing the need for a change output + // - reducing the excess if the current selection is changeless (only possible when rate_diff is small). + let should_explore_changeless = change_lower_bound.is_none(); + + if should_explore_changeless { + let selection_with_as_much_negative_ev_as_possible = cs + .clone() + .select_iter() + .rev() + .take_while(|(cs, _, wv)| { + wv.effective_value(self.target.feerate).0 < 0.0 + && cs.is_target_met(self.target, Drain::none()) + }) + .last(); + + if let Some((cs, _, _)) = selection_with_as_much_negative_ev_as_possible { + let can_do_better_by_slurping = + cs.unselected().next_back().and_then(|(_, wv)| { + if wv.effective_value(self.target.feerate).0 < 0.0 { + Some(wv) + } else { + None + } + }); + let lower_bound_without_change = match can_do_better_by_slurping { + Some(finishing_input) => { + // NOTE we are slurping negative value here to try and reduce excess in + // the hopes of getting rid of the change output + let value_to_slurp = -cs.rate_excess(self.target, Drain::none()); + let weight_to_extinguish_excess = + slurp_wv(finishing_input, value_to_slurp, self.target.feerate); + let waste_to_extinguish_excess = + weight_to_extinguish_excess * rate_diff; + // return: waste after excess reduction + cs.waste( + self.target, + self.long_term_feerate, + Drain::none(), + IGNORE_EXCESS, + ) + waste_to_extinguish_excess + } + None => cs.waste( + self.target, + self.long_term_feerate, + Drain::none(), + INCLUDE_EXCESS, + ), + }; + + lower_bound = lower_bound.min(lower_bound_without_change); + } + } + + Some(Ordf32(lower_bound)) + } else { + // If feerate >= long_term_feerate, You *might* think that the waste lower bound + // here is just the fewest number of inputs we need to meet the target but **no**. + // Consider if there is 1 sat remaining to reach target. Should you add all the + // weight of the next input for the waste calculation? *No* this leaads to a + // pesimistic lower bound even if we ignore the excess because it adds too much + // weight. + // + // Step 1: select everything up until the input that hits the target. + let (mut cs, slurp_index, to_slurp) = cs + .clone() + .select_iter() + .find(|(cs, _, _)| cs.is_target_met(self.target, change_lower_bound))?; + + cs.deselect(slurp_index); + + // Step 2: We pretend that the final input exactly cancels out the remaining excess + // by taking whatever value we want from it but at the value per weight of the real + // input. + let ideal_next_weight = { + // satisfying absolute and feerate constraints requires different calculations so we do them + // both independently and find which requires the most weight of the next input. + let remaining_rate = cs.rate_excess(self.target, change_lower_bound); + let remaining_abs = cs.absolute_excess(self.target, change_lower_bound); + + let weight_to_satisfy_abs = + remaining_abs.min(0) as f32 / to_slurp.value_pwu().0; + + let weight_to_satisfy_rate = + slurp_wv(to_slurp, remaining_rate.min(0), self.target.feerate); + + let weight_to_satisfy = weight_to_satisfy_abs.max(weight_to_satisfy_rate); + debug_assert!(weight_to_satisfy <= to_slurp.weight as f32); + weight_to_satisfy + }; + 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); + + Some(Ordf32(waste)) + } + } else { + // When long_term_feerate > current feerate each input by itself has negative waste. + // This doesn't mean that waste monotonically decreases as you add inputs because + // somewhere along the line adding an input might cause the change policy to add a + // change ouput which could increase waste. + // + // So we have to try two things and we which one is best to find the lower bound: + // 1. try selecting everything regardless of change + let mut lower_bound = { + let mut cs = cs.clone(); + // ... but first check that by selecting all effective we can actually reach target + cs.select_all_effective(self.target.feerate); + if !cs.is_target_met(self.target, Drain::none()) { + return None; + } + let change_at_value_optimum = (self.change_policy)(&cs, self.target); + cs.select_all(); + // NOTE: we use the change from our "all effective" selection for min waste since + // selecting all might not have change but in that case we'll catch it below. + cs.waste( + self.target, + self.long_term_feerate, + change_at_value_optimum, + IGNORE_EXCESS, + ) + }; + + let look_for_changeless_solution = change_lower_bound.is_none(); + + if look_for_changeless_solution { + // 2. select the highest weight solution with no change + let highest_weight_selection_without_change = cs + .clone() + .select_iter() + .rev() + .take_while(|(cs, _, wv)| { + wv.effective_value(self.target.feerate).0 < 0.0 + || (self.change_policy)(cs, self.target).is_none() + }) + .last(); + + if let Some((cs, _, _)) = highest_weight_selection_without_change { + let no_change_waste = cs.waste( + self.target, + self.long_term_feerate, + Drain::none(), + IGNORE_EXCESS, + ); + + lower_bound = lower_bound.min(no_change_waste) + } + } + + Some(Ordf32(lower_bound)) + } + } + + fn requires_ordering_by_descending_value_pwu(&self) -> bool { + true + } +} + +/// Returns the "perfect weight" for this candidate to slurp up a given value with `feerate` while +/// not changing the candidate's value/weight ratio. +/// +/// Used to pretend that a candidate had precisely `value_to_slurp` + fee needed to include it. It +/// tells you how much weight such a perfect candidate would have if it had the same value per +/// weight unit as `candidate`. This is useful for estimating a lower weight bound for a perfect +/// match. +fn slurp_wv(candidate: Candidate, value_to_slurp: i64, feerate: FeeRate) -> f32 { + // the value per weight unit this candidate offers at feerate + let value_per_wu = (candidate.value as f32 / candidate.weight as f32) - feerate.spwu(); + // return how much weight we need + let weight_needed = value_to_slurp as f32 / value_per_wu; + debug_assert!(weight_needed <= candidate.weight as f32); + weight_needed.min(0.0) +} diff --git a/nursery/coin_select/tests/bnb.proptest-regressions b/nursery/coin_select/tests/bnb.proptest-regressions new file mode 100644 index 000000000..1c364079f --- /dev/null +++ b/nursery/coin_select/tests/bnb.proptest-regressions @@ -0,0 +1,11 @@ +# Seeds for failure cases proptest has generated in the past. It is +# automatically read and these particular cases re-run before any +# novel cases are generated. +# +# It is recommended to check this file in to source control so that +# everyone who runs the test benefits from these saved cases. +cc 22ecc118934d2c7e620751b2a8940e88aa07639a1d567861e1a04be9750c4291 # shrinks to n_candidates = 4, target_value = 500, base_weight = 0, min_fee = 0, feerate = 59.03896, feerate_lt_diff = 0.0, drain_weight = 100, drain_spend_weight = 1, drain_dust = 100 +cc 6095f674d510fabaf130dd1d5bc58ba13ef6fc4bab986672f3c89b375d09325a # shrinks to n_candidates = 4, target_value = 500, base_weight = 0, min_fee = 0, feerate = 63.823364, feerate_lt_diff = 0.0, drain_weight = 100, drain_spend_weight = 1, drain_dust = 100 +cc f523c5d000e56a3193f212f9cc84ca60ee5f6f3b78fc4ab32063f1a05afc5a1b # shrinks to n_candidates = 12, target_value = 500, base_weight = 0, min_fee = 0, feerate = 79.459656, feerate_lt_diff = 0.0, drain_weight = 100, drain_spend_weight = 1, drain_dust = 100 +cc 6011be0850184ddcc369cc30de0f92ac4a42046daefdaf4393e551edb82ed23b # shrinks to solution_len = 1, num_additional_canidates = 0, num_preselected = 0 +cc a4f560d934de55fa1f17589e7a0bf22aab4bc77c9ae8dad8a623f7ad71d9ebfa # shrinks to num_inputs = 5, target = 2474 diff --git a/nursery/coin_select/tests/bnb.rs b/nursery/coin_select/tests/bnb.rs new file mode 100644 index 000000000..c9df3f5df --- /dev/null +++ b/nursery/coin_select/tests/bnb.rs @@ -0,0 +1,211 @@ +mod common; +use bdk_coin_select::{float::Ordf32, BnbMetric, Candidate, CoinSelector, Drain, FeeRate, Target}; +#[macro_use] +extern crate alloc; + +use alloc::vec::Vec; +use proptest::{prelude::*, proptest, test_runner::*}; + +fn test_wv(mut rng: impl RngCore) -> impl Iterator { + core::iter::repeat_with(move || { + let value = rng.gen_range(0, 1_000); + 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 + }) +} + +struct MinExcessThenWeight { + target: Target, +} + +/// Assumes tx weight is less than 1MB. +const EXCESS_RATIO: f32 = 1_000_000_f32; + +impl BnbMetric for MinExcessThenWeight { + fn score(&mut self, cs: &CoinSelector<'_>) -> Option { + let excess = cs.excess(self.target, Drain::none()); + if excess < 0 { + None + } else { + Some(Ordf32( + excess as f32 * EXCESS_RATIO + cs.input_weight() as f32, + )) + } + } + + fn bound(&mut self, cs: &CoinSelector<'_>) -> Option { + let mut cs = cs.clone(); + cs.select_until_target_met(self.target, Drain::none()) + .ok()?; + if let Some(last_index) = cs.selected_indices().iter().last().copied() { + cs.deselect(last_index); + } + Some(Ordf32( + cs.excess(self.target, Drain::none()) as f32 * EXCESS_RATIO + cs.input_weight() as f32, + )) + } +} + +#[test] +/// Detect regressions/improvements by making sure it always finds the solution in the same +/// number of iterations. +fn bnb_finds_an_exact_solution_in_n_iter() { + let solution_len = 6; + let num_additional_canidates = 12; + + let mut rng = TestRng::deterministic_rng(RngAlgorithm::ChaCha); + let mut wv = test_wv(&mut rng).map(|mut candidate| { + candidate.is_segwit = true; + candidate + }); + + let solution: Vec = (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 mut candidates = solution; + candidates.extend(wv.take(num_additional_canidates)); + candidates.sort_unstable_by_key(|wv| core::cmp::Reverse(wv.value)); + + let cs = CoinSelector::new(&candidates, 0); + + let target = Target { + value: target, + // we're trying to find an exact selection value so set fees to 0 + feerate: FeeRate::zero(), + min_fee: 0, + }; + + let solutions = cs.bnb_solutions(MinExcessThenWeight { target }); + + let mut rounds = 0; + let (best, score) = solutions + .enumerate() + .inspect(|(i, _)| rounds = *i + 1) + .filter_map(|(_, sol)| sol) + .last() + .expect("it found a solution"); + + assert_eq!(rounds, 50180); + assert_eq!(best.input_weight(), solution_weight); + assert_eq!(best.selected_value(), target.value, "score={:?}", score); +} + +#[test] +fn bnb_finds_solution_if_possible_in_n_iter() { + let num_inputs = 18; + let target = 8_314; + let mut rng = TestRng::deterministic_rng(RngAlgorithm::ChaCha); + let wv = test_wv(&mut rng); + let candidates = wv.take(num_inputs).collect::>(); + + let cs = CoinSelector::new(&candidates, 0); + + let target = Target { + value: target, + feerate: FeeRate::default_min_relay_fee(), + min_fee: 0, + }; + + let solutions = cs.bnb_solutions(MinExcessThenWeight { target }); + + let mut rounds = 0; + let (sol, _score) = solutions + .enumerate() + .inspect(|(i, _)| rounds = *i + 1) + .filter_map(|(_, sol)| sol) + .last() + .expect("found a solution"); + + assert_eq!(rounds, 202); + let excess = sol.excess(target, Drain::none()); + assert_eq!(excess, 8); +} + +proptest! { + #[test] + fn bnb_always_finds_solution_if_possible(num_inputs in 1usize..18, target in 0u64..10_000) { + let mut rng = TestRng::deterministic_rng(RngAlgorithm::ChaCha); + let wv = test_wv(&mut rng); + let candidates = wv.take(num_inputs).collect::>(); + let cs = CoinSelector::new(&candidates, 0); + + let target = Target { + value: target, + feerate: FeeRate::zero(), + min_fee: 0, + }; + + let solutions = cs.bnb_solutions(MinExcessThenWeight { target }); + + match solutions.enumerate().filter_map(|(i, sol)| Some((i, sol?))).last() { + Some((_i, (sol, _score))) => assert!(sol.selected_value() >= target.value), + _ => prop_assert!(!cs.is_selection_possible(target, Drain::none())), + } + } + + #[test] + #[cfg(not(debug_assertions))] // too slow if compiling for debug + fn bnb_always_finds_exact_solution_eventually( + solution_len in 1usize..8, + num_additional_canidates in 0usize..16, + num_preselected in 0usize..8 + ) { + let mut rng = TestRng::deterministic_rng(RngAlgorithm::ChaCha); + let mut wv = test_wv(&mut rng); + + let solution: Vec = (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 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); + } + + // sort in descending value + cs.sort_candidates_by_key(|(_, wv)| core::cmp::Reverse(wv.value)); + + let target = Target { + value: target, + // we're trying to find an exact selection value so set fees to 0 + feerate: FeeRate::zero(), + min_fee: 0 + }; + + let solutions = cs.bnb_solutions(MinExcessThenWeight { target }); + + let (_i, (best, _score)) = solutions + .enumerate() + .filter_map(|(i, sol)| Some((i, sol?))) + .last() + .expect("it found a solution"); + + prop_assert!(best.input_weight() <= solution_weight); + prop_assert_eq!(best.selected_value(), target.value); + } +} diff --git a/nursery/coin_select/tests/changeless.proptest-regressions b/nursery/coin_select/tests/changeless.proptest-regressions new file mode 100644 index 000000000..b515b1b5c --- /dev/null +++ b/nursery/coin_select/tests/changeless.proptest-regressions @@ -0,0 +1,7 @@ +# Seeds for failure cases proptest has generated in the past. It is +# automatically read and these particular cases re-run before any +# novel cases are generated. +# +# It is recommended to check this file in to source control so that +# everyone who runs the test benefits from these saved cases. +cc b03fc0267d15cf4455c7f00feed18d1ba82a783a38bf689dacdd572356013877 # shrinks to num_inputs = 7, target = 1277, feerate = 1.0, min_fee = 177, base_weight = 0, long_term_feerate_diff = 0.0, change_weight = 1, change_spend_weight = 1 diff --git a/nursery/coin_select/tests/changeless.rs b/nursery/coin_select/tests/changeless.rs new file mode 100644 index 000000000..5607fde39 --- /dev/null +++ b/nursery/coin_select/tests/changeless.rs @@ -0,0 +1,122 @@ +#![allow(unused)] +mod common; +use bdk_coin_select::{ + float::Ordf32, metrics, Candidate, CoinSelector, Drain, DrainWeights, FeeRate, Target, +}; +use proptest::{prelude::*, proptest, test_runner::*}; +use rand::{prelude::IteratorRandom, Rng, RngCore}; + +fn test_wv(mut rng: impl RngCore) -> impl Iterator { + core::iter::repeat_with(move || { + let value = rng.gen_range(0, 1_000); + Candidate { + value, + weight: rng.gen_range(0, 100), + input_count: rng.gen_range(1, 2), + is_segwit: rng.gen_bool(0.5), + } + }) +} + +proptest! { + #![proptest_config(ProptestConfig { + timeout: 1_000, + cases: 1_000, + ..Default::default() + })] + #[test] + #[cfg(not(debug_assertions))] // too slow if compiling for debug + fn changeless_prop( + num_inputs in 0usize..15, + target in 0u64..15_000, + feerate in 1.0f32..10.0, + min_fee in 0u64..1_000, + base_weight in 0u32..500, + long_term_feerate_diff in -5.0f32..5.0, + change_weight in 1u32..100, + change_spend_weight in 1u32..100, + ) { + println!("======================================="); + let start = std::time::Instant::now(); + let mut rng = TestRng::deterministic_rng(RngAlgorithm::ChaCha); + let long_term_feerate = FeeRate::from_sat_per_vb(0.0f32.max(feerate - long_term_feerate_diff)); + let feerate = FeeRate::from_sat_per_vb(feerate); + let drain = DrainWeights { + output_weight: change_weight, + spend_weight: change_spend_weight, + }; + + 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::>(); + println!("candidates: {:#?}", candidates); + + let cs = CoinSelector::new(&candidates, base_weight); + + let target = Target { + value: target, + feerate, + min_fee + }; + + let solutions = cs.bnb_solutions(metrics::Changeless { + target, + change_policy: &change_policy + }); + + + let best = solutions + .enumerate() + .filter_map(|(i, sol)| Some((i, sol?))) + .last(); + + + match best { + Some((_i, (sol, _score))) => { + let mut cmp_benchmarks = vec![ + { + let mut naive_select = cs.clone(); + naive_select.sort_candidates_by_key(|(_, wv)| core::cmp::Reverse(wv.effective_value(target.feerate))); + // we filter out failing onces below + let _ = naive_select.select_until_target_met(target, Drain { weights: drain, value: 0 }); + naive_select + }, + ]; + + cmp_benchmarks.extend((0..10).map(|_|random_minimal_selection(&cs, target, long_term_feerate, &change_policy, &mut rng))); + + let cmp_benchmarks = cmp_benchmarks.into_iter().filter(|cs| cs.is_target_met(target, change_policy(&cs, target))); + for (_bench_id, bench) in cmp_benchmarks.enumerate() { + prop_assert!(change_policy(&bench, target).is_some() >= change_policy(&sol, target).is_some()); + } + } + None => { + let mut cs = cs.clone(); + let mut metric = metrics::Changeless { target, change_policy: &change_policy }; + let has_solution = common::exhaustive_search(&mut cs, &mut metric).is_some(); + assert!(!has_solution); + } + } + dbg!(start.elapsed()); + } +} + +// this is probably a useful thing to have on CoinSelector but I don't want to design it yet +#[allow(unused)] +fn random_minimal_selection<'a>( + cs: &CoinSelector<'a>, + target: Target, + long_term_feerate: FeeRate, + change_policy: &impl Fn(&CoinSelector, Target) -> Drain, + rng: &mut impl RngCore, +) -> CoinSelector<'a> { + let mut cs = cs.clone(); + let mut last_waste: Option = None; + while let Some(next) = cs.unselected_indices().choose(rng) { + cs.select(next); + if cs.is_target_met(target, change_policy(&cs, target)) { + break; + } + } + cs +} diff --git a/nursery/coin_select/tests/common.rs b/nursery/coin_select/tests/common.rs new file mode 100644 index 000000000..91d0cd04d --- /dev/null +++ b/nursery/coin_select/tests/common.rs @@ -0,0 +1,359 @@ +#![allow(dead_code)] + +use std::any::type_name; + +use bdk_coin_select::{ + float::Ordf32, BnbMetric, Candidate, CoinSelector, Drain, DrainWeights, FeeRate, NoBnbSolution, + Target, +}; +use proptest::{ + prelude::*, + prop_assert, prop_assert_eq, + test_runner::{RngAlgorithm, TestRng}, +}; + +/// Used for constructing a proptest that compares an exhaustive search result with a bnb result +/// with the given metric. +/// +/// We don't restrict bnb rounds, so we expect that the bnb result to be equal to the exhaustive +/// search result. +pub fn can_eventually_find_best_solution( + params: StrategyParams, + candidates: Vec, + change_policy: &P, + mut metric: M, +) -> Result<(), proptest::test_runner::TestCaseError> +where + M: BnbMetric, + P: Fn(&CoinSelector, Target) -> Drain, +{ + println!("== TEST =="); + println!("{}", type_name::()); + println!("{:?}", params); + + let target = params.target(); + + let mut selection = CoinSelector::new(&candidates, params.base_weight); + let mut exp_selection = selection.clone(); + + if metric.requires_ordering_by_descending_value_pwu() { + exp_selection.sort_candidates_by_descending_value_pwu(); + } + print_candidates(¶ms, &exp_selection); + + println!("\texhaustive search:"); + let now = std::time::Instant::now(); + let exp_result = exhaustive_search(&mut exp_selection, &mut metric); + let exp_change = change_policy(&exp_selection, target); + let exp_result_str = result_string(&exp_result.ok_or("no possible solution"), exp_change); + println!( + "\t\telapsed={:8}s result={}", + now.elapsed().as_secs_f64(), + exp_result_str + ); + // bonus check: ensure min_fee is respected + if exp_result.is_some() { + let selected_value = exp_selection.selected_value(); + let drain_value = change_policy(&exp_selection, target).value; + let target_value = target.value; + assert!(selected_value - target_value - drain_value >= params.min_fee); + } + + println!("\tbranch and bound:"); + let now = std::time::Instant::now(); + let result = bnb_search(&mut selection, metric, usize::MAX); + let change = change_policy(&selection, target); + let result_str = result_string(&result, change); + println!( + "\t\telapsed={:8}s result={}", + now.elapsed().as_secs_f64(), + result_str + ); + + match exp_result { + Some((score_to_match, _max_rounds)) => { + let (score, _rounds) = result.expect("must find solution"); + // [todo] how do we check that `_rounds` is less than `_max_rounds` MOST of the time? + prop_assert_eq!( + score, + score_to_match, + "score: + got={} + exp={}", + result_str, + exp_result_str + ); + + // bonus check: ensure min_fee is respected + let selected_value = selection.selected_value(); + let drain_value = change_policy(&selection, target).value; + let target_value = target.value; + assert!(selected_value - target_value - drain_value >= params.min_fee); + } + _ => prop_assert!(result.is_err(), "should not find solution"), + } + + Ok(()) +} + +/// Used for constructing a proptest that compares the bound score at every branch with the actual +/// scores of all descendant branches. +/// +/// If this fails, it means the metric's bound function is too tight. +pub fn ensure_bound_is_not_too_tight( + params: StrategyParams, + candidates: Vec, + change_policy: &P, + mut metric: M, +) -> Result<(), proptest::test_runner::TestCaseError> +where + M: BnbMetric, + P: Fn(&CoinSelector, Target) -> Drain, +{ + println!("== TEST =="); + println!("{}", type_name::()); + println!("{:?}", params); + + let target = params.target(); + + let init_cs = { + let mut cs = CoinSelector::new(&candidates, params.base_weight); + if metric.requires_ordering_by_descending_value_pwu() { + cs.sort_candidates_by_descending_value_pwu(); + } + cs + }; + print_candidates(¶ms, &init_cs); + + for (cs, _) in ExhaustiveIter::new(&init_cs).into_iter().flatten() { + if let Some(lb_score) = metric.bound(&cs) { + // This is the branch's lower bound. In other words, this is the BEST selection + // possible (can overshoot) traversing down this branch. Let's check that! + + if let Some(score) = metric.score(&cs) { + prop_assert!( + score >= lb_score, + "checking branch: selection={} score={} change={} lb={}", + cs, + score, + change_policy(&cs, target).is_some(), + lb_score + ); + } + + for (descendant_cs, _) in ExhaustiveIter::new(&cs) + .into_iter() + .flatten() + .filter(|(_, inc)| *inc) + { + if let Some(descendant_score) = metric.score(&descendant_cs) { + prop_assert!( + descendant_score >= lb_score, + " + parent={:8} change={} lb={} target_met={} + descendant={:8} change={} score={} + ", + cs, + change_policy(&cs, target).is_some(), + lb_score, + cs.is_target_met(target, Drain::none()), + descendant_cs, + change_policy(&descendant_cs, target).is_some(), + descendant_score, + ); + } + } + } + } + Ok(()) +} + +#[derive(Debug)] +pub struct StrategyParams { + pub n_candidates: usize, + pub target_value: u64, + pub base_weight: u32, + pub min_fee: u64, + pub feerate: f32, + pub feerate_lt_diff: f32, + pub drain_weight: u32, + pub drain_spend_weight: u32, + pub drain_dust: u64, +} + +impl StrategyParams { + pub fn target(&self) -> Target { + Target { + feerate: self.feerate(), + min_fee: self.min_fee, + value: self.target_value, + } + } + + pub fn feerate(&self) -> FeeRate { + FeeRate::from_sat_per_vb(self.feerate) + } + + pub fn long_term_feerate(&self) -> FeeRate { + FeeRate::from_sat_per_vb((self.feerate + self.feerate_lt_diff).max(1.0)) + } + + pub fn drain_weights(&self) -> DrainWeights { + DrainWeights { + output_weight: self.drain_weight, + spend_weight: self.drain_spend_weight, + } + } +} + +pub fn gen_candidates(n: usize) -> Vec { + let mut rng = TestRng::deterministic_rng(RngAlgorithm::ChaCha); + core::iter::repeat_with(move || { + let value = rng.gen_range(1, 500_001); + let weight = rng.gen_range(1, 2001); + let input_count = rng.gen_range(1, 3); + let is_segwit = rng.gen_bool(0.01); + + Candidate { + value, + weight, + input_count, + is_segwit, + } + }) + .take(n) + .collect() +} + +pub fn print_candidates(params: &StrategyParams, cs: &CoinSelector<'_>) { + println!("\tcandidates:"); + for (i, candidate) in cs.candidates() { + println!( + "\t\t{:3} | ev:{:10.2} | vpw:{:10.2} | waste:{:10.2} | {:?}", + i, + candidate.effective_value(params.feerate()), + candidate.value_pwu(), + candidate.weight as f32 * (params.feerate().spwu() - params.long_term_feerate().spwu()), + candidate, + ); + } +} + +pub struct ExhaustiveIter<'a> { + stack: Vec<(CoinSelector<'a>, bool)>, // for branches: (cs, this_index, include?) +} + +impl<'a> ExhaustiveIter<'a> { + fn new(cs: &CoinSelector<'a>) -> Option { + let mut iter = Self { stack: Vec::new() }; + iter.push_branches(cs); + Some(iter) + } + + fn push_branches(&mut self, cs: &CoinSelector<'a>) { + let next_index = match cs.unselected_indices().next() { + Some(next_index) => next_index, + None => return, + }; + + let inclusion_cs = { + let mut cs = cs.clone(); + assert!(cs.select(next_index)); + cs + }; + self.stack.push((inclusion_cs, true)); + + let exclusion_cs = { + let mut cs = cs.clone(); + cs.ban(next_index); + cs + }; + self.stack.push((exclusion_cs, false)); + } +} + +impl<'a> Iterator for ExhaustiveIter<'a> { + type Item = (CoinSelector<'a>, bool); + + fn next(&mut self) -> Option { + let (cs, inclusion) = self.stack.pop()?; + self.push_branches(&cs); + Some((cs, inclusion)) + } +} + +pub fn exhaustive_search(cs: &mut CoinSelector, metric: &mut M) -> Option<(Ordf32, usize)> +where + M: BnbMetric, +{ + if metric.requires_ordering_by_descending_value_pwu() { + cs.sort_candidates_by_descending_value_pwu(); + } + + let mut best = Option::<(CoinSelector, Ordf32)>::None; + let mut rounds = 0; + + let iter = ExhaustiveIter::new(cs)? + .enumerate() + .inspect(|(i, _)| rounds = *i) + .filter(|(_, (_, inclusion))| *inclusion) + .filter_map(|(_, (cs, _))| metric.score(&cs).map(|score| (cs, score))); + + for (child_cs, score) in iter { + match &mut best { + Some((best_cs, best_score)) => { + if score < *best_score { + *best_cs = child_cs; + *best_score = score; + } + } + best => *best = Some((child_cs, score)), + } + } + + if let Some((best_cs, score)) = &best { + println!("\t\tsolution={}, score={}", best_cs, score); + *cs = best_cs.clone(); + } + + best.map(|(_, score)| (score, rounds)) +} + +pub fn bnb_search( + cs: &mut CoinSelector, + metric: M, + max_rounds: usize, +) -> Result<(Ordf32, usize), NoBnbSolution> +where + M: BnbMetric, +{ + let mut rounds = 0_usize; + let (selection, score) = cs + .bnb_solutions(metric) + .inspect(|_| rounds += 1) + .take(max_rounds) + .flatten() + .last() + .ok_or(NoBnbSolution { max_rounds, rounds })?; + println!("\t\tsolution={}, score={}", selection, score); + *cs = selection; + + Ok((score, rounds)) +} + +pub fn result_string(res: &Result<(Ordf32, usize), E>, change: Drain) -> String +where + E: std::fmt::Debug, +{ + match res { + Ok((score, rounds)) => { + let drain = if change.is_some() { + format!("{:?}", change) + } else { + "None".to_string() + }; + format!("Ok(score={} rounds={} drain={})", score, rounds, drain) + } + err => format!("{:?}", err), + } +} diff --git a/nursery/coin_select/tests/lowest_fee.proptest-regressions b/nursery/coin_select/tests/lowest_fee.proptest-regressions new file mode 100644 index 000000000..2e9a46786 --- /dev/null +++ b/nursery/coin_select/tests/lowest_fee.proptest-regressions @@ -0,0 +1,10 @@ +# Seeds for failure cases proptest has generated in the past. It is +# automatically read and these particular cases re-run before any +# novel cases are generated. +# +# It is recommended to check this file in to source control so that +# everyone who runs the test benefits from these saved cases. +cc 9c841bb85574de2412972df187e7ebd01f7a06a178a67f4d99c0178dd578ac34 # shrinks to n_candidates = 30, target_value = 76632, base_weight = 480, min_fee = 0, feerate = 8.853, feerate_lt_diff = 0.0, drain_weight = 100, drain_spend_weight = 1, drain_dust = 100 +cc e30499b75a1846759fc9ffd7ee558b08a4795598cf7919f6be6d62cc7a79d4cb # shrinks to n_candidates = 25, target_value = 56697, base_weight = 621, min_fee = 0, feerate = 9.417939, feerate_lt_diff = 0.0, drain_weight = 100, drain_spend_weight = 1, drain_dust = 100 +cc c580ee452624915fc710d5fe724c7a9347472ccd178f66c9db9479cfc6168f48 # shrinks to n_candidates = 25, target_value = 488278, base_weight = 242, min_fee = 0, feerate = 6.952743, feerate_lt_diff = 0.0, drain_weight = 100, drain_spend_weight = 1, drain_dust = 100 +cc 850e0115aeeb7ed50235fdb4b5183eb5bf8309a45874dc261e3d3fd2d8c84660 # shrinks to n_candidates = 8, target_value = 444541, base_weight = 253, min_fee = 0, feerate = 55.98181, feerate_lt_diff = 36.874306, drain_weight = 490, drain_spend_weight = 1779, drain_dust = 100 diff --git a/nursery/coin_select/tests/lowest_fee.rs b/nursery/coin_select/tests/lowest_fee.rs new file mode 100644 index 000000000..511dc134c --- /dev/null +++ b/nursery/coin_select/tests/lowest_fee.rs @@ -0,0 +1,161 @@ +#![allow(unused_imports)] + +mod common; +use bdk_coin_select::change_policy::{self, min_value_and_waste}; +use bdk_coin_select::metrics::{Changeless, LowestFee}; +use bdk_coin_select::{BnbMetric, Candidate, CoinSelector}; +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..20_usize, // candidates (n) + target_value in 500..500_000_u64, // target value (sats) + base_weight in 0..1000_u32, // base weight (wu) + min_fee in 0..1000_u64, // min fee (sats) + feerate in 1.0..100.0_f32, // feerate (sats/vb) + feerate_lt_diff in -5.0..50.0_f32, // longterm feerate diff (sats/vb) + drain_weight in 100..=500_u32, // drain weight (wu) + drain_spend_weight in 1..=2000_u32, // drain spend weight (wu) + drain_dust in 100..=1000_u64, // drain dust (sats) + ) { + let params = common::StrategyParams { n_candidates, target_value, base_weight, min_fee, feerate, feerate_lt_diff, drain_weight, drain_spend_weight, drain_dust }; + let candidates = common::gen_candidates(params.n_candidates); + let change_policy = min_value_and_waste(params.drain_weights(), params.drain_dust, params.long_term_feerate()); + let metric = LowestFee { target: params.target(), long_term_feerate: params.long_term_feerate(), change_policy: &change_policy }; + common::can_eventually_find_best_solution(params, candidates, &change_policy, metric)?; + } + + #[test] + #[cfg(not(debug_assertions))] // too slow if compiling for debug + fn ensure_bound_is_not_too_tight( + n_candidates in 0..15_usize, // candidates (n) + target_value in 500..500_000_u64, // target value (sats) + base_weight in 0..1000_u32, // base weight (wu) + min_fee in 0..1000_u64, // min fee (sats) + feerate in 1.0..100.0_f32, // feerate (sats/vb) + feerate_lt_diff in -5.0..50.0_f32, // longterm feerate diff (sats/vb) + drain_weight in 100..=500_u32, // drain weight (wu) + drain_spend_weight in 1..=1000_u32, // drain spend weight (wu) + drain_dust in 100..=1000_u64, // drain dust (sats) + ) { + let params = common::StrategyParams { n_candidates, target_value, base_weight, min_fee, feerate, feerate_lt_diff, drain_weight, drain_spend_weight, drain_dust }; + let candidates = common::gen_candidates(params.n_candidates); + let change_policy = min_value_and_waste(params.drain_weights(), params.drain_dust, params.long_term_feerate()); + let metric = LowestFee { target: params.target(), long_term_feerate: params.long_term_feerate(), change_policy: &change_policy }; + common::ensure_bound_is_not_too_tight(params, candidates, &change_policy, metric)?; + } + + #[test] + #[cfg(not(debug_assertions))] // too slow if compiling for debug + fn identical_candidates( + n_candidates in 30..300_usize, + target_value in 50_000..500_000_u64, // target value (sats) + base_weight in 0..641_u32, // base weight (wu) + min_fee in 0..1_000_u64, // min fee (sats) + feerate in 1.0..10.0_f32, // feerate (sats/vb) + feerate_lt_diff in -5.0..5.0_f32, // longterm feerate diff (sats/vb) + drain_weight in 100..=500_u32, // drain weight (wu) + drain_spend_weight in 1..=1000_u32, // drain spend weight (wu) + drain_dust in 100..=1000_u64, // drain dust (sats) + ) { + println!("== TEST =="); + + let params = common::StrategyParams { + n_candidates, + target_value, + base_weight, + min_fee, + feerate, + feerate_lt_diff, + drain_weight, + drain_spend_weight, + drain_dust, + }; + println!("{:?}", params); + + let candidates = core::iter::repeat(Candidate { + value: 20_000, + weight: (32 + 4 + 4 + 1) * 4 + 64 + 32, + input_count: 1, + is_segwit: true, + }) + .take(params.n_candidates) + .collect::>(); + + let mut cs = CoinSelector::new(&candidates, params.base_weight); + + let change_policy = min_value_and_waste( + params.drain_weights(), + params.drain_dust, + params.long_term_feerate(), + ); + + let metric = LowestFee { + target: params.target(), + long_term_feerate: params.long_term_feerate(), + change_policy: &change_policy, + }; + + let (score, rounds) = common::bnb_search(&mut cs, metric, params.n_candidates)?; + println!("\t\tscore={} rounds={}", score, rounds); + prop_assert!(rounds <= params.n_candidates); + } +} + +/// We combine the `LowestFee` and `Changeless` metrics to derive a `ChangelessLowestFee` metric. +#[test] +fn combined_changeless_metric() { + let params = common::StrategyParams { + n_candidates: 100, + target_value: 100_000, + base_weight: 1000, + min_fee: 0, + feerate: 5.0, + feerate_lt_diff: -4.0, + drain_weight: 200, + drain_spend_weight: 600, + drain_dust: 200, + }; + + let candidates = common::gen_candidates(params.n_candidates); + let mut cs_a = CoinSelector::new(&candidates, params.base_weight); + let mut cs_b = CoinSelector::new(&candidates, params.base_weight); + + let change_policy = min_value_and_waste( + params.drain_weights(), + params.drain_dust, + params.long_term_feerate(), + ); + + let metric_lowest_fee = LowestFee { + target: params.target(), + long_term_feerate: params.long_term_feerate(), + change_policy: &change_policy, + }; + + let metric_changeless = Changeless { + target: params.target(), + change_policy: &change_policy, + }; + + let metric_combined = ((metric_lowest_fee, 1.0_f32), (metric_changeless, 0.0_f32)); + + // cs_a uses the non-combined metric + let (score, rounds) = + common::bnb_search(&mut cs_a, metric_lowest_fee, usize::MAX).expect("must find solution"); + println!("score={:?} rounds={}", score, rounds); + + // cs_b uses the combined metric + let (combined_score, combined_rounds) = + common::bnb_search(&mut cs_b, metric_combined, usize::MAX).expect("must find solution"); + println!("score={:?} rounds={}", combined_score, combined_rounds); + + // [todo] shouldn't rounds be less since we are only considering changeless branches? + assert!(combined_rounds <= rounds); +} diff --git a/nursery/coin_select/tests/waste.proptest-regressions b/nursery/coin_select/tests/waste.proptest-regressions new file mode 100644 index 000000000..0f0bc3212 --- /dev/null +++ b/nursery/coin_select/tests/waste.proptest-regressions @@ -0,0 +1,16 @@ +# Seeds for failure cases proptest has generated in the past. It is +# automatically read and these particular cases re-run before any +# novel cases are generated. +# +# It is recommended to check this file in to source control so that +# everyone who runs the test benefits from these saved cases. +cc b526e3a05e5dffce95e0cf357f68d6819b5b92a1c4abd79fd8fe0e2582521352 # shrinks to num_inputs = 45, target = 16494, feerate = 3.0291684, min_fee = 0, base_weight = 155, long_term_feerate_diff = -0.70271873, change_weight = 58, change_spend_weight = 82 +cc f3c37a516004e7eda9183816d72bede9084ce678830d6582f2d63306f618adee # shrinks to num_inputs = 40, target = 6598, feerate = 8.487553, min_fee = 221, base_weight = 126, long_term_feerate_diff = 3.3214626, change_weight = 18, change_spend_weight = 18 +cc a6d03a6d93eb8d5a082d69a3d1677695377823acafe3dba954ac86519accf152 # shrinks to num_inputs = 49, target = 2917, feerate = 9.786607, min_fee = 0, base_weight = 4, long_term_feerate_diff = -0.75053596, change_weight = 77, change_spend_weight = 81 +cc a1eccddab6d7da9677575154a27a1e49b391041ed9e32b9bf937efd72ef0ab03 # shrinks to num_inputs = 12, target = 3988, feerate = 4.3125916, min_fee = 453, base_weight = 0, long_term_feerate_diff = -0.018570423, change_weight = 15, change_spend_weight = 32 +cc 4bb301aaba29e5f5311bb57c8737279045f7ad594adb91b94c5e080d3ba21933 # shrinks to num_inputs = 33, target = 2023, feerate = 4.4804115, min_fee = 965, base_weight = 0, long_term_feerate_diff = -0.30981845, change_weight = 80, change_spend_weight = 95 +cc 6c1e79f7bd7753a37c1aaebb72f3be418ac092a585e7629ab2331e0f9a585640 # shrinks to n_candidates = 11, target_value = 401712, base_weight = 33, min_fee = 0, feerate = 62.1756, feerate_lt_diff = 0.0, drain_weight = 100, drain_spend_weight = 253, drain_dust = 100 +cc 617e11dc77968b5d26748b10da6d4916210fb7004a120cff73784d9587816fee # shrinks to n_candidates = 6, target_value = 77118, base_weight = 996, min_fee = 661, feerate = 78.64882, feerate_lt_diff = 46.991302, drain_weight = 188, drain_spend_weight = 1242, drain_dust = 366 +cc 5905f9f223eb175556a89335da988256cb15f14e0f53f7ff512b1ff05ee74f83 # shrinks to n_candidates = 15, target_value = 497809, base_weight = 303, min_fee = 0, feerate = 32.44647, feerate_lt_diff = -2.8886793, drain_weight = 100, drain_spend_weight = 257, drain_dust = 100 +cc 414c6219145a3867c404ea0f54415ab6a1089f1497dede15c4989e7a88e9936a # shrinks to n_candidates = 3, target_value = 444025, base_weight = 770, min_fee = 0, feerate = 36.7444, feerate_lt_diff = 21.816896, drain_weight = 203, drain_spend_weight = 1921, drain_dust = 100 +cc 536487b3604db918a3ca5cfc3f38a3af6cef9b0140ddca59e7d2ea92af61e04e # shrinks to num_inputs = 17, target = 7008, feerate = 1.0, min_fee = 702, base_weight = 0, long_term_feerate_diff = -0.24188519, change_weight = 28, change_spend_weight = 44 diff --git a/nursery/coin_select/tests/waste.rs b/nursery/coin_select/tests/waste.rs new file mode 100644 index 000000000..6e1dca0aa --- /dev/null +++ b/nursery/coin_select/tests/waste.rs @@ -0,0 +1,495 @@ +#![allow(unused_imports)] + +mod common; + +use bdk_coin_select::change_policy::min_value_and_waste; +use bdk_coin_select::{ + change_policy, float::Ordf32, metrics::Waste, Candidate, CoinSelector, Drain, DrainWeights, + FeeRate, Target, +}; +use proptest::{ + prelude::*, + test_runner::{RngAlgorithm, TestRng}, +}; +use rand::prelude::IteratorRandom; + +#[test] +fn waste_all_selected_except_one_is_optimal_and_awkward() { + let num_inputs = 40; + let target = 15578; + let feerate = 8.190512; + let min_fee = 0; + let base_weight = 453; + let long_term_feerate_diff = -3.630499; + let change_weight = 1; + let change_spend_weight = 41; + let mut rng = TestRng::deterministic_rng(RngAlgorithm::ChaCha); + let long_term_feerate = + FeeRate::from_sat_per_vb((0.0f32).max(feerate - long_term_feerate_diff)); + let feerate = FeeRate::from_sat_per_vb(feerate); + let drain_weights = DrainWeights { + output_weight: change_weight, + spend_weight: change_spend_weight, + }; + + let change_policy = change_policy::min_waste(drain_weights, long_term_feerate); + let wv = test_wv(&mut rng); + let candidates = wv.take(num_inputs).collect::>(); + + let cs = CoinSelector::new(&candidates, base_weight); + let target = Target { + value: target, + feerate, + min_fee, + }; + + let solutions = cs.bnb_solutions(Waste { + target, + long_term_feerate, + change_policy: &change_policy, + }); + + let (_i, (best, score)) = solutions + .enumerate() + .filter_map(|(i, sol)| Some((i, sol?))) + .last() + .expect("it should have found solution"); + + let mut all_selected = cs.clone(); + all_selected.select_all(); + let target_waste = all_selected.waste( + target, + long_term_feerate, + change_policy(&all_selected, target), + 1.0, + ); + assert!(score.0 < target_waste); + assert_eq!(best.selected().len(), 39); +} + +#[test] +fn waste_naive_effective_value_shouldnt_be_better() { + let num_inputs = 23; + let target = 1475; + let feerate = 1.0; + let min_fee = 989; + let base_weight = 0; + let long_term_feerate_diff = 3.8413858; + let change_weight = 1; + let change_spend_weight = 1; + let mut rng = TestRng::deterministic_rng(RngAlgorithm::ChaCha); + let long_term_feerate = + FeeRate::from_sat_per_vb((0.0f32).max(feerate - long_term_feerate_diff)); + let feerate = FeeRate::from_sat_per_vb(feerate); + let drain_weights = DrainWeights { + output_weight: change_weight, + spend_weight: change_spend_weight, + }; + let drain = Drain { + weights: drain_weights, + value: 0, + }; + + let change_policy = change_policy::min_waste(drain_weights, long_term_feerate); + let wv = test_wv(&mut rng); + let candidates = wv.take(num_inputs).collect::>(); + + let cs = CoinSelector::new(&candidates, base_weight); + + let target = Target { + value: target, + feerate, + min_fee, + }; + + let solutions = cs.bnb_solutions(Waste { + target, + long_term_feerate, + change_policy: &change_policy, + }); + + let (_i, (_best, score)) = solutions + .enumerate() + .filter_map(|(i, sol)| Some((i, sol?))) + .last() + .expect("should find solution"); + + let mut naive_select = cs.clone(); + naive_select.sort_candidates_by_key(|(_, wv)| core::cmp::Reverse(wv.value_pwu())); + // we filter out failing onces below + let _ = naive_select.select_until_target_met(target, drain); + + let bench_waste = naive_select.waste( + target, + long_term_feerate, + change_policy(&naive_select, target), + 1.0, + ); + + assert!(score < Ordf32(bench_waste)); +} + +#[test] +fn waste_doesnt_take_too_long_to_finish() { + let start = std::time::Instant::now(); + let num_inputs = 22; + let target = 0; + let feerate = 4.9522414; + let min_fee = 0; + let base_weight = 2; + let long_term_feerate_diff = -0.17994404; + let change_weight = 1; + let change_spend_weight = 34; + + let mut rng = TestRng::deterministic_rng(RngAlgorithm::ChaCha); + let long_term_feerate = + FeeRate::from_sat_per_vb((0.0f32).max(feerate - long_term_feerate_diff)); + let feerate = FeeRate::from_sat_per_vb(feerate); + let drain_weights = DrainWeights { + output_weight: change_weight, + spend_weight: change_spend_weight, + }; + + let change_policy = change_policy::min_waste(drain_weights, long_term_feerate); + let wv = test_wv(&mut rng); + let candidates = wv.take(num_inputs).collect::>(); + + let cs = CoinSelector::new(&candidates, base_weight); + + let target = Target { + value: target, + feerate, + min_fee, + }; + + let solutions = cs.bnb_solutions(Waste { + target, + long_term_feerate, + change_policy: &change_policy, + }); + + solutions + .enumerate() + .inspect(|_| { + if start.elapsed().as_millis() > 1_000 { + panic!("took too long to finish") + } + }) + .filter_map(|(i, sol)| Some((i, sol?))) + .last() + .expect("should find solution"); +} + +/// When long term feerate is lower than current adding new inputs should in general make things +/// worse except in the case that we can get rid of the change output with negative effective +/// value inputs. In this case the right answer to select everything. +#[test] +fn waste_lower_long_term_feerate_but_still_need_to_select_all() { + let num_inputs = 16; + let target = 5586; + let feerate = 9.397041; + let min_fee = 0; + let base_weight = 91; + let long_term_feerate_diff = 0.22074795; + let change_weight = 1; + let change_spend_weight = 27; + + let mut rng = TestRng::deterministic_rng(RngAlgorithm::ChaCha); + let long_term_feerate = FeeRate::from_sat_per_vb(0.0f32.max(feerate - long_term_feerate_diff)); + let feerate = FeeRate::from_sat_per_vb(feerate); + let drain_weights = DrainWeights { + output_weight: change_weight, + spend_weight: change_spend_weight, + }; + + let change_policy = change_policy::min_waste(drain_weights, long_term_feerate); + let wv = test_wv(&mut rng); + let candidates = wv.take(num_inputs).collect::>(); + + let cs = CoinSelector::new(&candidates, base_weight); + + let target = Target { + value: target, + feerate, + min_fee, + }; + + let solutions = cs.bnb_solutions(Waste { + target, + long_term_feerate, + change_policy: &change_policy, + }); + let bench = { + let mut all_selected = cs.clone(); + all_selected.select_all(); + all_selected + }; + + let (_i, (_sol, waste)) = solutions + .enumerate() + .filter_map(|(i, sol)| Some((i, sol?))) + .last() + .expect("should find solution"); + + let bench_waste = bench.waste( + target, + long_term_feerate, + change_policy(&bench, target), + 1.0, + ); + + assert!(waste <= Ordf32(bench_waste)); +} + +#[test] +fn waste_low_but_non_negative_rate_diff_means_adding_more_inputs_might_reduce_excess() { + let num_inputs = 22; + let target = 7620; + let feerate = 8.173157; + let min_fee = 0; + let base_weight = 35; + let long_term_feerate_diff = 0.0; + let change_weight = 1; + let change_spend_weight = 47; + + let mut rng = TestRng::deterministic_rng(RngAlgorithm::ChaCha); + let long_term_feerate = FeeRate::from_sat_per_vb(0.0f32.max(feerate - long_term_feerate_diff)); + let feerate = FeeRate::from_sat_per_vb(feerate); + let drain_weights = DrainWeights { + output_weight: change_weight, + spend_weight: change_spend_weight, + }; + + let change_policy = change_policy::min_waste(drain_weights, long_term_feerate); + let wv = test_wv(&mut rng); + let mut candidates = wv.take(num_inputs).collect::>(); + // 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); + + let target = Target { + value: target, + feerate, + min_fee, + }; + + let solutions = cs.bnb_solutions(Waste { + target, + long_term_feerate, + change_policy: &change_policy, + }); + let bench = { + let mut all_selected = cs.clone(); + all_selected.select_all(); + all_selected + }; + + let (_i, (_sol, waste)) = solutions + .enumerate() + .filter_map(|(i, sol)| Some((i, sol?))) + .last() + .expect("should find solution"); + + let bench_waste = bench.waste( + target, + long_term_feerate, + change_policy(&bench, target), + 1.0, + ); + + assert!(waste <= Ordf32(bench_waste)); +} + +proptest! { + #![proptest_config(ProptestConfig { + timeout: 6_000, + cases: 1_000, + ..Default::default() + })] + // TODO: Because our waste bnb implementation has bounds that are too tight, sometimes the best + // solution is skipped. + // + // #[test] + // #[cfg(not(debug_assertions))] // too slow if compiling for debug + // fn waste_prop_waste( + // num_inputs in 0usize..20, + // target in 0u64..25_000, + // feerate in 1.0f32..10.0, + // min_fee in 0u64..1_000, + // base_weight in 0u32..500, + // long_term_feerate_diff in -5.0f32..5.0, + // change_weight in 1u32..100, + // change_spend_weight in 1u32..100, + // ) { + // println!("======================================="); + // let start = std::time::Instant::now(); + // let mut rng = TestRng::deterministic_rng(RngAlgorithm::ChaCha); + // let long_term_feerate = FeeRate::from_sat_per_vb(0.0f32.max(feerate - long_term_feerate_diff)); + // let feerate = FeeRate::from_sat_per_vb(feerate); + // let drain = DrainWeights { + // output_weight: change_weight, + // spend_weight: change_spend_weight, + // }; + // + // let change_policy = crate::change_policy::min_waste(drain, long_term_feerate); + // let wv = test_wv(&mut rng); + // let candidates = wv.take(num_inputs).collect::>(); + // + // let cs = CoinSelector::new(&candidates, base_weight); + // + // let target = Target { + // value: target, + // feerate, + // min_fee + // }; + // + // let solutions = cs.bnb_solutions(Waste { + // target, + // long_term_feerate, + // change_policy: &change_policy + // }); + // + // + // let best = solutions + // .enumerate() + // .filter_map(|(i, sol)| Some((i, sol?))) + // .last(); + // + // match best { + // Some((_i, (sol, _score))) => { + // + // let mut cmp_benchmarks = vec![ + // { + // let mut naive_select = cs.clone(); + // naive_select.sort_candidates_by_key(|(_, wv)| core::cmp::Reverse(wv.effective_value(target.feerate))); + // // we filter out failing onces below + // let _ = naive_select.select_until_target_met(target, Drain { weights: drain, value: 0 }); + // naive_select + // }, + // { + // let mut all_selected = cs.clone(); + // all_selected.select_all(); + // all_selected + // }, + // { + // let mut all_effective_selected = cs.clone(); + // all_effective_selected.select_all_effective(target.feerate); + // all_effective_selected + // } + // ]; + // + // // add some random selections -- technically it's possible that one of these is better but it's very unlikely if our algorithm is working correctly. + // cmp_benchmarks.extend((0..10).map(|_|randomly_satisfy_target_with_low_waste(&cs, target, long_term_feerate, &change_policy, &mut rng))); + // + // let cmp_benchmarks = cmp_benchmarks.into_iter().filter(|cs| cs.is_target_met(target, change_policy(&cs, target))); + // let sol_waste = sol.waste(target, long_term_feerate, change_policy(&sol, target), 1.0); + // + // for (_bench_id, mut bench) in cmp_benchmarks.enumerate() { + // let bench_waste = bench.waste(target, long_term_feerate, change_policy(&bench, target), 1.0); + // if sol_waste > bench_waste { + // dbg!(_bench_id); + // println!("bnb solution: {}", sol); + // bench.sort_candidates_by_descending_value_pwu(); + // println!("found better: {}", bench); + // } + // prop_assert!(sol_waste <= bench_waste); + // } + // }, + // None => { + // dbg!(feerate - long_term_feerate); + // prop_assert!(!cs.is_selection_plausible_with_change_policy(target, &change_policy)); + // } + // } + // + // dbg!(start.elapsed()); + // } + + // TODO: Because our waste bnb implementation has bounds that are too tight, sometimes the best + // solution is skipped. + // + // #[test] + // #[cfg(not(debug_assertions))] // too slow if compiling for debug + // fn can_eventually_find_best_solution( + // n_candidates in 1..20_usize, // candidates (n) + // target_value in 500..500_000_u64, // target value (sats) + // base_weight in 0..1000_u32, // base weight (wu) + // min_fee in 0..1_000_u64, // min fee (sats) + // feerate in 1.0..100.0_f32, // feerate (sats/vb) + // feerate_lt_diff in -5.0..50.0_f32, // longterm feerate diff (sats/vb) + // drain_weight in 100..=500_u32, // drain weight (wu) + // drain_spend_weight in 1..=2000_u32, // drain spend weight (wu) + // drain_dust in 100..=1000_u64, // drain dust (sats) + // ) { + // let params = common::StrategyParams { n_candidates, target_value, base_weight, min_fee, feerate, feerate_lt_diff, drain_weight, drain_spend_weight, drain_dust }; + // let candidates = common::gen_candidates(params.n_candidates); + // let change_policy = min_value_and_waste(params.drain_weights(), params.drain_dust, params.long_term_feerate()); + // let metric = Waste { target: params.target(), long_term_feerate: params.long_term_feerate(), change_policy: &change_policy }; + // common::can_eventually_find_best_solution(params, candidates, &change_policy, metric)?; + // } + + // TODO: Our waste bnb bounds are too tight! + // + // #[test] + // #[cfg(not(debug_assertions))] // too slow if compiling for debug + // fn ensure_bound_is_not_too_tight( + // n_candidates in 0..15_usize, // candidates (n) + // target_value in 500..500_000_u64, // target value (sats) + // base_weight in 0..641_u32, // base weight (wu) + // min_fee in 0..1_000_u64, // min fee (sats) + // feerate in 1.0..100.0_f32, // feerate (sats/vb) + // feerate_lt_diff in -5.0..50.0_f32, // longterm feerate diff (sats/vb) + // drain_weight in 100..=500_u32, // drain weight (wu) + // drain_spend_weight in 1..=1000_u32, // drain spend weight (wu) + // drain_dust in 100..=1000_u64, // drain dust (sats) + // ) { + // let params = common::StrategyParams { n_candidates, target_value, base_weight, min_fee, feerate, feerate_lt_diff, drain_weight, drain_spend_weight, drain_dust }; + // let candidates = common::gen_candidates(params.n_candidates); + // let change_policy = min_value_and_waste(params.drain_weights(), params.drain_dust, params.long_term_feerate()); + // let metric = Waste { target: params.target(), long_term_feerate: params.long_term_feerate(), change_policy: &change_policy }; + // common::ensure_bound_is_not_too_tight(params, candidates, &change_policy, metric)?; + // } +} + +fn test_wv(mut rng: impl RngCore) -> impl Iterator { + core::iter::repeat_with(move || { + let value = rng.gen_range(0, 1_000); + Candidate { + value, + weight: rng.gen_range(0, 100), + input_count: rng.gen_range(1, 2), + is_segwit: rng.gen_bool(0.5), + } + }) +} + +// this is probably a useful thing to have on CoinSelector but I don't want to design it yet +#[allow(unused)] +fn randomly_satisfy_target_with_low_waste<'a>( + cs: &CoinSelector<'a>, + target: Target, + long_term_feerate: FeeRate, + change_policy: &impl Fn(&CoinSelector, Target) -> Drain, + rng: &mut impl RngCore, +) -> CoinSelector<'a> { + let mut cs = cs.clone(); + + let mut last_waste: Option = None; + while let Some(next) = cs.unselected_indices().choose(rng) { + cs.select(next); + let change = change_policy(&cs, target); + if cs.is_target_met(target, change) { + let curr_waste = cs.waste(target, long_term_feerate, change, 1.0); + if let Some(last_waste) = last_waste { + if curr_waste > last_waste { + break; + } + } + last_waste = Some(curr_waste); + } + } + cs +} diff --git a/nursery/coin_select/tests/weight.rs b/nursery/coin_select/tests/weight.rs new file mode 100644 index 000000000..a7f71eaba --- /dev/null +++ b/nursery/coin_select/tests/weight.rs @@ -0,0 +1,200 @@ +#![allow(clippy::zero_prefixed_literal)] + +use std::str::FromStr; + +use bdk_coin_select::{Candidate, CoinSelector, Drain}; +use bitcoin::{absolute::Height, consensus::Decodable, Address, ScriptBuf, Transaction, TxOut}; + +fn hex_val(c: u8) -> u8 { + match c { + b'A'..=b'F' => c - b'A' + 10, + b'a'..=b'f' => c - b'a' + 10, + b'0'..=b'9' => c - b'0', + _ => panic!("invalid"), + } +} + +// Appears that Transaction has no from_str so I had to roll my own hex decoder +pub fn hex_decode(hex: &str) -> Vec { + let mut bytes = Vec::with_capacity(hex.len() * 2); + for hex_byte in hex.as_bytes().chunks(2) { + bytes.push(hex_val(hex_byte[0]) << 4 | hex_val(hex_byte[1])) + } + bytes +} + +#[test] +fn segwit_one_input_one_output() { + // FROM https://mempool.space/tx/e627fbb7f775a57fd398bf9b150655d4ac3e1f8afed4255e74ee10d7a345a9cc + let mut tx_bytes = hex_decode("01000000000101b2ec00fd7d3f2c89eb27e3e280960356f69fc88a324a4bca187dd4b020aa36690000000000ffffffff01d0bb9321000000001976a9141dc94fe723f43299c6187094b1dc5a032d47b06888ac024730440220669b764de7e9dcedcba6d6d57c8c761be2acc4e1a66938ceecacaa6d494f582d02202641df89d1758eeeed84290079dd9ad36611c73cd9e381dd090b83f5e5b1422e012103f6544e4ffaff4f8649222003ada5d74bd6d960162bcd85af2b619646c8c45a5298290c00"); + let mut cursor = std::io::Cursor::new(&mut tx_bytes); + let mut tx = Transaction::consensus_decode(&mut cursor).unwrap(); + let input_values = vec![563_336_755]; + let inputs = core::mem::take(&mut tx.input); + + let candidates = inputs + .iter() + .zip(input_values) + .map(|(txin, value)| Candidate { + value, + weight: txin.segwit_weight() as u32, + input_count: 1, + is_segwit: true, + }) + .collect::>(); + + let mut coin_selector = CoinSelector::new(&candidates, tx.weight().to_wu() as u32); + coin_selector.select_all(); + + assert_eq!(coin_selector.weight(0), 449); + assert_eq!( + (coin_selector + .implied_feerate(tx.output[0].value, Drain::none()) + .as_sat_vb() + * 10.0) + .round(), + 60.2 * 10.0 + ); +} + +#[test] +fn segwit_two_inputs_one_output() { + // FROM https://mempool.space/tx/37d2883bdf1b4c110b54cb624d36ab6a30140f8710ed38a52678260a7685e708 + let mut tx_bytes = hex_decode("020000000001021edcae5160b1ba2370a45ea9342b4c883a8941274539612bddf1c379ba7ecf180700000000ffffffff5c85e19bf4f0e293c0d5f9665cb05d2a55d8bba959edc5ef02075f6a1eb9fc120100000000ffffffff0168ce3000000000001976a9145ff742d992276a1f46e5113dde7382896ff86e2a88ac0247304402202e588db55227e0c24db7f07b65f221ebcae323fb595d13d2e1c360b773d809b0022008d2f57a618bd346cfd031549a3971f22464e3e3308cee340a976f1b47a96f0b012102effbcc87e6c59b810c2fa20b0bc3eb909a20b40b25b091cf005d416b85db8c8402483045022100bdc115b86e9c863279132b4808459cf9b266c8f6a9c14a3dfd956986b807e3320220265833b85197679687c5d5eed1b2637489b34249d44cf5d2d40bc7b514181a51012102077741a668889ce15d59365886375aea47a7691941d7a0d301697edbc773b45b00000000"); + let mut cursor = std::io::Cursor::new(&mut tx_bytes); + let mut tx = Transaction::consensus_decode(&mut cursor).unwrap(); + let input_values = vec![003_194_967, 000_014_068]; + let inputs = core::mem::take(&mut tx.input); + + let candidates = inputs + .iter() + .zip(input_values) + .map(|(txin, value)| Candidate { + value, + weight: txin.segwit_weight() as u32, + input_count: 1, + is_segwit: true, + }) + .collect::>(); + + let mut coin_selector = CoinSelector::new(&candidates, tx.weight().to_wu() as u32); + coin_selector.select_all(); + + assert_eq!(coin_selector.weight(0), 721); + assert_eq!( + (coin_selector + .implied_feerate(tx.output[0].value, Drain::none()) + .as_sat_vb() + * 10.0) + .round(), + 58.1 * 10.0 + ); +} + +#[test] +fn legacy_three_inputs() { + // FROM https://mempool.space/tx/5f231df4f73694b3cca9211e336451c20dab136e0a843c2e3166cdcb093e91f4 + let mut tx_bytes = hex_decode("0100000003fe785783e14669f638ba902c26e8e3d7036fb183237bc00f8a10542191c7171300000000fdfd00004730440220418996f20477d143d02ad47e74e5949641b6c2904159ab7c592d2cfc659f9bd802205b18f18ac86b714971f84a8b74a4cb14ad5c1a5b9d0d939bb32c6ae4032f4ea10148304502210091296ff8dd87b5ebfc3d47cb82cfe4750d52c544a2b88a85970354a4d0d4b1db022069632067ee6f30f06145f649bc76d5e5d5e6404dbe985e006fcde938f778c297014c695221030502b8ade694d57a6e86998180a64f4ce993372830dc796c3d561ad8b2a504de210272b68e1c037c4630eff7ea5858640cc0748e36f5de82fb38529ef1fd0a89670d2103ba0544a3a2aa9f2314022760b78b5c833aebf6f88468a089550f93834a2886ed53aeffffffff7e048a7c53a8af656e24442c65fe4c4299b1494f6c7579fe0fd9fa741ce83e3279000000fc004730440220018fa343acccd048ed8f8f179e1b6ae27435a41b5fb2c1d96a5a772777acc6dc022074783814f2100c6fc4d4c976f941212be50825814502ca0cbe3f929db789979e0147304402206373f01b73fb09876d0f5ee3087e0614cab3be249934bc2b7eb64ee67f53dc8302200b50f8a327020172b82aaba7480c77ecf07bb32322a05f4afbc543aa97d2fde8014c69522103039d906b2494e310f6c7774c98618be552720d04781e073dd3ff25d5906f22662103d82026baa529619b103ec6341d548a7eb6d924061a8469a7416155513a3071c12102e452bc4aa726d44646ba80db70465683b30efde282a19aa35c6029ae8925df5e53aeffffffffef80f0b1cc543de4f73d59c02a3c575ae5d0af17c1e11e6be7abe3325c777507ad000000fdfd00004730440220220fee11bf836621a11a8ea9100a4600c109c13895f11468d3e2062210c5481902201c5c8a462175538e87b8248e1ed3927c3a461c66d1b46215641c875e86eb22c4014830450221008d2de8c2f20a720129c372791e595b9602b1a9bce99618497aec5266148ffc1302203a493359d700ed96323f8805ed03e909959ff0f22eff359028db6861486b1555014c6952210374a4add33567f09967592c5bcdc3db421fdbba67bac4636328f96d941da31bd221039636c2ffac90afb7499b16e265078113dfb2d77b54270e37353217c9eaeaf3052103d0bcea6d10cdd2f16018ea71572631708e26f457f67cda36a7f816a87f7791d253aeffffffff04977261000000000016001470385d054721987f41521648d7b2f5c77f735d6bee92030000000000225120d0cda1b675a0b369964cbfa381721aae3549dd2c9c6f2cf71ff67d5bc277afd3f2aaf30000000000160014ed2d41ba08313dbb2630a7106b2fedafc14aa121d4f0c70000000000220020e5c7c00d174631d2d1e365d6347b016fb87b6a0c08902d8e443989cb771fa7ec00000000"); + let mut cursor = std::io::Cursor::new(&mut tx_bytes); + let mut tx = Transaction::consensus_decode(&mut cursor).unwrap(); + let orig_weight = tx.weight(); + let input_values = vec![022_680_000, 006_558_175, 006_558_200]; + let inputs = core::mem::take(&mut tx.input); + let candidates = inputs + .iter() + .zip(input_values) + .map(|(txin, value)| Candidate { + value, + weight: txin.legacy_weight() as u32, + input_count: 1, + is_segwit: false, + }) + .collect::>(); + + let mut coin_selector = CoinSelector::new(&candidates, tx.weight().to_wu() as u32); + coin_selector.select_all(); + + assert_eq!(coin_selector.weight(0), orig_weight.to_wu() as u32); + assert_eq!( + (coin_selector + .implied_feerate(tx.output.iter().map(|o| o.value).sum(), Drain::none()) + .as_sat_vb() + * 10.0) + .round(), + 99.2 * 10.0 + ); +} + +#[test] +fn legacy_three_inputs_one_segwit() { + // FROM https://mempool.space/tx/5f231df4f73694b3cca9211e336451c20dab136e0a843c2e3166cdcb093e91f4 + // Except we change the middle input to segwit + let mut tx_bytes = hex_decode("0100000003fe785783e14669f638ba902c26e8e3d7036fb183237bc00f8a10542191c7171300000000fdfd00004730440220418996f20477d143d02ad47e74e5949641b6c2904159ab7c592d2cfc659f9bd802205b18f18ac86b714971f84a8b74a4cb14ad5c1a5b9d0d939bb32c6ae4032f4ea10148304502210091296ff8dd87b5ebfc3d47cb82cfe4750d52c544a2b88a85970354a4d0d4b1db022069632067ee6f30f06145f649bc76d5e5d5e6404dbe985e006fcde938f778c297014c695221030502b8ade694d57a6e86998180a64f4ce993372830dc796c3d561ad8b2a504de210272b68e1c037c4630eff7ea5858640cc0748e36f5de82fb38529ef1fd0a89670d2103ba0544a3a2aa9f2314022760b78b5c833aebf6f88468a089550f93834a2886ed53aeffffffff7e048a7c53a8af656e24442c65fe4c4299b1494f6c7579fe0fd9fa741ce83e3279000000fc004730440220018fa343acccd048ed8f8f179e1b6ae27435a41b5fb2c1d96a5a772777acc6dc022074783814f2100c6fc4d4c976f941212be50825814502ca0cbe3f929db789979e0147304402206373f01b73fb09876d0f5ee3087e0614cab3be249934bc2b7eb64ee67f53dc8302200b50f8a327020172b82aaba7480c77ecf07bb32322a05f4afbc543aa97d2fde8014c69522103039d906b2494e310f6c7774c98618be552720d04781e073dd3ff25d5906f22662103d82026baa529619b103ec6341d548a7eb6d924061a8469a7416155513a3071c12102e452bc4aa726d44646ba80db70465683b30efde282a19aa35c6029ae8925df5e53aeffffffffef80f0b1cc543de4f73d59c02a3c575ae5d0af17c1e11e6be7abe3325c777507ad000000fdfd00004730440220220fee11bf836621a11a8ea9100a4600c109c13895f11468d3e2062210c5481902201c5c8a462175538e87b8248e1ed3927c3a461c66d1b46215641c875e86eb22c4014830450221008d2de8c2f20a720129c372791e595b9602b1a9bce99618497aec5266148ffc1302203a493359d700ed96323f8805ed03e909959ff0f22eff359028db6861486b1555014c6952210374a4add33567f09967592c5bcdc3db421fdbba67bac4636328f96d941da31bd221039636c2ffac90afb7499b16e265078113dfb2d77b54270e37353217c9eaeaf3052103d0bcea6d10cdd2f16018ea71572631708e26f457f67cda36a7f816a87f7791d253aeffffffff04977261000000000016001470385d054721987f41521648d7b2f5c77f735d6bee92030000000000225120d0cda1b675a0b369964cbfa381721aae3549dd2c9c6f2cf71ff67d5bc277afd3f2aaf30000000000160014ed2d41ba08313dbb2630a7106b2fedafc14aa121d4f0c70000000000220020e5c7c00d174631d2d1e365d6347b016fb87b6a0c08902d8e443989cb771fa7ec00000000"); + let mut cursor = std::io::Cursor::new(&mut tx_bytes); + let mut tx = Transaction::consensus_decode(&mut cursor).unwrap(); + tx.input[1].script_sig = ScriptBuf::default(); + tx.input[1].witness = vec![ + // semi-realistic p2wpkh spend + hex_decode("3045022100bdc115b86e9c863279132b4808459cf9b266c8f6a9c14a3dfd956986b807e3320220265833b85197679687c5d5eed1b2637489b34249d44cf5d2d40bc7b514181a5101"), + hex_decode("02077741a668889ce15d59365886375aea47a7691941d7a0d301697edbc773b45b"), + ].into(); + let orig_weight = tx.weight(); + let input_values = vec![022_680_000, 006_558_175, 006_558_200]; + let inputs = core::mem::take(&mut tx.input); + let candidates = inputs + .iter() + .zip(input_values) + .enumerate() + .map(|(i, (txin, value))| { + let is_segwit = i == 1; + Candidate { + value, + weight: if is_segwit { + txin.segwit_weight() + } else { + txin.legacy_weight() + } as u32, + input_count: 1, + is_segwit, + } + }) + .collect::>(); + + let mut coin_selector = CoinSelector::new(&candidates, tx.weight().to_wu() as u32); + coin_selector.select_all(); + + assert_eq!(coin_selector.weight(0), orig_weight.to_wu() as u32); +} + +/// Ensure that `fund_outputs` caculates the same `base_weight` as `rust-bitcoin`. +/// +/// We test it with 3 different output counts (resulting in different varint output-count weights). +#[test] +fn fund_outputs() { + let txo = TxOut { + script_pubkey: Address::from_str("bc1q4hym5spvze5d4wand9mf9ed7ku00kg6cv3h9ct") + .expect("must parse address") + .assume_checked() + .script_pubkey(), + value: 50_000, + }; + let txo_weight = txo.weight() as u32; + + let output_counts: &[usize] = &[0x01, 0xfd, 0x01_0000]; + + for &output_count in output_counts { + let weight_from_fund_outputs = + CoinSelector::fund_outputs(&[], (0..=output_count).map(|_| txo_weight)).weight(0); + + let exp_weight = Transaction { + version: 0, + lock_time: bitcoin::absolute::LockTime::Blocks(Height::ZERO), + input: Vec::new(), + output: (0..=output_count).map(|_| txo.clone()).collect(), + } + .weight() + .to_wu() as u32; + + assert_eq!(weight_from_fund_outputs, exp_weight); + } +}