Merge bitcoindevkit/bdk#557: add OldestFirstCoinSelection
6931d0bd1f044bf73c0cb760c0eb0be19aea8de1 add private function select_sorted_utxso to be resued by multiple CoinSelection impl (KaFai Choi) 545beec743412717895d0ba5d32ccd9aac68f4a6 add OldestFirstCoinSelection (KaFai Choi) Pull request description: <!-- You can erase any parts of this template not applicable to your Pull Request. --> ### Description This PR is to add `OldestFirstCoinSelection`. See this issue for detail https://github.com/bitcoindevkit/bdk/issues/120 <!-- Describe the purpose of this PR, what's being adding and/or fixed --> ### Notes to the reviewers Apologize in advance if the quality of this PR is too low.(I am newbie in both bitcoin wallet and rust). While this PR seemed very straight-forward to me in the first glance, it's actually a bit more complicated than I thought as it involves calling DB get the blockheight before sorting it. The current implementation should be pretty naive but I would like to get some opinion to see if I am heading to a right direction first before working on optimizations like ~~1. Avoiding calling DB for optional_utxos if if the amount from required_utxos are already enough.~~ Probably not worth to do such optimization to keep code simpler? ### Checklists #### All Submissions: * [x] I've signed all my commits * [x] I followed the [contribution guidelines](https://github.com/bitcoindevkit/bdk/blob/master/CONTRIBUTING.md) * [x] I ran `cargo fmt` and `cargo clippy` before committing #### New Features: * [x] I've added tests for the new feature * [x] I've added docs for the new feature * [x] I've updated `CHANGELOG.md` #### Bugfixes: * [ ] This pull request breaks the existing API * [ ] I've added tests to reproduce the issue which are now passing * [ ] I'm linking the issue being fixed by this PR ACKs for top commit: afilini: ACK 6931d0bd1f044bf73c0cb760c0eb0be19aea8de1 Tree-SHA512: d297bbad847d99cfdd8c6b1450c3777c5d55bc51c7934f287975c4d114a21840d428a75a172bfb7eacbac95413535452b644cab971efb8c0b5caf0d06d6d8356
This commit is contained in:
commit
6e8744d59d
@ -6,6 +6,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
- added `OldestFirstCoinSelection` impl to `CoinSelectionAlgorithm`
|
||||||
|
|
||||||
|
|
||||||
## [v0.18.0] - [v0.17.0]
|
## [v0.18.0] - [v0.17.0]
|
||||||
|
@ -97,6 +97,7 @@ use rand::seq::SliceRandom;
|
|||||||
use rand::thread_rng;
|
use rand::thread_rng;
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
use rand::{rngs::StdRng, SeedableRng};
|
use rand::{rngs::StdRng, SeedableRng};
|
||||||
|
use std::collections::HashMap;
|
||||||
use std::convert::TryInto;
|
use std::convert::TryInto;
|
||||||
|
|
||||||
/// Default coin selection algorithm used by [`TxBuilder`](super::tx_builder::TxBuilder) if not
|
/// Default coin selection algorithm used by [`TxBuilder`](super::tx_builder::TxBuilder) if not
|
||||||
@ -182,7 +183,7 @@ impl<D: Database> CoinSelectionAlgorithm<D> for LargestFirstCoinSelection {
|
|||||||
mut optional_utxos: Vec<WeightedUtxo>,
|
mut optional_utxos: Vec<WeightedUtxo>,
|
||||||
fee_rate: FeeRate,
|
fee_rate: FeeRate,
|
||||||
amount_needed: u64,
|
amount_needed: u64,
|
||||||
mut fee_amount: u64,
|
fee_amount: u64,
|
||||||
) -> Result<CoinSelectionResult, Error> {
|
) -> Result<CoinSelectionResult, Error> {
|
||||||
log::debug!(
|
log::debug!(
|
||||||
"amount_needed = `{}`, fee_amount = `{}`, fee_rate = `{:?}`",
|
"amount_needed = `{}`, fee_amount = `{}`, fee_rate = `{:?}`",
|
||||||
@ -201,8 +202,75 @@ impl<D: Database> CoinSelectionAlgorithm<D> for LargestFirstCoinSelection {
|
|||||||
.chain(optional_utxos.into_iter().rev().map(|utxo| (false, utxo)))
|
.chain(optional_utxos.into_iter().rev().map(|utxo| (false, utxo)))
|
||||||
};
|
};
|
||||||
|
|
||||||
// Keep including inputs until we've got enough.
|
select_sorted_utxos(utxos, fee_rate, amount_needed, fee_amount)
|
||||||
// Store the total input value in selected_amount and the total fee being paid in fee_amount
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// OldestFirstCoinSelection always picks the utxo with the smallest blockheight to add to the selected coins next
|
||||||
|
///
|
||||||
|
/// This coin selection algorithm sorts the available UTXOs by blockheight and then picks them starting
|
||||||
|
/// from the oldest ones until the required amount is reached.
|
||||||
|
#[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,
|
||||||
|
amount_needed: u64,
|
||||||
|
fee_amount: u64,
|
||||||
|
) -> Result<CoinSelectionResult, Error> {
|
||||||
|
// query db and create a blockheight lookup table
|
||||||
|
let blockheights = optional_utxos
|
||||||
|
.iter()
|
||||||
|
.map(|wu| wu.utxo.outpoint().txid)
|
||||||
|
// fold is used so we can skip db query for txid that already exist in hashmap acc
|
||||||
|
.fold(Ok(HashMap::new()), |bh_result_acc, txid| {
|
||||||
|
bh_result_acc.and_then(|mut bh_acc| {
|
||||||
|
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
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})?;
|
||||||
|
|
||||||
|
// We put the "required UTXOs" first and make sure the optional UTXOs are sorted from
|
||||||
|
// oldest to newest according to blocktime
|
||||||
|
// For utxo that doesn't exist in DB, they will have lowest priority to be selected
|
||||||
|
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, amount_needed, fee_amount)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn select_sorted_utxos(
|
||||||
|
utxos: impl Iterator<Item = (bool, WeightedUtxo)>,
|
||||||
|
fee_rate: FeeRate,
|
||||||
|
amount_needed: u64,
|
||||||
|
mut fee_amount: u64,
|
||||||
|
) -> Result<CoinSelectionResult, Error> {
|
||||||
let mut selected_amount = 0;
|
let mut selected_amount = 0;
|
||||||
let selected = utxos
|
let selected = utxos
|
||||||
.scan(
|
.scan(
|
||||||
@ -239,7 +307,6 @@ impl<D: Database> CoinSelectionAlgorithm<D> for LargestFirstCoinSelection {
|
|||||||
selected,
|
selected,
|
||||||
fee_amount,
|
fee_amount,
|
||||||
})
|
})
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
@ -541,7 +608,7 @@ mod test {
|
|||||||
use bitcoin::{OutPoint, Script, TxOut};
|
use bitcoin::{OutPoint, Script, TxOut};
|
||||||
|
|
||||||
use super::*;
|
use super::*;
|
||||||
use crate::database::MemoryDatabase;
|
use crate::database::{BatchOperations, MemoryDatabase};
|
||||||
use crate::types::*;
|
use crate::types::*;
|
||||||
use crate::wallet::Vbytes;
|
use crate::wallet::Vbytes;
|
||||||
|
|
||||||
@ -582,6 +649,61 @@ mod test {
|
|||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn setup_database_and_get_oldest_first_test_utxos<D: Database>(
|
||||||
|
database: &mut D,
|
||||||
|
) -> Vec<WeightedUtxo> {
|
||||||
|
// ensure utxos are from different tx
|
||||||
|
let utxo1 = utxo(120_000, 1);
|
||||||
|
let utxo2 = utxo(80_000, 2);
|
||||||
|
let utxo3 = utxo(300_000, 3);
|
||||||
|
|
||||||
|
// add tx to DB so utxos are sorted by blocktime asc
|
||||||
|
// utxos will be selected by the following order
|
||||||
|
// utxo1(blockheight 1) -> utxo2(blockheight 2), utxo3 (blockheight 3)
|
||||||
|
// timestamp are all set as the same to ensure that only block height is used in sorting
|
||||||
|
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> {
|
fn generate_random_utxos(rng: &mut StdRng, utxos_number: usize) -> Vec<WeightedUtxo> {
|
||||||
let mut res = Vec::new();
|
let mut res = Vec::new();
|
||||||
for _ in 0..utxos_number {
|
for _ in 0..utxos_number {
|
||||||
@ -731,6 +853,164 @@ mod test {
|
|||||||
.unwrap();
|
.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 result = OldestFirstCoinSelection::default()
|
||||||
|
.coin_select(
|
||||||
|
&database,
|
||||||
|
vec![],
|
||||||
|
utxos,
|
||||||
|
FeeRate::from_sat_per_vb(1.0),
|
||||||
|
180_000,
|
||||||
|
FEE_AMOUNT,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(result.selected.len(), 2);
|
||||||
|
assert_eq!(result.selected_amount(), 200_000);
|
||||||
|
assert_eq!(result.fee_amount, 186)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_oldest_first_coin_selection_utxo_not_in_db_will_be_selected_last() {
|
||||||
|
// ensure utxos are from different tx
|
||||||
|
let utxo1 = utxo(120_000, 1);
|
||||||
|
let utxo2 = utxo(80_000, 2);
|
||||||
|
let utxo3 = utxo(300_000, 3);
|
||||||
|
|
||||||
|
let mut database = MemoryDatabase::default();
|
||||||
|
|
||||||
|
// add tx to DB so utxos are sorted by blocktime asc
|
||||||
|
// utxos will be selected by the following order
|
||||||
|
// utxo1(blockheight 1) -> utxo2(blockheight 2), utxo3 (not exist in DB)
|
||||||
|
// timestamp are all set as the same to ensure that only block height is used in sorting
|
||||||
|
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 result = OldestFirstCoinSelection::default()
|
||||||
|
.coin_select(
|
||||||
|
&database,
|
||||||
|
vec![],
|
||||||
|
vec![utxo3, utxo1, utxo2],
|
||||||
|
FeeRate::from_sat_per_vb(1.0),
|
||||||
|
180_000,
|
||||||
|
FEE_AMOUNT,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(result.selected.len(), 2);
|
||||||
|
assert_eq!(result.selected_amount(), 200_000);
|
||||||
|
assert_eq!(result.fee_amount, 186)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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 result = OldestFirstCoinSelection::default()
|
||||||
|
.coin_select(
|
||||||
|
&database,
|
||||||
|
utxos,
|
||||||
|
vec![],
|
||||||
|
FeeRate::from_sat_per_vb(1.0),
|
||||||
|
20_000,
|
||||||
|
FEE_AMOUNT,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(result.selected.len(), 3);
|
||||||
|
assert_eq!(result.selected_amount(), 500_000);
|
||||||
|
assert_eq!(result.fee_amount, 254);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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 result = OldestFirstCoinSelection::default()
|
||||||
|
.coin_select(
|
||||||
|
&database,
|
||||||
|
vec![],
|
||||||
|
utxos,
|
||||||
|
FeeRate::from_sat_per_vb(1.0),
|
||||||
|
20_000,
|
||||||
|
FEE_AMOUNT,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(result.selected.len(), 1);
|
||||||
|
assert_eq!(result.selected_amount(), 120_000);
|
||||||
|
assert_eq!(result.fee_amount, 118);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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);
|
||||||
|
|
||||||
|
OldestFirstCoinSelection::default()
|
||||||
|
.coin_select(
|
||||||
|
&database,
|
||||||
|
vec![],
|
||||||
|
utxos,
|
||||||
|
FeeRate::from_sat_per_vb(1.0),
|
||||||
|
600_000,
|
||||||
|
FEE_AMOUNT,
|
||||||
|
)
|
||||||
|
.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 amount_needed: u64 =
|
||||||
|
utxos.iter().map(|wu| wu.utxo.txout().value).sum::<u64>() - (FEE_AMOUNT + 50);
|
||||||
|
|
||||||
|
OldestFirstCoinSelection::default()
|
||||||
|
.coin_select(
|
||||||
|
&database,
|
||||||
|
vec![],
|
||||||
|
utxos,
|
||||||
|
FeeRate::from_sat_per_vb(1000.0),
|
||||||
|
amount_needed,
|
||||||
|
FEE_AMOUNT,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_bnb_coin_selection_success() {
|
fn test_bnb_coin_selection_success() {
|
||||||
// In this case bnb won't find a suitable match and single random draw will
|
// In this case bnb won't find a suitable match and single random draw will
|
||||||
|
Loading…
x
Reference in New Issue
Block a user