From 545beec743412717895d0ba5d32ccd9aac68f4a6 Mon Sep 17 00:00:00 2001 From: KaFai Choi Date: Sat, 26 Feb 2022 18:15:57 +0700 Subject: [PATCH] add OldestFirstCoinSelection --- CHANGELOG.md | 1 + src/wallet/coin_selection.rs | 312 ++++++++++++++++++++++++++++++++++- 2 files changed, 312 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1dabc409..4e797aa0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -27,6 +27,7 @@ To decouple the `Wallet` from the `Blockchain` we've made major changes: - Stop making a request for the block height when calling `Wallet:new`. - Added `SyncOptions` to capture extra (future) arguments to `Wallet::sync`. - Removed `max_addresses` sync parameter which determined how many addresses to cache before syncing since this can just be done with `ensure_addresses_cached`. +- added `OldestFirstCoinSelection` impl to `CoinSelectionAlgorithm` ## [v0.16.1] - [v0.16.0] diff --git a/src/wallet/coin_selection.rs b/src/wallet/coin_selection.rs index 2b549b45..1538346a 100644 --- a/src/wallet/coin_selection.rs +++ b/src/wallet/coin_selection.rs @@ -97,6 +97,7 @@ use rand::seq::SliceRandom; use rand::thread_rng; #[cfg(test)] use rand::{rngs::StdRng, SeedableRng}; +use std::collections::HashMap; use std::convert::TryInto; /// Default coin selection algorithm used by [`TxBuilder`](super::tx_builder::TxBuilder) if not @@ -242,6 +243,102 @@ impl CoinSelectionAlgorithm for LargestFirstCoinSelection { } } +/// 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 CoinSelectionAlgorithm for OldestFirstCoinSelection { + fn coin_select( + &self, + database: &D, + required_utxos: Vec, + mut optional_utxos: Vec, + fee_rate: FeeRate, + amount_needed: u64, + mut fee_amount: u64, + ) -> Result { + // 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))) + }; + + // Keep including inputs until we've got enough. + // Store the total input value in selected_amount and the total fee being paid in fee_amount + let mut selected_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 < amount_needed + **fee_amount { + **fee_amount += + fee_rate.fee_wu(TXIN_BASE_WEIGHT + weighted_utxo.satisfaction_weight); + **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::>(); + + let amount_needed_with_fees = amount_needed + fee_amount; + if selected_amount < amount_needed_with_fees { + return Err(Error::InsufficientFunds { + needed: amount_needed_with_fees, + available: selected_amount, + }); + } + + Ok(CoinSelectionResult { + selected, + fee_amount, + }) + } +} + #[derive(Debug, Clone)] // Adds fee information to an UTXO. struct OutputGroup { @@ -541,7 +638,7 @@ mod test { use bitcoin::{OutPoint, Script, TxOut}; use super::*; - use crate::database::MemoryDatabase; + use crate::database::{BatchOperations, MemoryDatabase}; use crate::types::*; use crate::wallet::Vbytes; @@ -582,6 +679,61 @@ mod test { ] } + fn setup_database_and_get_oldest_first_test_utxos( + database: &mut D, + ) -> Vec { + // 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 { let mut res = Vec::new(); for _ in 0..utxos_number { @@ -731,6 +883,164 @@ mod test { .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::() - (FEE_AMOUNT + 50); + + OldestFirstCoinSelection::default() + .coin_select( + &database, + vec![], + utxos, + FeeRate::from_sat_per_vb(1000.0), + amount_needed, + FEE_AMOUNT, + ) + .unwrap(); + } + #[test] fn test_bnb_coin_selection_success() { // In this case bnb won't find a suitable match and single random draw will