]> Untitled Git - bdk/commitdiff
add OldestFirstCoinSelection
authorKaFai Choi <kafai@river.com>
Sat, 26 Feb 2022 11:15:57 +0000 (18:15 +0700)
committerKaFai Choi <kafai@river.com>
Sat, 2 Apr 2022 03:59:04 +0000 (10:59 +0700)
CHANGELOG.md
src/wallet/coin_selection.rs

index 1dabc409a7583fb64ef2431aff941fa6a3458818..4e797aa0fe4f96228edc96729fef71a3b0c64a1d 100644 (file)
@@ -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]
 
index 2b549b45d01dac464153038ab005cd077e6ad4a4..1538346a70c817ba70de8d9fabd4cbe68672344a 100644 (file)
@@ -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<D: Database> CoinSelectionAlgorithm<D> 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<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,
+        mut 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)))
+        };
+
+        // 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::<Vec<_>>();
+
+        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<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> {
         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::<u64>() - (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