use crate::types::FeeRate;
use crate::wallet::utils::IsDust;
use crate::{database::Database, WeightedUtxo};
use crate::{error::Error, Utxo};
use bitcoin::consensus::encode::serialize;
use bitcoin::{Script, Weight};
#[cfg(test)]
use assert_matches::assert_matches;
use rand::seq::SliceRandom;
#[cfg(not(test))]
use rand::thread_rng;
use std::collections::HashMap;
use std::convert::TryInto;
#[cfg(not(test))]
pub type DefaultCoinSelectionAlgorithm = BranchAndBoundCoinSelection;
#[cfg(test)]
pub type DefaultCoinSelectionAlgorithm = LargestFirstCoinSelection;
pub(crate) const TXIN_BASE_WEIGHT: usize = (32 + 4 + 4) * 4;
#[derive(Debug)]
pub enum Excess {
NoChange {
dust_threshold: u64,
remaining_amount: u64,
change_fee: u64,
},
Change {
amount: u64,
fee: u64,
},
}
#[derive(Debug)]
pub struct CoinSelectionResult {
pub selected: Vec<Utxo>,
pub fee_amount: u64,
pub excess: Excess,
}
impl CoinSelectionResult {
pub fn selected_amount(&self) -> u64 {
self.selected.iter().map(|u| u.txout().value).sum()
}
pub fn local_selected_amount(&self) -> u64 {
self.selected
.iter()
.filter_map(|u| match u {
Utxo::Local(_) => Some(u.txout().value),
_ => None,
})
.sum()
}
}
pub trait CoinSelectionAlgorithm<D: Database>: std::fmt::Debug {
#[allow(clippy::too_many_arguments)]
fn coin_select(
&self,
database: &D,
required_utxos: Vec<WeightedUtxo>,
optional_utxos: Vec<WeightedUtxo>,
fee_rate: FeeRate,
target_amount: u64,
drain_script: &Script,
) -> Result<CoinSelectionResult, Error>;
}
#[derive(Debug, Default, Clone, Copy)]
pub struct LargestFirstCoinSelection;
impl<D: Database> CoinSelectionAlgorithm<D> for LargestFirstCoinSelection {
fn coin_select(
&self,
_database: &D,
required_utxos: Vec<WeightedUtxo>,
mut optional_utxos: Vec<WeightedUtxo>,
fee_rate: FeeRate,
target_amount: u64,
drain_script: &Script,
) -> Result<CoinSelectionResult, Error> {
log::debug!(
"target_amount = `{}`, fee_rate = `{:?}`",
target_amount,
fee_rate
);
let utxos = {
optional_utxos.sort_unstable_by_key(|wu| wu.utxo.txout().value);
required_utxos
.into_iter()
.map(|utxo| (true, utxo))
.chain(optional_utxos.into_iter().rev().map(|utxo| (false, utxo)))
};
select_sorted_utxos(utxos, fee_rate, target_amount, drain_script)
}
}
#[derive(Debug, Default, Clone, Copy)]
pub struct OldestFirstCoinSelection;
impl<D: Database> CoinSelectionAlgorithm<D> for OldestFirstCoinSelection {
fn coin_select(
&self,
database: &D,
required_utxos: Vec<WeightedUtxo>,
mut optional_utxos: Vec<WeightedUtxo>,
fee_rate: FeeRate,
target_amount: u64,
drain_script: &Script,
) -> Result<CoinSelectionResult, Error> {
let blockheights = optional_utxos
.iter()
.map(|wu| wu.utxo.outpoint().txid)
.try_fold(HashMap::new(), |mut bh_acc, txid| {
if bh_acc.contains_key(&txid) {
Ok(bh_acc)
} else {
database.get_tx(&txid, false).map(|details| {
bh_acc.insert(
txid,
details.and_then(|d| d.confirmation_time.map(|ct| ct.height)),
);
bh_acc
})
}
})?;
let utxos = {
optional_utxos.sort_unstable_by_key(|wu| {
match blockheights.get(&wu.utxo.outpoint().txid) {
Some(Some(blockheight)) => blockheight,
_ => &u32::MAX,
}
});
required_utxos
.into_iter()
.map(|utxo| (true, utxo))
.chain(optional_utxos.into_iter().map(|utxo| (false, utxo)))
};
select_sorted_utxos(utxos, fee_rate, target_amount, drain_script)
}
}
pub fn decide_change(remaining_amount: u64, fee_rate: FeeRate, drain_script: &Script) -> Excess {
let drain_output_len = serialize(drain_script).len() + 8usize;
let change_fee = fee_rate.fee_vb(drain_output_len);
let drain_val = remaining_amount.saturating_sub(change_fee);
if drain_val.is_dust(drain_script) {
let dust_threshold = drain_script.dust_value().to_sat();
Excess::NoChange {
dust_threshold,
change_fee,
remaining_amount,
}
} else {
Excess::Change {
amount: drain_val,
fee: change_fee,
}
}
}
fn select_sorted_utxos(
utxos: impl Iterator<Item = (bool, WeightedUtxo)>,
fee_rate: FeeRate,
target_amount: u64,
drain_script: &Script,
) -> Result<CoinSelectionResult, Error> {
let mut selected_amount = 0;
let mut fee_amount = 0;
let selected = utxos
.scan(
(&mut selected_amount, &mut fee_amount),
|(selected_amount, fee_amount), (must_use, weighted_utxo)| {
if must_use || **selected_amount < target_amount + **fee_amount {
**fee_amount += fee_rate.fee_wu(Weight::from_wu(
(TXIN_BASE_WEIGHT + weighted_utxo.satisfaction_weight) as u64,
));
**selected_amount += weighted_utxo.utxo.txout().value;
log::debug!(
"Selected {}, updated fee_amount = `{}`",
weighted_utxo.utxo.outpoint(),
fee_amount
);
Some(weighted_utxo.utxo)
} else {
None
}
},
)
.collect::<Vec<_>>();
let amount_needed_with_fees = target_amount + fee_amount;
if selected_amount < amount_needed_with_fees {
return Err(Error::InsufficientFunds {
needed: amount_needed_with_fees,
available: selected_amount,
});
}
let remaining_amount = selected_amount - amount_needed_with_fees;
let excess = decide_change(remaining_amount, fee_rate, drain_script);
Ok(CoinSelectionResult {
selected,
fee_amount,
excess,
})
}
#[derive(Debug, Clone)]
struct OutputGroup {
weighted_utxo: WeightedUtxo,
fee: u64,
effective_value: i64,
}
impl OutputGroup {
fn new(weighted_utxo: WeightedUtxo, fee_rate: FeeRate) -> Self {
let fee = fee_rate.fee_wu(Weight::from_wu(
(TXIN_BASE_WEIGHT + weighted_utxo.satisfaction_weight) as u64,
));
let effective_value = weighted_utxo.utxo.txout().value as i64 - fee as i64;
OutputGroup {
weighted_utxo,
fee,
effective_value,
}
}
}
#[derive(Debug)]
pub struct BranchAndBoundCoinSelection {
size_of_change: u64,
}
impl Default for BranchAndBoundCoinSelection {
fn default() -> Self {
Self {
size_of_change: 8 + 1 + 22,
}
}
}
impl BranchAndBoundCoinSelection {
pub fn new(size_of_change: u64) -> Self {
Self { size_of_change }
}
}
const BNB_TOTAL_TRIES: usize = 100_000;
impl<D: Database> CoinSelectionAlgorithm<D> for BranchAndBoundCoinSelection {
fn coin_select(
&self,
_database: &D,
required_utxos: Vec<WeightedUtxo>,
optional_utxos: Vec<WeightedUtxo>,
fee_rate: FeeRate,
target_amount: u64,
drain_script: &Script,
) -> Result<CoinSelectionResult, Error> {
let required_utxos: Vec<OutputGroup> = required_utxos
.into_iter()
.map(|u| OutputGroup::new(u, fee_rate))
.collect();
let optional_utxos: Vec<OutputGroup> = optional_utxos
.into_iter()
.map(|u| OutputGroup::new(u, fee_rate))
.filter(|u| u.effective_value.is_positive())
.collect();
let curr_value = required_utxos
.iter()
.fold(0, |acc, x| acc + x.effective_value);
let curr_available_value = optional_utxos
.iter()
.fold(0, |acc, x| acc + x.effective_value);
let cost_of_change = self.size_of_change as f32 * fee_rate.as_sat_per_vb();
let total_value: Result<u64, _> = (curr_available_value + curr_value).try_into();
match total_value {
Ok(v) if v >= target_amount => {}
_ => {
let (utxo_fees, utxo_value) = required_utxos
.iter()
.chain(optional_utxos.iter())
.fold((0, 0), |(mut fees, mut value), utxo| {
fees += utxo.fee;
value += utxo.weighted_utxo.utxo.txout().value;
(fees, value)
});
return Err(Error::InsufficientFunds {
needed: target_amount + utxo_fees,
available: utxo_value,
});
}
}
let target_amount = target_amount
.try_into()
.expect("Bitcoin amount to fit into i64");
if curr_value > target_amount {
let remaining_amount = (curr_value - target_amount) as u64;
let excess = decide_change(remaining_amount, fee_rate, drain_script);
return Ok(BranchAndBoundCoinSelection::calculate_cs_result(
vec![],
required_utxos,
excess,
));
}
Ok(self
.bnb(
required_utxos.clone(),
optional_utxos.clone(),
curr_value,
curr_available_value,
target_amount,
cost_of_change,
drain_script,
fee_rate,
)
.unwrap_or_else(|_| {
self.single_random_draw(
required_utxos,
optional_utxos,
curr_value,
target_amount,
drain_script,
fee_rate,
)
}))
}
}
impl BranchAndBoundCoinSelection {
#[allow(clippy::too_many_arguments)]
fn bnb(
&self,
required_utxos: Vec<OutputGroup>,
mut optional_utxos: Vec<OutputGroup>,
mut curr_value: i64,
mut curr_available_value: i64,
target_amount: i64,
cost_of_change: f32,
drain_script: &Script,
fee_rate: FeeRate,
) -> Result<CoinSelectionResult, Error> {
let mut current_selection: Vec<bool> = Vec::with_capacity(optional_utxos.len());
optional_utxos.sort_unstable_by_key(|a| a.effective_value);
optional_utxos.reverse();
let mut best_selection = Vec::new();
let mut best_selection_value = None;
for _ in 0..BNB_TOTAL_TRIES {
let mut backtrack = false;
if curr_value + curr_available_value < target_amount
|| curr_value > target_amount + cost_of_change as i64
{
backtrack = true;
} else if curr_value >= target_amount {
backtrack = true;
if best_selection_value.is_none() || curr_value < best_selection_value.unwrap() {
best_selection = current_selection.clone();
best_selection_value = Some(curr_value);
}
if curr_value == target_amount {
break;
}
}
if backtrack {
while let Some(false) = current_selection.last() {
current_selection.pop();
curr_available_value += optional_utxos[current_selection.len()].effective_value;
}
if current_selection.last_mut().is_none() {
if best_selection.is_empty() {
return Err(Error::BnBNoExactMatch);
}
break;
}
if let Some(c) = current_selection.last_mut() {
*c = false;
}
let utxo = &optional_utxos[current_selection.len() - 1];
curr_value -= utxo.effective_value;
} else {
let utxo = &optional_utxos[current_selection.len()];
curr_available_value -= utxo.effective_value;
current_selection.push(true);
curr_value += utxo.effective_value;
}
}
if best_selection.is_empty() {
return Err(Error::BnBTotalTriesExceeded);
}
let selected_utxos = optional_utxos
.into_iter()
.zip(best_selection)
.filter_map(|(optional, is_in_best)| if is_in_best { Some(optional) } else { None })
.collect::<Vec<OutputGroup>>();
let selected_amount = best_selection_value.unwrap();
let remaining_amount = (selected_amount - target_amount) as u64;
let excess = decide_change(remaining_amount, fee_rate, drain_script);
Ok(BranchAndBoundCoinSelection::calculate_cs_result(
selected_utxos,
required_utxos,
excess,
))
}
#[allow(clippy::too_many_arguments)]
fn single_random_draw(
&self,
required_utxos: Vec<OutputGroup>,
mut optional_utxos: Vec<OutputGroup>,
curr_value: i64,
target_amount: i64,
drain_script: &Script,
fee_rate: FeeRate,
) -> CoinSelectionResult {
#[cfg(not(test))]
optional_utxos.shuffle(&mut thread_rng());
#[cfg(test)]
{
use rand::{rngs::StdRng, SeedableRng};
let seed = [0; 32];
let mut rng: StdRng = SeedableRng::from_seed(seed);
optional_utxos.shuffle(&mut rng);
}
let selected_utxos = optional_utxos.into_iter().fold(
(curr_value, vec![]),
|(mut amount, mut utxos), utxo| {
if amount >= target_amount {
(amount, utxos)
} else {
amount += utxo.effective_value;
utxos.push(utxo);
(amount, utxos)
}
},
);
let remaining_amount = (selected_utxos.0 - target_amount) as u64;
let excess = decide_change(remaining_amount, fee_rate, drain_script);
BranchAndBoundCoinSelection::calculate_cs_result(selected_utxos.1, required_utxos, excess)
}
fn calculate_cs_result(
mut selected_utxos: Vec<OutputGroup>,
mut required_utxos: Vec<OutputGroup>,
excess: Excess,
) -> CoinSelectionResult {
selected_utxos.append(&mut required_utxos);
let fee_amount = selected_utxos.iter().map(|u| u.fee).sum::<u64>();
let selected = selected_utxos
.into_iter()
.map(|u| u.weighted_utxo.utxo)
.collect::<Vec<_>>();
CoinSelectionResult {
selected,
fee_amount,
excess,
}
}
}
#[cfg(test)]
mod test {
use std::str::FromStr;
use bitcoin::{OutPoint, ScriptBuf, TxOut};
use super::*;
use crate::database::{BatchOperations, MemoryDatabase};
use crate::types::*;
use crate::wallet::Vbytes;
use rand::rngs::StdRng;
use rand::seq::SliceRandom;
use rand::{Rng, SeedableRng};
const P2WPKH_SATISFACTION_SIZE: usize = 1 + 1 + 72 + 1 + 33 + 4;
const FEE_AMOUNT: u64 = 50;
fn utxo(value: u64, index: u32) -> WeightedUtxo {
assert!(index < 10);
let outpoint = OutPoint::from_str(&format!(
"000000000000000000000000000000000000000000000000000000000000000{}:0",
index
))
.unwrap();
WeightedUtxo {
satisfaction_weight: P2WPKH_SATISFACTION_SIZE,
utxo: Utxo::Local(LocalUtxo {
outpoint,
txout: TxOut {
value,
script_pubkey: ScriptBuf::new(),
},
keychain: KeychainKind::External,
is_spent: false,
}),
}
}
fn get_test_utxos() -> Vec<WeightedUtxo> {
vec![utxo(100_000, 0), utxo(FEE_AMOUNT - 40, 1), utxo(200_000, 2)]
}
fn setup_database_and_get_oldest_first_test_utxos<D: Database>(
database: &mut D,
) -> Vec<WeightedUtxo> {
let utxo1 = utxo(120_000, 1);
let utxo2 = utxo(80_000, 2);
let utxo3 = utxo(300_000, 3);
let utxo1_tx_details = TransactionDetails {
transaction: None,
txid: utxo1.utxo.outpoint().txid,
received: 1,
sent: 0,
fee: None,
confirmation_time: Some(BlockTime {
height: 1,
timestamp: 1231006505,
}),
};
let utxo2_tx_details = TransactionDetails {
transaction: None,
txid: utxo2.utxo.outpoint().txid,
received: 1,
sent: 0,
fee: None,
confirmation_time: Some(BlockTime {
height: 2,
timestamp: 1231006505,
}),
};
let utxo3_tx_details = TransactionDetails {
transaction: None,
txid: utxo3.utxo.outpoint().txid,
received: 1,
sent: 0,
fee: None,
confirmation_time: Some(BlockTime {
height: 3,
timestamp: 1231006505,
}),
};
database.set_tx(&utxo1_tx_details).unwrap();
database.set_tx(&utxo2_tx_details).unwrap();
database.set_tx(&utxo3_tx_details).unwrap();
vec![utxo1, utxo2, utxo3]
}
fn generate_random_utxos(rng: &mut StdRng, utxos_number: usize) -> Vec<WeightedUtxo> {
let mut res = Vec::new();
for _ in 0..utxos_number {
res.push(WeightedUtxo {
satisfaction_weight: P2WPKH_SATISFACTION_SIZE,
utxo: Utxo::Local(LocalUtxo {
outpoint: OutPoint::from_str(
"ebd9813ecebc57ff8f30797de7c205e3c7498ca950ea4341ee51a685ff2fa30a:0",
)
.unwrap(),
txout: TxOut {
value: rng.gen_range(0..200000000),
script_pubkey: ScriptBuf::new(),
},
keychain: KeychainKind::External,
is_spent: false,
}),
});
}
res
}
fn generate_same_value_utxos(utxos_value: u64, utxos_number: usize) -> Vec<WeightedUtxo> {
let utxo = WeightedUtxo {
satisfaction_weight: P2WPKH_SATISFACTION_SIZE,
utxo: Utxo::Local(LocalUtxo {
outpoint: OutPoint::from_str(
"ebd9813ecebc57ff8f30797de7c205e3c7498ca950ea4341ee51a685ff2fa30a:0",
)
.unwrap(),
txout: TxOut {
value: utxos_value,
script_pubkey: ScriptBuf::new(),
},
keychain: KeychainKind::External,
is_spent: false,
}),
};
vec![utxo; utxos_number]
}
fn sum_random_utxos(mut rng: &mut StdRng, utxos: &mut [WeightedUtxo]) -> u64 {
let utxos_picked_len = rng.gen_range(2..utxos.len() / 2);
utxos.shuffle(&mut rng);
utxos[..utxos_picked_len]
.iter()
.map(|u| u.utxo.txout().value)
.sum()
}
#[test]
fn test_largest_first_coin_selection_success() {
let utxos = get_test_utxos();
let database = MemoryDatabase::default();
let drain_script = ScriptBuf::default();
let target_amount = 250_000 + FEE_AMOUNT;
let result = LargestFirstCoinSelection
.coin_select(
&database,
utxos,
vec![],
FeeRate::from_sat_per_vb(1.0),
target_amount,
&drain_script,
)
.unwrap();
assert_eq!(result.selected.len(), 3);
assert_eq!(result.selected_amount(), 300_010);
assert_eq!(result.fee_amount, 204)
}
#[test]
fn test_largest_first_coin_selection_use_all() {
let utxos = get_test_utxos();
let database = MemoryDatabase::default();
let drain_script = ScriptBuf::default();
let target_amount = 20_000 + FEE_AMOUNT;
let result = LargestFirstCoinSelection
.coin_select(
&database,
utxos,
vec![],
FeeRate::from_sat_per_vb(1.0),
target_amount,
&drain_script,
)
.unwrap();
assert_eq!(result.selected.len(), 3);
assert_eq!(result.selected_amount(), 300_010);
assert_eq!(result.fee_amount, 204);
}
#[test]
fn test_largest_first_coin_selection_use_only_necessary() {
let utxos = get_test_utxos();
let database = MemoryDatabase::default();
let drain_script = ScriptBuf::default();
let target_amount = 20_000 + FEE_AMOUNT;
let result = LargestFirstCoinSelection
.coin_select(
&database,
vec![],
utxos,
FeeRate::from_sat_per_vb(1.0),
target_amount,
&drain_script,
)
.unwrap();
assert_eq!(result.selected.len(), 1);
assert_eq!(result.selected_amount(), 200_000);
assert_eq!(result.fee_amount, 68);
}
#[test]
#[should_panic(expected = "InsufficientFunds")]
fn test_largest_first_coin_selection_insufficient_funds() {
let utxos = get_test_utxos();
let database = MemoryDatabase::default();
let drain_script = ScriptBuf::default();
let target_amount = 500_000 + FEE_AMOUNT;
LargestFirstCoinSelection
.coin_select(
&database,
vec![],
utxos,
FeeRate::from_sat_per_vb(1.0),
target_amount,
&drain_script,
)
.unwrap();
}
#[test]
#[should_panic(expected = "InsufficientFunds")]
fn test_largest_first_coin_selection_insufficient_funds_high_fees() {
let utxos = get_test_utxos();
let database = MemoryDatabase::default();
let drain_script = ScriptBuf::default();
let target_amount = 250_000 + FEE_AMOUNT;
LargestFirstCoinSelection
.coin_select(
&database,
vec![],
utxos,
FeeRate::from_sat_per_vb(1000.0),
target_amount,
&drain_script,
)
.unwrap();
}
#[test]
fn test_oldest_first_coin_selection_success() {
let mut database = MemoryDatabase::default();
let utxos = setup_database_and_get_oldest_first_test_utxos(&mut database);
let drain_script = ScriptBuf::default();
let target_amount = 180_000 + FEE_AMOUNT;
let result = OldestFirstCoinSelection
.coin_select(
&database,
vec![],
utxos,
FeeRate::from_sat_per_vb(1.0),
target_amount,
&drain_script,
)
.unwrap();
assert_eq!(result.selected.len(), 2);
assert_eq!(result.selected_amount(), 200_000);
assert_eq!(result.fee_amount, 136)
}
#[test]
fn test_oldest_first_coin_selection_utxo_not_in_db_will_be_selected_last() {
let utxo1 = utxo(120_000, 1);
let utxo2 = utxo(80_000, 2);
let utxo3 = utxo(300_000, 3);
let drain_script = ScriptBuf::default();
let mut database = MemoryDatabase::default();
let utxo1_tx_details = TransactionDetails {
transaction: None,
txid: utxo1.utxo.outpoint().txid,
received: 1,
sent: 0,
fee: None,
confirmation_time: Some(BlockTime {
height: 1,
timestamp: 1231006505,
}),
};
let utxo2_tx_details = TransactionDetails {
transaction: None,
txid: utxo2.utxo.outpoint().txid,
received: 1,
sent: 0,
fee: None,
confirmation_time: Some(BlockTime {
height: 2,
timestamp: 1231006505,
}),
};
database.set_tx(&utxo1_tx_details).unwrap();
database.set_tx(&utxo2_tx_details).unwrap();
let target_amount = 180_000 + FEE_AMOUNT;
let result = OldestFirstCoinSelection
.coin_select(
&database,
vec![],
vec![utxo3, utxo1, utxo2],
FeeRate::from_sat_per_vb(1.0),
target_amount,
&drain_script,
)
.unwrap();
assert_eq!(result.selected.len(), 2);
assert_eq!(result.selected_amount(), 200_000);
assert_eq!(result.fee_amount, 136)
}
#[test]
fn test_oldest_first_coin_selection_use_all() {
let mut database = MemoryDatabase::default();
let utxos = setup_database_and_get_oldest_first_test_utxos(&mut database);
let drain_script = ScriptBuf::default();
let target_amount = 20_000 + FEE_AMOUNT;
let result = OldestFirstCoinSelection
.coin_select(
&database,
utxos,
vec![],
FeeRate::from_sat_per_vb(1.0),
target_amount,
&drain_script,
)
.unwrap();
assert_eq!(result.selected.len(), 3);
assert_eq!(result.selected_amount(), 500_000);
assert_eq!(result.fee_amount, 204);
}
#[test]
fn test_oldest_first_coin_selection_use_only_necessary() {
let mut database = MemoryDatabase::default();
let utxos = setup_database_and_get_oldest_first_test_utxos(&mut database);
let drain_script = ScriptBuf::default();
let target_amount = 20_000 + FEE_AMOUNT;
let result = OldestFirstCoinSelection
.coin_select(
&database,
vec![],
utxos,
FeeRate::from_sat_per_vb(1.0),
target_amount,
&drain_script,
)
.unwrap();
assert_eq!(result.selected.len(), 1);
assert_eq!(result.selected_amount(), 120_000);
assert_eq!(result.fee_amount, 68);
}
#[test]
#[should_panic(expected = "InsufficientFunds")]
fn test_oldest_first_coin_selection_insufficient_funds() {
let mut database = MemoryDatabase::default();
let utxos = setup_database_and_get_oldest_first_test_utxos(&mut database);
let drain_script = ScriptBuf::default();
let target_amount = 600_000 + FEE_AMOUNT;
OldestFirstCoinSelection
.coin_select(
&database,
vec![],
utxos,
FeeRate::from_sat_per_vb(1.0),
target_amount,
&drain_script,
)
.unwrap();
}
#[test]
#[should_panic(expected = "InsufficientFunds")]
fn test_oldest_first_coin_selection_insufficient_funds_high_fees() {
let mut database = MemoryDatabase::default();
let utxos = setup_database_and_get_oldest_first_test_utxos(&mut database);
let target_amount: u64 = utxos.iter().map(|wu| wu.utxo.txout().value).sum::<u64>() - 50;
let drain_script = ScriptBuf::default();
OldestFirstCoinSelection
.coin_select(
&database,
vec![],
utxos,
FeeRate::from_sat_per_vb(1000.0),
target_amount,
&drain_script,
)
.unwrap();
}
#[test]
fn test_bnb_coin_selection_success() {
let utxos = generate_same_value_utxos(100_000, 20);
let database = MemoryDatabase::default();
let drain_script = ScriptBuf::default();
let target_amount = 250_000 + FEE_AMOUNT;
let result = BranchAndBoundCoinSelection::default()
.coin_select(
&database,
vec![],
utxos,
FeeRate::from_sat_per_vb(1.0),
target_amount,
&drain_script,
)
.unwrap();
assert_eq!(result.selected.len(), 3);
assert_eq!(result.selected_amount(), 300_000);
assert_eq!(result.fee_amount, 204);
}
#[test]
fn test_bnb_coin_selection_required_are_enough() {
let utxos = get_test_utxos();
let database = MemoryDatabase::default();
let drain_script = ScriptBuf::default();
let target_amount = 20_000 + FEE_AMOUNT;
let result = BranchAndBoundCoinSelection::default()
.coin_select(
&database,
utxos.clone(),
utxos,
FeeRate::from_sat_per_vb(1.0),
target_amount,
&drain_script,
)
.unwrap();
assert_eq!(result.selected.len(), 3);
assert_eq!(result.selected_amount(), 300_010);
assert_eq!(result.fee_amount, 204);
}
#[test]
fn test_bnb_coin_selection_optional_are_enough() {
let utxos = get_test_utxos();
let database = MemoryDatabase::default();
let drain_script = ScriptBuf::default();
let target_amount = 299756 + FEE_AMOUNT;
let result = BranchAndBoundCoinSelection::default()
.coin_select(
&database,
vec![],
utxos,
FeeRate::from_sat_per_vb(1.0),
target_amount,
&drain_script,
)
.unwrap();
assert_eq!(result.selected.len(), 2);
assert_eq!(result.selected_amount(), 300000);
assert_eq!(result.fee_amount, 136);
}
#[test]
#[ignore]
fn test_bnb_coin_selection_required_not_enough() {
let utxos = get_test_utxos();
let database = MemoryDatabase::default();
let required = vec![utxos[0].clone()];
let mut optional = utxos[1..].to_vec();
optional.push(utxo(500_000, 3));
let amount: u64 = required.iter().map(|u| u.utxo.txout().value).sum();
assert_eq!(amount, 100_000);
let amount: u64 = optional.iter().map(|u| u.utxo.txout().value).sum();
assert!(amount > 150_000);
let drain_script = ScriptBuf::default();
let target_amount = 150_000 + FEE_AMOUNT;
let result = BranchAndBoundCoinSelection::default()
.coin_select(
&database,
required,
optional,
FeeRate::from_sat_per_vb(1.0),
target_amount,
&drain_script,
)
.unwrap();
assert_eq!(result.selected.len(), 2);
assert_eq!(result.selected_amount(), 300_000);
assert_eq!(result.fee_amount, 136);
}
#[test]
#[should_panic(expected = "InsufficientFunds")]
fn test_bnb_coin_selection_insufficient_funds() {
let utxos = get_test_utxos();
let database = MemoryDatabase::default();
let drain_script = ScriptBuf::default();
let target_amount = 500_000 + FEE_AMOUNT;
BranchAndBoundCoinSelection::default()
.coin_select(
&database,
vec![],
utxos,
FeeRate::from_sat_per_vb(1.0),
target_amount,
&drain_script,
)
.unwrap();
}
#[test]
#[should_panic(expected = "InsufficientFunds")]
fn test_bnb_coin_selection_insufficient_funds_high_fees() {
let utxos = get_test_utxos();
let database = MemoryDatabase::default();
let drain_script = ScriptBuf::default();
let target_amount = 250_000 + FEE_AMOUNT;
BranchAndBoundCoinSelection::default()
.coin_select(
&database,
vec![],
utxos,
FeeRate::from_sat_per_vb(1000.0),
target_amount,
&drain_script,
)
.unwrap();
}
#[test]
fn test_bnb_coin_selection_check_fee_rate() {
let utxos = get_test_utxos();
let database = MemoryDatabase::default();
let drain_script = ScriptBuf::default();
let target_amount = 99932;
let result = BranchAndBoundCoinSelection::new(0)
.coin_select(
&database,
vec![],
utxos,
FeeRate::from_sat_per_vb(1.0),
target_amount,
&drain_script,
)
.unwrap();
assert_eq!(result.selected.len(), 1);
assert_eq!(result.selected_amount(), 100_000);
let input_size = (TXIN_BASE_WEIGHT + P2WPKH_SATISFACTION_SIZE).vbytes();
assert!((1.0 - (result.fee_amount as f32 / input_size as f32)).abs() < f32::EPSILON);
}
#[test]
fn test_bnb_coin_selection_exact_match() {
let seed = [0; 32];
let mut rng: StdRng = SeedableRng::from_seed(seed);
let database = MemoryDatabase::default();
for _i in 0..200 {
let mut optional_utxos = generate_random_utxos(&mut rng, 16);
let target_amount = sum_random_utxos(&mut rng, &mut optional_utxos);
let drain_script = ScriptBuf::default();
let result = BranchAndBoundCoinSelection::new(0)
.coin_select(
&database,
vec![],
optional_utxos,
FeeRate::from_sat_per_vb(0.0),
target_amount,
&drain_script,
)
.unwrap();
assert_eq!(result.selected_amount(), target_amount);
}
}
#[test]
#[should_panic(expected = "BnBNoExactMatch")]
fn test_bnb_function_no_exact_match() {
let fee_rate = FeeRate::from_sat_per_vb(10.0);
let utxos: Vec<OutputGroup> = get_test_utxos()
.into_iter()
.map(|u| OutputGroup::new(u, fee_rate))
.collect();
let curr_available_value = utxos.iter().fold(0, |acc, x| acc + x.effective_value);
let size_of_change = 31;
let cost_of_change = size_of_change as f32 * fee_rate.as_sat_per_vb();
let drain_script = ScriptBuf::default();
let target_amount = 20_000 + FEE_AMOUNT;
BranchAndBoundCoinSelection::new(size_of_change)
.bnb(
vec![],
utxos,
0,
curr_available_value,
target_amount as i64,
cost_of_change,
&drain_script,
fee_rate,
)
.unwrap();
}
#[test]
#[should_panic(expected = "BnBTotalTriesExceeded")]
fn test_bnb_function_tries_exceeded() {
let fee_rate = FeeRate::from_sat_per_vb(10.0);
let utxos: Vec<OutputGroup> = generate_same_value_utxos(100_000, 100_000)
.into_iter()
.map(|u| OutputGroup::new(u, fee_rate))
.collect();
let curr_available_value = utxos.iter().fold(0, |acc, x| acc + x.effective_value);
let size_of_change = 31;
let cost_of_change = size_of_change as f32 * fee_rate.as_sat_per_vb();
let target_amount = 20_000 + FEE_AMOUNT;
let drain_script = ScriptBuf::default();
BranchAndBoundCoinSelection::new(size_of_change)
.bnb(
vec![],
utxos,
0,
curr_available_value,
target_amount as i64,
cost_of_change,
&drain_script,
fee_rate,
)
.unwrap();
}
#[test]
fn test_bnb_function_almost_exact_match_with_fees() {
let fee_rate = FeeRate::from_sat_per_vb(1.0);
let size_of_change = 31;
let cost_of_change = size_of_change as f32 * fee_rate.as_sat_per_vb();
let utxos: Vec<_> = generate_same_value_utxos(50_000, 10)
.into_iter()
.map(|u| OutputGroup::new(u, fee_rate))
.collect();
let curr_value = 0;
let curr_available_value = utxos.iter().fold(0, |acc, x| acc + x.effective_value);
let target_amount = 2 * 50_000 - 2 * 67 - cost_of_change.ceil() as i64 + 5;
let drain_script = ScriptBuf::default();
let result = BranchAndBoundCoinSelection::new(size_of_change)
.bnb(
vec![],
utxos,
curr_value,
curr_available_value,
target_amount,
cost_of_change,
&drain_script,
fee_rate,
)
.unwrap();
assert_eq!(result.selected_amount(), 100_000);
assert_eq!(result.fee_amount, 136);
}
#[test]
fn test_bnb_function_exact_match_more_utxos() {
let seed = [0; 32];
let mut rng: StdRng = SeedableRng::from_seed(seed);
let fee_rate = FeeRate::from_sat_per_vb(0.0);
for _ in 0..200 {
let optional_utxos: Vec<_> = generate_random_utxos(&mut rng, 40)
.into_iter()
.map(|u| OutputGroup::new(u, fee_rate))
.collect();
let curr_value = 0;
let curr_available_value = optional_utxos
.iter()
.fold(0, |acc, x| acc + x.effective_value);
let target_amount =
optional_utxos[3].effective_value + optional_utxos[23].effective_value;
let drain_script = ScriptBuf::default();
let result = BranchAndBoundCoinSelection::new(0)
.bnb(
vec![],
optional_utxos,
curr_value,
curr_available_value,
target_amount,
0.0,
&drain_script,
fee_rate,
)
.unwrap();
assert_eq!(result.selected_amount(), target_amount as u64);
}
}
#[test]
fn test_single_random_draw_function_success() {
let seed = [0; 32];
let mut rng: StdRng = SeedableRng::from_seed(seed);
let mut utxos = generate_random_utxos(&mut rng, 300);
let target_amount = sum_random_utxos(&mut rng, &mut utxos) + FEE_AMOUNT;
let fee_rate = FeeRate::from_sat_per_vb(1.0);
let utxos: Vec<OutputGroup> = utxos
.into_iter()
.map(|u| OutputGroup::new(u, fee_rate))
.collect();
let drain_script = ScriptBuf::default();
let result = BranchAndBoundCoinSelection::default().single_random_draw(
vec![],
utxos,
0,
target_amount as i64,
&drain_script,
fee_rate,
);
assert!(result.selected_amount() > target_amount);
assert_eq!(result.fee_amount, (result.selected.len() * 68) as u64);
}
#[test]
fn test_bnb_exclude_negative_effective_value() {
let utxos = get_test_utxos();
let database = MemoryDatabase::default();
let drain_script = ScriptBuf::default();
let selection = BranchAndBoundCoinSelection::default().coin_select(
&database,
vec![],
utxos,
FeeRate::from_sat_per_vb(10.0),
500_000,
&drain_script,
);
assert_matches!(
selection,
Err(Error::InsufficientFunds {
available: 300_000,
..
})
);
}
#[test]
fn test_bnb_include_negative_effective_value_when_required() {
let utxos = get_test_utxos();
let database = MemoryDatabase::default();
let drain_script = ScriptBuf::default();
let (required, optional) = utxos
.into_iter()
.partition(|u| matches!(u, WeightedUtxo { utxo, .. } if utxo.txout().value < 1000));
let selection = BranchAndBoundCoinSelection::default().coin_select(
&database,
required,
optional,
FeeRate::from_sat_per_vb(10.0),
500_000,
&drain_script,
);
assert_matches!(
selection,
Err(Error::InsufficientFunds {
available: 300_010,
..
})
);
}
#[test]
fn test_bnb_sum_of_effective_value_negative() {
let utxos = get_test_utxos();
let database = MemoryDatabase::default();
let drain_script = ScriptBuf::default();
let selection = BranchAndBoundCoinSelection::default().coin_select(
&database,
utxos,
vec![],
FeeRate::from_sat_per_vb(10_000.0),
500_000,
&drain_script,
);
assert_matches!(
selection,
Err(Error::InsufficientFunds {
available: 300_010,
..
})
);
}
}