Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Introduce new bdk_coin_select implementation #1072

Closed
wants to merge 28 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
7dad80c
Move bdk_coin_select in from old PR
LLFourn Mar 23, 2023
270916b
Include the SPK length field weight in TXOUT_BASE_weight
LLFourn Jun 21, 2023
0e504ef
Fix weight calculations for mixed legacy and segwit
LLFourn Jul 4, 2023
ce2fad9
feat(coin_select): add `DrainWeights` and `min_value_and_waste` policy
evanlinjin Aug 10, 2023
b4098de
feat(coin_select): add `CoinSelector::fund_outputs` method
evanlinjin Aug 11, 2023
9cd79fe
feat(coin_select)!: Add `CoinSelector::run_bnb`
evanlinjin Aug 11, 2023
0ab8042
feat(coin_select): Implement `LowestFee` metric
evanlinjin Aug 15, 2023
3745b9e
test(coin_select): prop tests for metrics
evanlinjin Aug 17, 2023
a14d7c6
test(coin_select): fix `waste_prop_waste` from timing out
evanlinjin Aug 21, 2023
84ed9fa
test(coin_select): add inner prop test methods
evanlinjin Aug 22, 2023
fa466ae
fix(coin_select): make bnb more efficient with identical candidates
evanlinjin Aug 24, 2023
cdbe775
feat(coin_select): implement `WasteChangeless` metric
evanlinjin Aug 27, 2023
97e17d4
feat(coin_select): tighten bounds of `lowest_fee` metric
evanlinjin Sep 5, 2023
f2597cc
feat(coin_select): add bnb `insert_new_branches` optimization
evanlinjin Sep 5, 2023
b13b1ce
feat(coin_select): downgrade dev dependencies to work with MSRV
evanlinjin Sep 5, 2023
00c7f30
test(coin_select): refactor proptest helpers to avoid `Box::leak`
evanlinjin Sep 7, 2023
22e0fb4
test(coin_select): also test min_fee in proptests and docs
evanlinjin Sep 11, 2023
c3056fd
feat(coin_select)!: add ability to combine scores from different metrics
evanlinjin Sep 11, 2023
82f0eab
test(coin_select): test `LowestFee` + `Changeless` combined metric
evanlinjin Sep 11, 2023
29780ce
doc(coin_select): make clippy happy
evanlinjin Sep 11, 2023
5513f6c
coin_select: rm `waste_changeless` metric
evanlinjin Nov 6, 2023
53de6d1
coin_select: make changeless test pass
evanlinjin Nov 6, 2023
19e50d5
chore(coin_select): make code work with 1.57 MSRV
evanlinjin Nov 6, 2023
aa425f8
chore(coin_select): handle all TODOs
evanlinjin Nov 8, 2023
2cf8304
chore(coin_select): rm debug assertion in `LowestFee` metric
evanlinjin Nov 13, 2023
2a06d73
chore(coin_select): temporarily comment out failing waste proptest
evanlinjin Nov 13, 2023
476bc87
docs(coin_select): update README
evanlinjin Nov 14, 2023
d620dc6
chore(coin_select): update `Cargo.toml`
evanlinjin Nov 14, 2023
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions Cargo.1.48.0.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
[workspace]
members = [
"nursery/coin_select"
]
16 changes: 16 additions & 0 deletions build-msrv-crates.sh
Original file line number Diff line number Diff line change
@@ -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
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we should instead just put this code in a separate repo in the bdk org and have our own MSRV for it.

cat Cargo.toml
cargo build --release
cleanup
20 changes: 10 additions & 10 deletions crates/bdk/src/wallet/coin_selection.rs
Original file line number Diff line number Diff line change
Expand Up @@ -836,7 +836,7 @@ mod test {
let drain_script = ScriptBuf::default();
let target_amount = 250_000 + FEE_AMOUNT;

let result = LargestFirstCoinSelection::default()
let result = LargestFirstCoinSelection
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

huh why are we changing this file?

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

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

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

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

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

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

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

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

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

OldestFirstCoinSelection::default()
OldestFirstCoinSelection
.coin_select(
vec![],
utxos,
Expand Down
174 changes: 94 additions & 80 deletions example-crates/example_cli/src/lib.rs
Original file line number Diff line number Diff line change
@@ -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::{
Expand All @@ -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;
Expand Down Expand Up @@ -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::<Vec<_>>();

let internal_keychain = if graph.index.keychains().get(&Keychain::Internal).is_some() {
Keychain::Internal
Expand All @@ -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
Expand All @@ -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::<Vec<_>>();
let selected_txos = selector
.apply_selection(&raw_candidates)
.collect::<Vec<_>>();

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()
Expand Down Expand Up @@ -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
Expand Down
18 changes: 15 additions & 3 deletions nursery/coin_select/Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,10 +1,22 @@
[package]
name = "bdk_coin_select"
version = "0.0.1"
authors = [ "LLFourn <[email protected]>" ]
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"]
Expand Down
Loading
Loading