From 74ac57537bdcdb70ef0e6cce80aea96a0c2de7a3 Mon Sep 17 00:00:00 2001 From: yancy Date: Tue, 10 Jan 2023 17:39:23 +0100 Subject: [PATCH] Add single random draw selection algorithm --- CHANGELOG.md | 4 + Cargo.toml | 7 +- clippy.toml | 2 +- src/errors.rs | 26 ++++ src/lib.rs | 127 ++++++++----------- src/single_random_draw.rs | 251 ++++++++++++++++++++++++++++++++++++++ 6 files changed, 339 insertions(+), 78 deletions(-) create mode 100644 src/errors.rs create mode 100644 src/single_random_draw.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index 0d78b66..bf1eedc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,7 @@ # 0.1.0 - 2022-12-17 * Initial release. + +# 0.2.0 - 2023-06-03 + +- Add Single Random Draw module and a basic error type. diff --git a/Cargo.toml b/Cargo.toml index 5bc2626..ab8cb3b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -8,14 +8,19 @@ homepage = "https://github.com/rust-bitcoin/rust-bitcoin-coin-selection/" license = "CC0-1.0" name = "rust-bitcoin-coin-selection" repository = "https://github.com/rust-bitcoin/rust-bitcoin-coin-selection/" -version = "0.1.0" +version = "0.2.0" # documentation = "https://docs.rs/bitcoin-coin-selection/" description = "Libary providing utility functions to efficiently select a set of UTXOs." keywords = ["crypto", "bitcoin"] readme = "README.md" [dependencies] +bitcoin = { git="https://github.com/yancyribbens/rust-bitcoin", branch="master" } rand = {version = "0.8.5", default-features = false, optional = true} [dev-dependencies] +rust-bitcoin-coin-selection = {path = ".", features = ["rand"]} rand = "0.8.5" + +[patch.crates-io] +bitcoin_hashes = { git="https://github.com/yancyribbens/rust-bitcoin.git" } diff --git a/clippy.toml b/clippy.toml index 799264e..11d46a7 100644 --- a/clippy.toml +++ b/clippy.toml @@ -1 +1 @@ -msrv = "1.41.1" +msrv = "1.48.0" diff --git a/src/errors.rs b/src/errors.rs new file mode 100644 index 0000000..3f6ff32 --- /dev/null +++ b/src/errors.rs @@ -0,0 +1,26 @@ +/// Error types. +use bitcoin::FeeRate; +use bitcoin::Weight; +use std::error::Error as E; +use std::fmt; + +#[derive(Debug)] +pub enum Error { + MultiplicationOverflow(Weight, FeeRate), + AdditionOverflow(Weight, Weight), +} + +impl E for Error {} + +impl fmt::Display for Error { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + match self { + Error::MultiplicationOverflow(one, two) => { + write!(f, "{} * {} exceeds u64 Max", one, two) + } + Error::AdditionOverflow(one, two) => { + write!(f, "{} + {} exceeds u64 Max", one, two) + } + } + } +} diff --git a/src/lib.rs b/src/lib.rs index d4e143d..075d6bf 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -9,7 +9,6 @@ #![deny(non_snake_case)] #![deny(unused_mut)] #![deny(missing_docs)] - // Experimental features we need. #![cfg_attr(bench, feature(test))] #![cfg_attr(docsrs, feature(doc_cfg))] @@ -19,8 +18,17 @@ extern crate test; use std::cmp::Reverse; -#[cfg(any(test, feature = "rand"))] -use rand::{seq::SliceRandom, thread_rng}; +mod errors; +mod single_random_draw; + +use bitcoin::Amount; +use bitcoin::FeeRate; +use bitcoin::TxOut; +use bitcoin::Weight; + +use crate::errors::Error; +use crate::single_random_draw::select_coins_srd; +use rand::thread_rng; /// Trait that a UTXO struct must implement to be used as part of the coin selection /// algorithm. @@ -29,52 +37,56 @@ pub trait Utxo: Clone { fn get_value(&self) -> u64; } +// https://github.com/bitcoin/bitcoin/blob/f722a9bd132222d9d5cd503b5af25c905b205cdb/src/wallet/coinselection.h#L20 +const CHANGE_LOWER: Amount = Amount::from_sat(50_000); + +// TODO add to Rust-bitcoin +/// The base weight is the output (32 + 4) + nSequence 4 +/// +pub const TXIN_BASE_WEIGHT: Weight = Weight::from_wu(32 + 4 + 4); + +// TODO: Use miniscript's max_weight_to_satisfy() method to calculate the +// max satisfaction weight instead. Currently, implementers of this crate +// are required to loop through each UTXO and calculate the satisfaction_weight. +// Instead, by using max_weight_to_satisfy() should allow the implementer to pass +// the UTXO set unmodified. IE reduce the runtime-complexity by O(n). + +/// This struct contains the weight of all params needed to satisfy the UTXO. +/// +/// The idea of using a WeightUtxo type was inspired by the BDK implementation: +/// https://github.com/bitcoindevkit/bdk/blob/feafaaca31a0a40afc03ce98591d151c48c74fa2/crates/bdk/src/types.rs#L181 +#[derive(Clone, Debug, PartialEq)] +pub struct WeightedUtxo { + /// TODO + pub satisfaction_weight: Weight, + /// TODO + pub utxo: TxOut, +} + /// Select coins first using BnB algorithm similar to what is done in bitcoin /// core see: , /// and falls back on a random UTXO selection. Returns none if the target cannot /// be reached with the given utxo pool. /// Requires compilation with the "rand" feature. -#[cfg(any(test, feature = "rand"))] +#[cfg(feature = "rand")] #[cfg_attr(docsrs, doc(cfg(feature = "rand")))] +// removeing utxo_pool param next release +#[allow(clippy::too_many_arguments)] pub fn select_coins( - target: u64, + target: Amount, cost_of_change: u64, + fee_rate: FeeRate, + weighted_utxos: &mut [WeightedUtxo], utxo_pool: &mut [T], -) -> Option> { - match select_coins_bnb(target, cost_of_change, utxo_pool) { - Some(res) => Some(res), - None => select_coins_random(target, utxo_pool), - } -} - -/// Randomly select coins for the given target by shuffling the utxo pool and -/// taking UTXOs until the given target is reached, or returns None if the target -/// cannot be reached with the given utxo pool. -/// Requires compilation with the "rand" feature. -#[cfg(any(test, feature = "rand"))] -#[cfg_attr(docsrs, doc(cfg(feature = "rand")))] -pub fn select_coins_random(target: u64, utxo_pool: &mut [T]) -> Option> { - utxo_pool.shuffle(&mut thread_rng()); - - let mut sum = 0; - - let res = utxo_pool - .iter() - .take_while(|x| { - if sum >= target { - return false; - } - sum += x.get_value(); - true - }) - .cloned() - .collect(); - - if sum >= target { - return Some(res); +) -> Result, Error> { + match select_coins_bnb(target.to_sat(), cost_of_change, utxo_pool) { + Some(_res) => Ok(Vec::new()), + None => Ok(select_coins_srd(target, fee_rate, weighted_utxos, &mut thread_rng()) + .unwrap() + .into_iter() + .map(|w| w.utxo) + .collect()), } - - None } /// Select coins using BnB algorithm similar to what is done in bitcoin @@ -293,43 +305,6 @@ mod tests { let utxo_match = select_coins_bnb(1, COST_OF_CHANGE, &mut UTXO_POOL.clone()); assert_eq!(None, utxo_match); } - - #[test] - fn select_coins_random_draw_with_solution() { - let utxo_match = select_coins_random(ONE_BTC, &mut UTXO_POOL.clone()); - utxo_match.expect("Did not properly randomly select coins"); - } - - #[test] - fn select_coins_random_draw_no_solution() { - let utxo_match = select_coins_random(11 * ONE_BTC, &mut UTXO_POOL.clone()); - assert!(utxo_match.is_none()); - } - - #[test] - fn select_coins_bnb_match_with_random() { - let utxo_match = select_coins(1, COST_OF_CHANGE, &mut UTXO_POOL.clone()); - utxo_match.expect("Did not use random selection"); - } - - #[test] - fn select_coins_random_test() { - let mut test_utxo_pool = vec![MinimalUtxo { value: 5000000000 }]; - - let utxo_match = - select_coins(100000358, 20, &mut test_utxo_pool).expect("Did not find match"); - - assert_eq!(1, utxo_match.len()); - } - - #[test] - fn select_coins_random_fail_test() { - let mut test_utxo_pool = vec![MinimalUtxo { value: 5000000000 }]; - - let utxo_match = select_coins(5000000358, 20, &mut test_utxo_pool); - - assert!(utxo_match.is_none()); - } } #[cfg(bench)] diff --git a/src/single_random_draw.rs b/src/single_random_draw.rs new file mode 100644 index 0000000..465c796 --- /dev/null +++ b/src/single_random_draw.rs @@ -0,0 +1,251 @@ +//! This library provides efficient algorithms to compose a set of unspent transaction outputs +//! (UTXOs). + +use crate::errors::Error; +use crate::WeightedUtxo; +use crate::CHANGE_LOWER; +use crate::TXIN_BASE_WEIGHT; +use bitcoin::Amount; +use bitcoin::FeeRate; +use rand::seq::SliceRandom; + +/// Calculates the effective_value of an input. +/// +/// Returns `Ok(None)` if the effective_value is negative. If the effective_value is positive, return `Ok(Some(Amount))`. +/// +/// ## Errors +/// +/// Returns `Err(Error::Multiplication)` if `FeeRate` * `Weight` overflows. +/// +fn get_effective_value( + weighted_utxo: &WeightedUtxo, + fee_rate: FeeRate, +) -> Result, Error> { + let satisfaction_weight = weighted_utxo.satisfaction_weight; + + let checked_weight = satisfaction_weight.checked_add(TXIN_BASE_WEIGHT); + + let weight = match checked_weight { + Some(w) => w, + None => return Err(Error::AdditionOverflow(satisfaction_weight, TXIN_BASE_WEIGHT)), + }; + + let input_fee: Option = fee_rate.checked_mul_by_weight(weight); + + match input_fee { + Some(f) => Ok(weighted_utxo.utxo.value.checked_sub(f)), + None => Err(Error::MultiplicationOverflow(satisfaction_weight, fee_rate)), + } +} + +/// Randomly select coins for the given target by shuffling the UTXO pool and +/// taking UTXOs until the given target is reached. +/// +/// The fee_rate can have an impact on the selection process since the fee +/// must be paid for in addition to the target. However, the total fee +/// is dependant on the number of UTXOs consumed and the new inputs created. +/// The selection strategy therefore calculates the fees of what is known +/// ahead of time (the number of UTXOs create and the transaction header), +/// and then then for each new input, the effective_value is tracked which +/// deducts the fee for each individual input at selection time. For more +/// discussion see the following: +/// +/// https://bitcoin.stackexchange.com/questions/103654/calculating-fee-based-on-fee-rate-for-bitcoin-transaction/114847#114847 +/// +/// ## Parameters +/// /// +/// /// * `target` - target value to send to recipient. Include the fee to pay for the known parts of the transaction excluding the fee for the inputs. +/// /// * `fee_rate` - ratio of transaction amount per size. +/// /// * `weighted_utxos` - Weighted UTXOs from which to sum the target amount. +/// /// * `rng` - used primarily by tests to make the selection deterministic. +pub fn select_coins_srd( + target: Amount, + fee_rate: FeeRate, + weighted_utxos: &mut [WeightedUtxo], + rng: &mut R, +) -> Result, Error> { + let mut result: Vec = Vec::new(); + + weighted_utxos.shuffle(rng); + + let threshold = target + CHANGE_LOWER; + let mut value = Amount::ZERO; + + for w_utxo in weighted_utxos { + // note: I'd prefer to use filter() and filter all inputs that have a negative + // effective_value, however, an error message is returned if a multiplication + // overflow occurs while the caclulating effective_value. + let effective_value: Option = get_effective_value(w_utxo, fee_rate)?; + + // skip if effective_value is negative. + match effective_value { + Some(e) => value += e, + None => continue, + } + + result.push(w_utxo.clone()); + + if value >= threshold { + return Ok(result); + } + } + + Ok(Vec::new()) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::single_random_draw::select_coins_srd; + use crate::WeightedUtxo; + use crate::CHANGE_LOWER; + use bitcoin::Amount; + use bitcoin::ScriptBuf; + use bitcoin::TxOut; + use bitcoin::Weight; + use core::str::FromStr; + use rand::rngs::mock::StepRng; + + const FEE_RATE: FeeRate = FeeRate::from_sat_per_kwu(10); + const SATISFACTION_SIZE: Weight = Weight::from_wu(204); + + fn create_weighted_utxos() -> Vec { + let utxo_one = WeightedUtxo { + satisfaction_weight: SATISFACTION_SIZE, + utxo: TxOut { + value: Amount::from_str("1 cBTC").unwrap(), + script_pubkey: ScriptBuf::new(), + }, + }; + + let utxo_two = WeightedUtxo { + satisfaction_weight: SATISFACTION_SIZE, + utxo: TxOut { + value: Amount::from_str("2 cBTC").unwrap(), + script_pubkey: ScriptBuf::new(), + }, + }; + + vec![utxo_one, utxo_two] + } + + fn get_rng() -> StepRng { + // [1, 2] + // let mut vec: Vec = (1..3).collect(); + // let mut rng = StepRng::new(0, 0); + // + // [2, 1] + // vec.shuffle(&mut rng); + + // shuffle() will always result in the order described above when a constant + // is used as the rng. The first is removed from the beginning and added to + // the end while the remaining elements keep their order. + StepRng::new(0, 0) + } + + #[test] + fn select_coins_srd_with_solution() { + let target: Amount = Amount::from_str("1.5 cBTC").unwrap(); + let mut weighted_utxos: Vec = create_weighted_utxos(); + + let result = select_coins_srd(target, FEE_RATE, &mut weighted_utxos, &mut get_rng()) + .expect("unexpected error"); + + assert_eq!(vec![weighted_utxos[0].clone()], result); + } + + #[test] + fn select_coins_srd_no_solution() { + let target: Amount = Amount::from_str("4 cBTC").unwrap(); + let mut weighted_utxos: Vec = create_weighted_utxos(); + + let result = select_coins_srd(target, FEE_RATE, &mut weighted_utxos, &mut get_rng()) + .expect("unexpected error"); + + assert!(result.is_empty()); + } + + #[test] + fn select_coins_srd_all_solution() { + let target: Amount = Amount::from_str("2.5 cBTC").unwrap(); + let mut weighted_utxos: Vec = create_weighted_utxos(); + + let result = select_coins_srd(target, FeeRate::ZERO, &mut weighted_utxos, &mut get_rng()) + .expect("unexpected error"); + + assert_eq!(weighted_utxos.clone(), result); + } + + #[test] + fn select_coins_skip_negative_effective_value() { + let target: Amount = Amount::from_str("1 cBTC").unwrap() - CHANGE_LOWER; + + let mut weighted_utxos: Vec = vec![WeightedUtxo { + satisfaction_weight: Weight::ZERO, + utxo: TxOut { + value: Amount::from_str("1 sat").unwrap(), + script_pubkey: ScriptBuf::new(), + }, + }]; + + let result = select_coins_srd(target, FEE_RATE, &mut weighted_utxos, &mut get_rng()) + .expect("unexpected error"); + + assert!(result.is_empty()); + } + + #[test] + fn select_coins_srd_fee_rate_error() { + let target: Amount = Amount::from_str("2 cBTC").unwrap(); + let mut weighted_utxos: Vec = create_weighted_utxos(); + + let result: Error = + select_coins_srd(target, FeeRate::MAX, &mut weighted_utxos, &mut get_rng()) + .expect_err("expected error"); + + assert_eq!(result.to_string(), "204 * 18446744073709551615 exceeds u64 Max"); + } + + #[test] + fn select_coins_srd_change_output_too_small() { + let target: Amount = Amount::from_str("3 cBTC").unwrap(); + let mut weighted_utxos: Vec = create_weighted_utxos(); + + let result = select_coins_srd(target, FEE_RATE, &mut weighted_utxos, &mut get_rng()) + .expect("unexpected error"); + + assert!(result.is_empty()); + } + + #[test] + fn select_coins_srd_with_high_fee() { + let target: Amount = Amount::from_str("1.905 cBTC").unwrap(); + // The high fee_rate will cause both utxos to be consumed + // instead of just one. + let fee_rate: FeeRate = FeeRate::from_sat_per_kwu(250); + let mut weighted_utxos: Vec = create_weighted_utxos(); + + let result = select_coins_srd(target, fee_rate, &mut weighted_utxos, &mut get_rng()) + .expect("unexpected error"); + + assert_eq!(weighted_utxos.clone(), result); + } + + #[test] + fn select_coins_srd_addition_overflow() { + let target: Amount = Amount::from_str("2 cBTC").unwrap(); + + let mut weighted_utxos: Vec = vec![WeightedUtxo { + satisfaction_weight: Weight::MAX, + utxo: TxOut { + value: Amount::from_str("1 cBTC").unwrap(), + script_pubkey: ScriptBuf::new(), + }, + }]; + + let result: Error = select_coins_srd(target, FEE_RATE, &mut weighted_utxos, &mut get_rng()) + .expect_err("expected error"); + + assert_eq!(result.to_string(), "18446744073709551615 + 40 exceeds u64 Max"); + } +}