From fe55a4cb39b45b85d2a6e45698bf3d5647a01ffc Mon Sep 17 00:00:00 2001 From: yancy Date: Wed, 28 Aug 2024 20:39:50 -0500 Subject: [PATCH] Add arb_tests --- Cargo.toml | 15 ++- README.md | 4 + fuzz/Cargo.toml | 16 +-- run_proptests.sh | 10 ++ src/branch_and_bound.rs | 203 +++++++++++++++++++++++++++++++++++--- src/lib.rs | 173 +++++++++++++++++++++++++++++++- src/single_random_draw.rs | 20 +++- 7 files changed, 415 insertions(+), 26 deletions(-) create mode 100755 run_proptests.sh diff --git a/Cargo.toml b/Cargo.toml index 60114ff..6ddec11 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -14,14 +14,27 @@ keywords = ["bitcoin", "coin-selection", "coin", "coinselection", "utxo"] readme = "README.md" [dependencies] -bitcoin = "0.32.3" +bitcoin = { git = "https://github.com/yancyribbens/rust-bitcoin.git", rev = "a0c58a4a8b4244d7c541906c61d1343dd6acdccd" } rand = {version = "0.8.5", default-features = false, optional = true} [dev-dependencies] +bitcoin = { git = "https://github.com/yancyribbens/rust-bitcoin.git", rev = "a0c58a4a8b4244d7c541906c61d1343dd6acdccd", features = ["arbitrary"] } criterion = "0.3" bitcoin-coin-selection = {path = ".", features = ["rand"]} rand = "0.8.5" +arbitrary = { version = "1", features = ["derive"] } +arbtest = "0.3.1" +exhaustigen = "0.1.0" [[bench]] name = "coin_selection" harness = false + +[patch.crates-io] +bitcoin_hashes = { git = "https://github.com/rust-bitcoin/rust-bitcoin.git", rev = "a0c58a4a8b4244d7c541906c61d1343dd6acdccd" } +base58ck = { git = "https://github.com/rust-bitcoin/rust-bitcoin.git", rev = "a0c58a4a8b4244d7c541906c61d1343dd6acdccd" } +bitcoin-internals = { git = "https://github.com/rust-bitcoin/rust-bitcoin.git", rev = "a0c58a4a8b4244d7c541906c61d1343dd6acdccd" } +bitcoin-io = { git = "https://github.com/rust-bitcoin/rust-bitcoin.git", rev = "a0c58a4a8b4244d7c541906c61d1343dd6acdccd" } +bitcoin-primitives = { git = "https://github.com/rust-bitcoin/rust-bitcoin.git", rev = "a0c58a4a8b4244d7c541906c61d1343dd6acdccd" } +bitcoin-addresses = { git = "https://github.com/rust-bitcoin/rust-bitcoin.git", rev = "a0c58a4a8b4244d7c541906c61d1343dd6acdccd" } +bitcoin-units = { git = "https://github.com/rust-bitcoin/rust-bitcoin.git", rev = "a0c58a4a8b4244d7c541906c61d1343dd6acdccd" } diff --git a/README.md b/README.md index ee28526..17900d7 100644 --- a/README.md +++ b/README.md @@ -21,6 +21,10 @@ To run the benchmarks use: `cargo bench`. Note: criterion requires rustc version 1.65 to run the benchmarks. +## Proptest + +To continuously run the proptests: `run_proptests.sh` + ## Fuzz Fuzz with `cargo fuzz run select_coins_srd`, `cargo fuzz run select_coins_bnb` or `cargo fuzz run select_coins`. diff --git a/fuzz/Cargo.toml b/fuzz/Cargo.toml index 5f4b619..f2f78d4 100644 --- a/fuzz/Cargo.toml +++ b/fuzz/Cargo.toml @@ -10,7 +10,7 @@ cargo-fuzz = true [dependencies] libfuzzer-sys = "0.4" rand = "0.8.5" -bitcoin = { git = "https://github.com/rust-bitcoin/rust-bitcoin.git", rev = "cfb53c78667dafe8aea488f104f65a2a29a2f94d", features = ["arbitrary"] } +bitcoin = { git = "https://github.com/rust-bitcoin/rust-bitcoin.git", rev = "a0c58a4a8b4244d7c541906c61d1343dd6acdccd", features = ["arbitrary"] } arbitrary = { version = "1", features = ["derive"] } [dependencies.bitcoin-coin-selection] @@ -39,10 +39,10 @@ doc = false bench = false [patch.crates-io] -bitcoin_hashes = { git = "https://github.com/rust-bitcoin/rust-bitcoin.git", rev = "cfb53c78667dafe8aea488f104f65a2a29a2f94d" } -base58ck = { git = "https://github.com/rust-bitcoin/rust-bitcoin.git", rev = "cfb53c78667dafe8aea488f104f65a2a29a2f94d" } -bitcoin-internals = { git = "https://github.com/rust-bitcoin/rust-bitcoin.git", rev = "cfb53c78667dafe8aea488f104f65a2a29a2f94d" } -bitcoin-io = { git = "https://github.com/rust-bitcoin/rust-bitcoin.git", rev = "cfb53c78667dafe8aea488f104f65a2a29a2f94d" } -bitcoin-primitives = { git = "https://github.com/rust-bitcoin/rust-bitcoin.git", rev = "cfb53c78667dafe8aea488f104f65a2a29a2f94d" } -bitcoin-addresses = { git = "https://github.com/rust-bitcoin/rust-bitcoin.git", rev = "cfb53c78667dafe8aea488f104f65a2a29a2f94d" } -bitcoin-units = { git = "https://github.com/rust-bitcoin/rust-bitcoin.git", rev = "cfb53c78667dafe8aea488f104f65a2a29a2f94d" } +bitcoin_hashes = { git = "https://github.com/rust-bitcoin/rust-bitcoin.git", rev = "a0c58a4a8b4244d7c541906c61d1343dd6acdccd" } +base58ck = { git = "https://github.com/rust-bitcoin/rust-bitcoin.git", rev = "a0c58a4a8b4244d7c541906c61d1343dd6acdccd" } +bitcoin-internals = { git = "https://github.com/rust-bitcoin/rust-bitcoin.git", rev = "a0c58a4a8b4244d7c541906c61d1343dd6acdccd" } +bitcoin-io = { git = "https://github.com/rust-bitcoin/rust-bitcoin.git", rev = "a0c58a4a8b4244d7c541906c61d1343dd6acdccd" } +bitcoin-primitives = { git = "https://github.com/rust-bitcoin/rust-bitcoin.git", rev = "a0c58a4a8b4244d7c541906c61d1343dd6acdccd" } +bitcoin-addresses = { git = "https://github.com/rust-bitcoin/rust-bitcoin.git", rev = "a0c58a4a8b4244d7c541906c61d1343dd6acdccd" } +bitcoin-units = { git = "https://github.com/rust-bitcoin/rust-bitcoin.git", rev = "a0c58a4a8b4244d7c541906c61d1343dd6acdccd" } diff --git a/run_proptests.sh b/run_proptests.sh new file mode 100755 index 0000000..21e6232 --- /dev/null +++ b/run_proptests.sh @@ -0,0 +1,10 @@ +#!/bin/bash +while : +do + if cargo test proptest ; then + echo "success" + else + echo "fail" + break + fi +done diff --git a/src/branch_and_bound.rs b/src/branch_and_bound.rs index b445f2b..d232004 100644 --- a/src/branch_and_bound.rs +++ b/src/branch_and_bound.rs @@ -320,12 +320,17 @@ mod tests { use core::str::FromStr; use std::iter::{once, zip}; + use arbitrary::{Arbitrary, Unstructured}; + use arbtest::arbtest; + use bitcoin::transaction::effective_value; use bitcoin::{Amount, Weight}; use super::*; - use crate::tests::{build_utxo, Utxo}; + use crate::tests::{assert_proptest_bnb, build_utxo, Utxo, UtxoPool}; use crate::WeightedUtxo; + const TX_IN_BASE_WEIGHT: u64 = 160; + #[derive(Debug)] pub struct ParamsStr<'a> { target: &'a str, @@ -375,24 +380,13 @@ mod tests { assert_eq!(input_str_list, expected_str_list); } - // This is a temporary patch and can be removed when a new relesae of rust-bitcoin is - // published. See: https://github.com/rust-bitcoin/rust-bitcoin/pull/3346 - fn amount_from_str_patch(amount: &str) -> Amount { - let a = Amount::from_str(amount); - - match a { - Ok(a) => a, - Err(_) => Amount::ZERO, - } - } - fn assert_coin_select_params(p: &ParamsStr, expected_inputs: Option<&[&str]>) { let fee_rate = p.fee_rate.parse::().unwrap(); // would be nice if FeeRate had // from_str like Amount::from_str() let lt_fee_rate = p.lt_fee_rate.parse::().unwrap(); - let target = amount_from_str_patch(p.target); - let cost_of_change = amount_from_str_patch(p.cost_of_change); + let target = Amount::from_str(p.target).unwrap(); + let cost_of_change = Amount::from_str(p.cost_of_change).unwrap(); let fee_rate = FeeRate::from_sat_per_kwu(fee_rate); let lt_fee_rate = FeeRate::from_sat_per_kwu(lt_fee_rate); @@ -419,6 +413,34 @@ mod tests { } } + // Use in place of arbitrary_in_range() + // see: https://github.com/rust-fuzz/arbitrary/pull/192 + fn arb_amount_in_range(u: &mut Unstructured, r: std::ops::RangeInclusive) -> Amount { + let u = u.int_in_range::(r).unwrap(); + Amount::from_sat(u) + } + + // Use in place of arbitrary_in_range() + // see: https://github.com/rust-fuzz/arbitrary/pull/192 + fn arb_fee_rate_in_range(u: &mut Unstructured, r: std::ops::RangeInclusive) -> FeeRate { + let u = u.int_in_range::(r).unwrap(); + FeeRate::from_sat_per_kwu(u) + } + + fn calculate_max_fee_rate(amount: Amount, weight: Weight) -> Option { + let weight = weight + Weight::from_wu(TX_IN_BASE_WEIGHT); + + let mut result = None; + if let Some(fee_rate) = amount.checked_div_by_weight(weight) { + let fee_rate_round_down = fee_rate - FeeRate::from_sat_per_kwu(1); + if fee_rate_round_down > FeeRate::ZERO { + result = Some(fee_rate_round_down) + } + }; + + result + } + #[test] fn select_coins_bnb_one() { assert_coin_select("1 cBTC", &["1 cBTC"]); } @@ -701,4 +723,157 @@ mod tests { assert_eq!(list.len(), 1); assert_eq!(list.next().unwrap().value(), Amount::from_sat(target)); } + + #[test] + fn select_one_of_one_idealized_proptest() { + let minimal_non_dust: u64 = 1; + let effective_value_max: u64 = SignedAmount::MAX.to_sat() as u64; + + arbtest(|u| { + let amount = arb_amount_in_range(u, minimal_non_dust..=effective_value_max); + let utxo = build_utxo(amount, Weight::ZERO); + let pool: Vec = vec![utxo.clone()]; + + let coins: Vec = + select_coins_bnb(utxo.value(), Amount::ZERO, FeeRate::ZERO, FeeRate::ZERO, &pool) + .unwrap() + .cloned() + .collect(); + + assert_eq!(coins, pool); + + Ok(()) + }); + } + + #[test] + fn select_one_of_many_proptest() { + arbtest(|u| { + let pool = UtxoPool::arbitrary(u)?; + let utxos = pool.utxos.clone(); + + let utxo = u.choose(&utxos)?; + + let max_fee_rate = calculate_max_fee_rate(utxo.value(), utxo.satisfaction_weight()); + if let Some(f) = max_fee_rate { + let fee_rate = arb_fee_rate_in_range(u, 1..=f.to_sat_per_kwu()); + + let target_effective_value = + effective_value(fee_rate, utxo.satisfaction_weight(), utxo.value()).unwrap(); + + if let Ok(target) = target_effective_value.to_unsigned() { + let result = select_coins_bnb(target, Amount::ZERO, fee_rate, fee_rate, &utxos); + + if let Some(r) = result { + let sum: SignedAmount = r + .map(|u| { + effective_value(fee_rate, u.satisfaction_weight(), u.value()) + .unwrap() + }) + .sum(); + let amount_sum = sum.to_unsigned().unwrap(); + assert_eq!(amount_sum, target); + } else { + // if result was none, then assert that fail happened because overflow when + // ssumming pool. In the future, assert specific error when added. + let available_value = utxos.into_iter().map(|u| u.value()).checked_sum(); + assert!(available_value.is_none()); + } + } + } + + Ok(()) + }); + } + + #[test] + fn select_many_of_many_proptest() { + arbtest(|u| { + let pool = UtxoPool::arbitrary(u)?; + let utxos = pool.utxos.clone(); + + // generate all the possible utxos subsets + let mut gen = exhaustigen::Gen::new(); + let mut subsets: Vec> = Vec::new(); + while !gen.done() { + let s = gen.gen_subset(&pool.utxos).collect::>(); + subsets.push(s); + } + + // choose a set at random to be the target + let target_selection: &Vec<&Utxo> = u.choose(&subsets).unwrap(); + + // find the minmum fee_rate that will result in all utxos having a posiive + // effective_value + let mut fee_rates: Vec = target_selection + .iter() + .map(|u| { + calculate_max_fee_rate(u.value(), u.satisfaction_weight()) + .unwrap_or(FeeRate::ZERO) + }) + .collect(); + fee_rates.sort(); + + let min_fee_rate = fee_rates.first().unwrap_or(&FeeRate::ZERO).to_sat_per_kwu(); + let fee_rate = arb_fee_rate_in_range(u, 0..=min_fee_rate); + + let effective_values: Vec = target_selection + .iter() + .map(|u| { + let e = effective_value(fee_rate, u.satisfaction_weight(), u.value()); + + e.unwrap_or(SignedAmount::ZERO) + }) + .collect(); + + let eff_values_sum = effective_values.into_iter().checked_sum(); + + // if None, then this random subset is an invalid target (skip) + if let Some(s) = eff_values_sum { + if let Ok(target) = s.to_unsigned() { + let result = select_coins_bnb(target, Amount::ZERO, fee_rate, fee_rate, &utxos); + + if let Some(r) = result { + let effective_value_sum: Amount = r + .map(|u| { + effective_value(fee_rate, u.satisfaction_weight(), u.value()) + .unwrap() + .to_unsigned() + .unwrap() + }) + .sum(); + assert_eq!(effective_value_sum, target); + } else { + let available_value = utxos.into_iter().map(|u| u.value()).checked_sum(); + assert!( + available_value.is_none() + || target_selection.is_empty() + || target == Amount::ZERO + ); + } + } + } + + Ok(()) + }); + } + + #[test] + fn select_bnb_proptest() { + arbtest(|u| { + let pool = UtxoPool::arbitrary(u)?; + let target = Amount::arbitrary(u)?; + let cost_of_change = Amount::arbitrary(u)?; + let fee_rate = FeeRate::arbitrary(u)?; + let lt_fee_rate = FeeRate::arbitrary(u)?; + + let utxos = pool.utxos.clone(); + + let result = select_coins_bnb(target, cost_of_change, fee_rate, lt_fee_rate, &utxos); + + assert_proptest_bnb(target, cost_of_change, fee_rate, pool, result); + + Ok(()) + }); + } } diff --git a/src/lib.rs b/src/lib.rs index 198e22d..cd66fab 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -106,7 +106,11 @@ pub fn select_coins( #[cfg(test)] mod tests { - use bitcoin::{ScriptBuf, TxOut}; + use arbitrary::Arbitrary; + use arbtest::arbtest; + use bitcoin::amount::CheckedSum; + use bitcoin::transaction::effective_value; + use bitcoin::{Amount, ScriptBuf, TxOut, Weight}; use super::*; @@ -125,7 +129,7 @@ mod tests { utxos } - #[derive(Debug)] + #[derive(Debug, Clone, PartialEq, Ord, Eq, PartialOrd, Arbitrary)] pub struct Utxo { pub output: TxOut, pub satisfaction_weight: Weight, @@ -136,6 +140,11 @@ mod tests { Utxo { output, satisfaction_weight } } + #[derive(Debug, Arbitrary)] + pub struct UtxoPool { + pub utxos: Vec, + } + impl WeightedUtxo for Utxo { fn satisfaction_weight(&self) -> Weight { self.satisfaction_weight } fn value(&self) -> Amount { self.output.value } @@ -192,4 +201,164 @@ mod tests { assert!(result > target); assert!(result <= target + cost_of_change); } + + pub fn build_possible_solutions_srd<'a>( + pool: &'a UtxoPool, + fee_rate: FeeRate, + target: Amount, + solutions: &mut Vec>, + ) { + let mut gen = exhaustigen::Gen::new(); + while !gen.done() { + let subset = gen.gen_subset(&pool.utxos).collect::>(); + let effective_values_sum = subset + .iter() + .filter_map(|u| effective_value(fee_rate, u.satisfaction_weight(), u.value())) + .checked_sum(); + + if let Some(s) = effective_values_sum { + if let Ok(p) = s.to_unsigned() { + if p >= target { + solutions.push(subset) + } + } + } + } + } + + pub fn build_possible_solutions_bnb<'a>( + pool: &'a UtxoPool, + fee_rate: FeeRate, + target: Amount, + cost_of_change: Amount, + solutions: &mut Vec>, + ) { + let mut gen = exhaustigen::Gen::new(); + while !gen.done() { + let subset = gen.gen_subset(&pool.utxos).collect::>(); + let effective_values_sum = subset + .iter() + .filter_map(|u| effective_value(fee_rate, u.satisfaction_weight(), u.value())) + .checked_sum(); + + if let Some(eff_sum) = effective_values_sum { + if let Ok(unsigned_sum) = eff_sum.to_unsigned() { + if unsigned_sum >= target { + if let Some(upper_bound) = target.checked_add(cost_of_change) { + if unsigned_sum <= upper_bound { + solutions.push(subset) + } + } + } + } + } + } + } + + pub fn assert_proptest_bnb<'a, T: Iterator>( + target: Amount, + cost_of_change: Amount, + fee_rate: FeeRate, + pool: UtxoPool, + result: Option, + ) { + let mut bnb_solutions: Vec> = Vec::new(); + build_possible_solutions_bnb(&pool, fee_rate, target, cost_of_change, &mut bnb_solutions); + + if let Some(r) = result { + let utxo_sum: Amount = r + .map(|u| { + effective_value(fee_rate, u.satisfaction_weight(), u.value()) + .unwrap() + .to_unsigned() + .unwrap() + }) + .sum(); + + assert!(utxo_sum >= target); + assert!(utxo_sum <= target + cost_of_change); + } else { + assert!( + target > Amount::MAX_MONEY || target == Amount::ZERO || bnb_solutions.is_empty() + ); + } + } + + pub fn assert_proptest_srd<'a, T: Iterator>( + target: Amount, + fee_rate: FeeRate, + pool: UtxoPool, + result: Option, + ) { + let mut srd_solutions: Vec> = Vec::new(); + build_possible_solutions_srd(&pool, fee_rate, target, &mut srd_solutions); + + if let Some(r) = result { + let utxo_sum: Amount = r + .map(|u| { + effective_value(fee_rate, u.satisfaction_weight(), u.value()) + .unwrap() + .to_unsigned() + .unwrap() + }) + .sum(); + + assert!(utxo_sum >= target); + } else { + assert!( + target > Amount::MAX_MONEY || target == Amount::ZERO || srd_solutions.is_empty() + ); + } + } + + pub fn assert_proptest<'a, T: Iterator>( + target: Amount, + cost_of_change: Amount, + fee_rate: FeeRate, + pool: UtxoPool, + result: Option, + ) { + let mut bnb_solutions: Vec> = Vec::new(); + build_possible_solutions_bnb(&pool, fee_rate, target, cost_of_change, &mut bnb_solutions); + + let mut srd_solutions: Vec> = Vec::new(); + build_possible_solutions_srd(&pool, fee_rate, target, &mut srd_solutions); + + if let Some(r) = result { + let utxo_sum: Amount = r + .map(|u| { + effective_value(fee_rate, u.satisfaction_weight(), u.value()) + .unwrap() + .to_unsigned() + .unwrap() + }) + .sum(); + + assert!(utxo_sum >= target); + } else { + assert!( + target > Amount::MAX_MONEY + || target == Amount::ZERO + || bnb_solutions.is_empty() && srd_solutions.is_empty() + ); + } + } + + #[test] + fn select_coins_proptest() { + arbtest(|u| { + let pool = UtxoPool::arbitrary(u)?; + let target = Amount::arbitrary(u)?; + let cost_of_change = Amount::arbitrary(u)?; + let fee_rate = FeeRate::arbitrary(u)?; + let lt_fee_rate = FeeRate::arbitrary(u)?; + + let utxos = pool.utxos.clone(); + let result = select_coins(target, cost_of_change, fee_rate, lt_fee_rate, &utxos); + + assert_proptest(target, cost_of_change, fee_rate, pool, result); + + Ok(()) + }); + } } diff --git a/src/single_random_draw.rs b/src/single_random_draw.rs index 95b0703..de006c7 100644 --- a/src/single_random_draw.rs +++ b/src/single_random_draw.rs @@ -84,12 +84,14 @@ pub fn select_coins_srd<'a, R: rand::Rng + ?Sized, Utxo: WeightedUtxo>( mod tests { use core::str::FromStr; + use arbitrary::Arbitrary; + use arbtest::arbtest; use bitcoin::{Amount, Weight}; use rand::rngs::mock::StepRng; use super::*; use crate::single_random_draw::select_coins_srd; - use crate::tests::{build_utxo, Utxo}; + use crate::tests::{assert_proptest_srd, build_utxo, Utxo, UtxoPool}; use crate::WeightedUtxo; const FEE_RATE: FeeRate = FeeRate::from_sat_per_kwu(10); @@ -286,4 +288,20 @@ mod tests { assert_coin_select_params(¶ms, Some(&["1 cBTC"])); } + + #[test] + fn select_srd_match_proptest() { + arbtest(|u| { + let pool = UtxoPool::arbitrary(u)?; + let target = Amount::arbitrary(u)?; + let fee_rate = FeeRate::arbitrary(u)?; + + let utxos = pool.utxos.clone(); + let result: Option<_> = select_coins_srd(target, fee_rate, &utxos, &mut get_rng()); + + assert_proptest_srd(target, fee_rate, pool, result); + + Ok(()) + }); + } }