Skip to content

Commit

Permalink
Add arb_tests
Browse files Browse the repository at this point in the history
  • Loading branch information
yancyribbens committed Oct 21, 2024
1 parent 193d9cc commit fe55a4c
Show file tree
Hide file tree
Showing 7 changed files with 415 additions and 26 deletions.
15 changes: 14 additions & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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" }
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
Expand Down
16 changes: 8 additions & 8 deletions fuzz/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down Expand Up @@ -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" }
10 changes: 10 additions & 0 deletions run_proptests.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
#!/bin/bash
while :
do
if cargo test proptest ; then
echo "success"
else
echo "fail"
break
fi
done
203 changes: 189 additions & 14 deletions src/branch_and_bound.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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::<u64>().unwrap(); // would be nice if FeeRate had
// from_str like Amount::from_str()
let lt_fee_rate = p.lt_fee_rate.parse::<u64>().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);

Expand All @@ -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<u64>) -> Amount {
let u = u.int_in_range::<u64>(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<u64>) -> FeeRate {
let u = u.int_in_range::<u64>(r).unwrap();
FeeRate::from_sat_per_kwu(u)
}

fn calculate_max_fee_rate(amount: Amount, weight: Weight) -> Option<FeeRate> {
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"]); }

Expand Down Expand Up @@ -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<Utxo> = vec![utxo.clone()];

let coins: Vec<Utxo> =
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<&Utxo>> = Vec::new();
while !gen.done() {
let s = gen.gen_subset(&pool.utxos).collect::<Vec<_>>();
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<FeeRate> = 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<SignedAmount> = 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(())
});
}
}
Loading

0 comments on commit fe55a4c

Please sign in to comment.