From 6f837a45cbf704e78a802c8ea4a6ec97b5fd6653 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=BF=97=E5=AE=87?= Date: Wed, 12 Aug 2026 06:54:37 +0000 Subject: [PATCH 01/26] fix!: replace Candidate input_count/is_segwit with segwit_count/legacy_count Fixes CoinSelector::input_weight undercounting candidates that group multiple legacy inputs in a segwit transaction (where each legacy input serializes a 1 WU empty witness). Tracking segwit and legacy input counts separately also allows a single Candidate to mix legacy and segwit inputs. --- CHANGELOG.md | 1 + README.md | 23 +++---- benches/coin_selector.rs | 4 +- src/coin_selector.rs | 46 +++++++++---- tests/bnb.rs | 19 +++--- tests/changeless.rs | 4 +- tests/common.rs | 15 +++-- tests/lowest_fee.rs | 29 ++++---- tests/srd.rs | 16 ++--- tests/weight.rs | 140 ++++++++++++++++++++++++++++++++++++--- 10 files changed, 222 insertions(+), 75 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3e5daf0..0696755 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,6 @@ # Unreleased +- **Breaking:** Replace `Candidate`'s `input_count` and `is_segwit` fields with `segwit_count` and `legacy_count`, fixing `CoinSelector::input_weight` undercounting candidates that group multiple inputs: in a segwit transaction every legacy input still serializes an empty witness (1 WU), which was previously paid once per candidate instead of once per legacy input, so a group of N legacy inputs came out N-1 WU short. Splitting the count by script type also means a single candidate may now mix legacy and segwit inputs and still be priced exactly. - **Breaking:** `BnbMetric`'s `score`, `bound`, and `drain` take the `target: Target` as a parameter, and `CoinSelector::run_bnb`/`bnb_solutions` gain a leading `target` argument. Consequently `LowestFee` and `Changeless` no longer store a `target` field. This removes the target that `Changeless` previously had to keep in sync with its inner metric, and aligns the metric API with the rest of `CoinSelector`, where `target` is always passed in. - **Breaking:** `BnbMetric` metrics now decide the change output themselves. The trait gains a `drain(&mut self, cs) -> Drain` method; call it on a branch-and-bound solution (or the `LowestFee` metric directly) to get the change output the metric optimized against, instead of computing a separate `ChangePolicy`. - **Breaking:** `CoinSelector::run_bnb` now returns `(Ordf32, Drain)` instead of just `Ordf32`, handing back the change output the metric decided on for the winning selection. diff --git a/README.md b/README.md index 4335d4b..a03ab9b 100644 --- a/README.md +++ b/README.md @@ -33,23 +33,22 @@ let candidates = vec![ Candidate { // How many inputs does this candidate represents. Needed so we can // figure out the weight of the varint that encodes the number of inputs - input_count: 1, + // and whether segwit transaction fields need to be counted in. + segwit_count: 1, + legacy_count: 0, // the value of the input value: 1_000_000, // the total weight of the input(s) including their witness/scriptSig // you may need to use miniscript to figure out the correct value here. weight: TR_KEYSPEND_TXIN_WEIGHT, - // wether it's a segwit input. Needed so we know whether to include the - // segwit header in total weight calculations. - is_segwit: true }, Candidate { // A candidate can represent multiple inputs in the case where you // always want some inputs to be spent together. - input_count: 2, + segwit_count: 2, + legacy_count: 0, weight: 2*TR_KEYSPEND_TXIN_WEIGHT, value: 3_000_000, - is_segwit: true } ]; @@ -105,22 +104,22 @@ let outputs = vec![TxOut { let candidates = [ Candidate { - input_count: 1, + segwit_count: 1, + legacy_count: 0, value: 400_000, weight: TR_KEYSPEND_TXIN_WEIGHT, - is_segwit: true }, Candidate { - input_count: 1, + segwit_count: 1, + legacy_count: 0, value: 200_000, weight: TR_KEYSPEND_TXIN_WEIGHT, - is_segwit: true }, Candidate { - input_count: 1, + segwit_count: 1, + legacy_count: 0, value: 11_000, weight: TR_KEYSPEND_TXIN_WEIGHT, - is_segwit: true } ]; let drain_weights = bdk_coin_select::DrainWeights::default(); diff --git a/benches/coin_selector.rs b/benches/coin_selector.rs index c05eabb..89d8d93 100644 --- a/benches/coin_selector.rs +++ b/benches/coin_selector.rs @@ -33,8 +33,8 @@ fn make_candidates(n: usize) -> Vec { Candidate { value, weight: TXIN_BASE_WEIGHT + P2WPKH_SAT_W, - input_count: 1, - is_segwit: true, + segwit_count: 1, + legacy_count: 0, } }) .collect() diff --git a/src/coin_selector.rs b/src/coin_selector.rs index 604abd8..977a8e8 100644 --- a/src/coin_selector.rs +++ b/src/coin_selector.rs @@ -142,20 +142,23 @@ impl<'a> CoinSelector<'a> { /// The weight of the inputs including the witness header and the varint for the number of /// inputs. pub fn input_weight(&self) -> u64 { - let is_segwit_tx = self.selected().any(|(_, wv)| wv.is_segwit); + let is_segwit_tx = self.selected().any(|(_, wv)| wv.segwit_count > 0); let witness_header_extra_weight = is_segwit_tx as u64 * 2; - let input_count = self.selected().map(|(_, wv)| wv.input_count).sum::(); + let input_count = self + .selected() + .map(|(_, wv)| wv.segwit_count + wv.legacy_count) + .sum::(); let input_varint_weight = varint_size(input_count) * 4; let selected_weight: u64 = self .selected() .map(|(_, candidate)| { let mut weight = candidate.weight; - if is_segwit_tx && !candidate.is_segwit { - // non-segwit candidates do not have the witness length field included in their - // weight field so we need to add 1 here if it's in a segwit tx. - weight += 1; + if is_segwit_tx { + // Legacy inputs do not have the witness length included in their weight field + // so we need to add 1 to each if it's a segwit tx. + weight += candidate.legacy_count as u64; } weight }) @@ -889,7 +892,11 @@ impl std::error::Error for NoBnbSolution {} /// A `Candidate` represents an input candidate for [`CoinSelector`]. /// -/// This can either be a single UTXO, or a group of UTXOs that should be spent together. +/// This can either be a single UTXO, or a group of UTXOs that should be spent together. A group +/// may mix legacy and segwit inputs; set [`legacy_count`] and [`segwit_count`] accordingly. +/// +/// [`legacy_count`]: Candidate::legacy_count +/// [`segwit_count`]: Candidate::segwit_count #[derive(Debug, Clone, Copy)] pub struct Candidate { /// Total value of the UTXO(s) that this [`Candidate`] represents. @@ -897,11 +904,24 @@ pub struct Candidate { /// Total weight of including this/these UTXO(s). /// `txin` fields: `prevout`, `nSequence`, `scriptSigLen`, `scriptSig`, `scriptWitnessLen`, /// `scriptWitness` should all be included. + /// + /// For legacy inputs, do *not* include the `scriptWitnessLen` byte: a legacy input only + /// serializes an (empty) witness when the transaction has a witness section, and + /// [`CoinSelector::input_weight`] adds that 1 WU per legacy input once any segwit input is + /// selected. pub weight: u64, - /// Total number of inputs; so we can calculate extra `varint` weight due to `vin` len changes. - pub input_count: usize, - /// Whether this [`Candidate`] contains at least one segwit spend. - pub is_segwit: bool, + /// Total number of segwit inputs. + /// + /// If any selected candidate has a non-zero `segwit_count`, the transaction serializes a + /// witness section (marker + flag, 2 WU) and every input — including legacy ones — pays for + /// a witness. + pub segwit_count: usize, + /// Total number of legacy (non-segwit) inputs. + /// + /// Each legacy input serializes an empty witness (1 WU) when the transaction has a witness + /// section; [`CoinSelector::input_weight`] prices this per legacy input, so grouped legacy + /// inputs are counted exactly. + pub legacy_count: usize, } impl Candidate { @@ -920,8 +940,8 @@ impl Candidate { Candidate { value, weight, - input_count: 1, - is_segwit, + segwit_count: is_segwit as usize, + legacy_count: !is_segwit as usize, } } diff --git a/tests/bnb.rs b/tests/bnb.rs index 45a22dc..3fea68a 100644 --- a/tests/bnb.rs +++ b/tests/bnb.rs @@ -11,16 +11,16 @@ use proptest::{prelude::*, proptest, test_runner::*}; fn test_wv(mut rng: impl RngCore) -> impl Iterator { core::iter::repeat_with(move || { let value = rng.random_range(0..1_000); - let mut candidate = Candidate { + let candidate = Candidate { value, weight: 100, - input_count: rng.random_range(1..2), - is_segwit: rng.random_bool(0.5), + segwit_count: rng.random_range(1..2), + legacy_count: 0, }; - // HACK: set is_segwit = true for all these tests because you can't actually lower bound - // things easily with how segwit inputs interfere with their weights. We can't modify the - // above since that would change what we pull from rng. - candidate.is_segwit = true; + // Keep drawing the bool these tests always drew so the rng stream (and therefore the + // generated cases) is unchanged. All candidates are segwit: mixing in legacy inputs makes + // their weights context-dependent, which these tests can't lower-bound easily. + let _ = rng.random_bool(0.5); candidate }) } @@ -62,10 +62,7 @@ fn bnb_finds_an_exact_solution_in_n_iter() { let num_additional_canidates = 12; let mut rng = TestRng::deterministic_rng(RngAlgorithm::ChaCha); - let mut wv = test_wv(&mut rng).map(|mut candidate| { - candidate.is_segwit = true; - candidate - }); + let mut wv = test_wv(&mut rng); let solution: Vec = (0..solution_len).map(|_| wv.next().unwrap()).collect(); let solution_weight = { diff --git a/tests/changeless.rs b/tests/changeless.rs index aac10a3..2d43cb7 100644 --- a/tests/changeless.rs +++ b/tests/changeless.rs @@ -14,8 +14,8 @@ fn test_wv(mut rng: impl RngCore) -> impl Iterator { Candidate { value, weight: rng.random_range(0..100), - input_count: rng.random_range(1..2), - is_segwit: false, + segwit_count: rng.random_range(1..2), + legacy_count: 0, } }) } diff --git a/tests/common.rs b/tests/common.rs index 273b830..e508b04 100644 --- a/tests/common.rs +++ b/tests/common.rs @@ -269,14 +269,21 @@ pub fn gen_candidates(n: usize) -> Vec { core::iter::repeat_with(move || { let value = rng.random_range(1..500_001); let weight = rng.random_range(1..2001); - let input_count = rng.random_range(1..3); - let is_segwit = rng.random_bool(0.01); + + let (mut legacy_count, mut segwit_count); + loop { + legacy_count = rng.random_range(0..3); + segwit_count = if rng.random_bool(0.01) { 1 } else { 0 }; + if legacy_count > 0 || segwit_count > 0 { + break; + } + } Candidate { value, weight, - input_count, - is_segwit, + segwit_count, + legacy_count, } }) .take(n) diff --git a/tests/lowest_fee.rs b/tests/lowest_fee.rs index d6b8cba..f428d76 100644 --- a/tests/lowest_fee.rs +++ b/tests/lowest_fee.rs @@ -1,5 +1,4 @@ #![allow(unused_imports)] - mod common; use bdk_coin_select::metrics::{Changeless, LowestFee}; use bdk_coin_select::{ @@ -85,8 +84,8 @@ proptest! { Candidate { value: 20_000, weight: (32 + 4 + 4 + 1) * 4 + 64 + 32, - input_count: 1, - is_segwit: true, + segwit_count: 1, + legacy_count: 0, }; params.n_candidates ]; @@ -238,21 +237,21 @@ fn does_not_create_change_below_spend_cost() { Candidate { value: 100_000, weight: 100, - input_count: 1, - is_segwit: true, + segwit_count: 1, + legacy_count: 0, }, Candidate { value: 50_000, weight: 100, - input_count: 1, - is_segwit: true, + segwit_count: 1, + legacy_count: 0, }, // NOTE: this input has negative effective value Candidate { value: 10, weight: 100, - input_count: 1, - is_segwit: true, + segwit_count: 1, + legacy_count: 0, }, ]; @@ -321,14 +320,14 @@ fn zero_fee_tx() { Candidate { value: 100_000, weight: 100, - input_count: 1, - is_segwit: true, + segwit_count: 1, + legacy_count: 0, }, Candidate { value: 50_000, weight: 100, - input_count: 1, - is_segwit: true, + segwit_count: 1, + legacy_count: 0, }, ]; @@ -354,8 +353,8 @@ fn err_candidate(value: u64) -> Candidate { Candidate { value, weight: 272, // ~1 P2WPKH input - input_count: 1, - is_segwit: true, + segwit_count: 1, + legacy_count: 0, } } diff --git a/tests/srd.rs b/tests/srd.rs index b1b3096..e8348f1 100644 --- a/tests/srd.rs +++ b/tests/srd.rs @@ -75,20 +75,20 @@ fn srd_insufficient_funds() { Candidate { value: 50_000, weight: 100, - input_count: 1, - is_segwit: true, + segwit_count: 1, + legacy_count: 0, }, Candidate { value: 50_000, weight: 100, - input_count: 1, - is_segwit: true, + segwit_count: 1, + legacy_count: 0, }, Candidate { value: 50_000, weight: 100, - input_count: 1, - is_segwit: true, + segwit_count: 1, + legacy_count: 0, }, ]; let target = target(200_000, 5.0); @@ -117,8 +117,8 @@ fn srd_max_weight_exceeded() { Candidate { value: 100_000, weight: 1000, - input_count: 1, - is_segwit: true, + segwit_count: 1, + legacy_count: 0, }; 10 ]; diff --git a/tests/weight.rs b/tests/weight.rs index 6a8dbb5..5ad619c 100644 --- a/tests/weight.rs +++ b/tests/weight.rs @@ -21,6 +21,26 @@ pub fn hex_decode(hex: &str) -> Vec { bytes } +// https://mempool.space/tx/5f231df4f73694b3cca9211e336451c20dab136e0a843c2e3166cdcb093e91f4 +const THREE_INPUT_LEGACY_TX_HEX: &str = "0100000003fe785783e14669f638ba902c26e8e3d7036fb183237bc00f8a10542191c7171300000000fdfd00004730440220418996f20477d143d02ad47e74e5949641b6c2904159ab7c592d2cfc659f9bd802205b18f18ac86b714971f84a8b74a4cb14ad5c1a5b9d0d939bb32c6ae4032f4ea10148304502210091296ff8dd87b5ebfc3d47cb82cfe4750d52c544a2b88a85970354a4d0d4b1db022069632067ee6f30f06145f649bc76d5e5d5e6404dbe985e006fcde938f778c297014c695221030502b8ade694d57a6e86998180a64f4ce993372830dc796c3d561ad8b2a504de210272b68e1c037c4630eff7ea5858640cc0748e36f5de82fb38529ef1fd0a89670d2103ba0544a3a2aa9f2314022760b78b5c833aebf6f88468a089550f93834a2886ed53aeffffffff7e048a7c53a8af656e24442c65fe4c4299b1494f6c7579fe0fd9fa741ce83e3279000000fc004730440220018fa343acccd048ed8f8f179e1b6ae27435a41b5fb2c1d96a5a772777acc6dc022074783814f2100c6fc4d4c976f941212be50825814502ca0cbe3f929db789979e0147304402206373f01b73fb09876d0f5ee3087e0614cab3be249934bc2b7eb64ee67f53dc8302200b50f8a327020172b82aaba7480c77ecf07bb32322a05f4afbc543aa97d2fde8014c69522103039d906b2494e310f6c7774c98618be552720d04781e073dd3ff25d5906f22662103d82026baa529619b103ec6341d548a7eb6d924061a8469a7416155513a3071c12102e452bc4aa726d44646ba80db70465683b30efde282a19aa35c6029ae8925df5e53aeffffffffef80f0b1cc543de4f73d59c02a3c575ae5d0af17c1e11e6be7abe3325c777507ad000000fdfd00004730440220220fee11bf836621a11a8ea9100a4600c109c13895f11468d3e2062210c5481902201c5c8a462175538e87b8248e1ed3927c3a461c66d1b46215641c875e86eb22c4014830450221008d2de8c2f20a720129c372791e595b9602b1a9bce99618497aec5266148ffc1302203a493359d700ed96323f8805ed03e909959ff0f22eff359028db6861486b1555014c6952210374a4add33567f09967592c5bcdc3db421fdbba67bac4636328f96d941da31bd221039636c2ffac90afb7499b16e265078113dfb2d77b54270e37353217c9eaeaf3052103d0bcea6d10cdd2f16018ea71572631708e26f457f67cda36a7f816a87f7791d253aeffffffff04977261000000000016001470385d054721987f41521648d7b2f5c77f735d6bee92030000000000225120d0cda1b675a0b369964cbfa381721aae3549dd2c9c6f2cf71ff67d5bc277afd3f2aaf30000000000160014ed2d41ba08313dbb2630a7106b2fedafc14aa121d4f0c70000000000220020e5c7c00d174631d2d1e365d6347b016fb87b6a0c08902d8e443989cb771fa7ec00000000"; + +/// The 3-legacy-input mainnet tx above. +fn legacy_three_input_tx() -> Transaction { + Transaction::consensus_decode(&mut hex_decode(THREE_INPUT_LEGACY_TX_HEX).as_slice()).unwrap() +} + +/// The same tx with the middle input turned into a (semi-realistic) P2WPKH segwit spend. +fn legacy_three_input_tx_mixed() -> Transaction { + let mut tx = legacy_three_input_tx(); + tx.input[1].script_sig = ScriptBuf::default(); + tx.input[1].witness = vec![ + // semi-realistic p2wpkh spend + hex_decode("3045022100bdc115b86e9c863279132b4808459cf9b266c8f6a9c14a3dfd956986b807e3320220265833b85197679687c5d5eed1b2637489b34249d44cf5d2d40bc7b514181a5101"), + hex_decode("02077741a668889ce15d59365886375aea47a7691941d7a0d301697edbc773b45b"), + ].into(); + tx +} + #[test] fn segwit_one_input_one_output() { // FROM https://mempool.space/tx/e627fbb7f775a57fd398bf9b150655d4ac3e1f8afed4255e74ee10d7a345a9cc @@ -35,8 +55,8 @@ fn segwit_one_input_one_output() { .map(|(txin, value)| Candidate { value, weight: txin.segwit_weight().to_wu(), - input_count: 1, - is_segwit: true, + segwit_count: 1, + legacy_count: 0, }) .collect::>(); @@ -78,8 +98,8 @@ fn segwit_two_inputs_one_output() { .map(|(txin, value)| Candidate { value, weight: txin.segwit_weight().to_wu(), - input_count: 1, - is_segwit: true, + segwit_count: 1, + legacy_count: 0, }) .collect::>(); @@ -122,8 +142,8 @@ fn legacy_three_inputs() { .map(|(txin, value)| Candidate { value, weight: txin.legacy_weight().to_wu(), - input_count: 1, - is_segwit: false, + segwit_count: 0, + legacy_count: 1, }) .collect::>(); @@ -179,8 +199,8 @@ fn legacy_three_inputs_one_segwit() { txin.legacy_weight() } .to_wu(), - input_count: 1, - is_segwit, + segwit_count: is_segwit as usize, + legacy_count: !is_segwit as usize, } }) .collect::>(); @@ -200,6 +220,110 @@ fn legacy_three_inputs_one_segwit() { ); } +#[test] +fn legacy_three_inputs_grouped() { + // Same tx as `legacy_three_inputs`, but all three legacy inputs carried by a single candidate. + // No witness section is serialized, so nothing is added per legacy input — this guards the + // all-legacy path (it also passed under the old per-candidate accounting). + let tx = legacy_three_input_tx(); + let input_values = [022_680_000, 006_558_175, 006_558_200]; + + let candidates = [Candidate { + value: input_values.iter().sum(), + weight: tx + .input + .iter() + .map(|txin| txin.legacy_weight().to_wu()) + .sum(), + segwit_count: 0, + legacy_count: tx.input.len(), + }]; + + let target_ouputs = TargetOutputs { + value_sum: tx.output.iter().map(|output| output.value.to_sat()).sum(), + weight_sum: tx.output.iter().map(|output| output.weight().to_wu()).sum(), + n_outputs: tx.output.len(), + }; + + let mut coin_selector = CoinSelector::new(&candidates); + coin_selector.select_all(); + + assert_eq!( + coin_selector.weight(target_ouputs, DrainWeights::NONE), + tx.weight().to_wu() + ); +} + +#[test] +fn legacy_pair_grouped_with_segwit_input() { + // Same tx as `legacy_three_inputs_one_segwit`, but the two legacy inputs are grouped into a + // single candidate. In a segwit tx each legacy input still serializes an (empty) witness + // costing 1 WU, so the grouped candidate must pay 2 WU — not 1 — for its two empty witnesses. + let tx = legacy_three_input_tx_mixed(); + let input_values = [022_680_000, 006_558_175, 006_558_200]; + + let candidates = [ + Candidate { + value: input_values[0] + input_values[2], + weight: tx.input[0].legacy_weight().to_wu() + tx.input[2].legacy_weight().to_wu(), + segwit_count: 0, + legacy_count: 2, + }, + Candidate { + value: input_values[1], + weight: tx.input[1].segwit_weight().to_wu(), + segwit_count: 1, + legacy_count: 0, + }, + ]; + + let target_ouputs = TargetOutputs { + value_sum: tx.output.iter().map(|output| output.value.to_sat()).sum(), + weight_sum: tx.output.iter().map(|output| output.weight().to_wu()).sum(), + n_outputs: tx.output.len(), + }; + + let mut coin_selector = CoinSelector::new(&candidates); + coin_selector.select_all(); + + assert_eq!( + coin_selector.weight(target_ouputs, DrainWeights::NONE), + tx.weight().to_wu() + ); +} + +#[test] +fn mixed_group_all_inputs_one_candidate() { + // Same tx as `legacy_three_inputs_one_segwit`, with all three inputs — legacy *and* segwit — + // in a single mixed candidate. `legacy_count`/`segwit_count` price this exactly: 2 WU for the + // two empty legacy witnesses, the segwit header once, and a 3-input varint. + let tx = legacy_three_input_tx_mixed(); + let input_values = [022_680_000, 006_558_175, 006_558_200]; + + let candidates = [Candidate { + value: input_values.iter().sum(), + weight: tx.input[0].legacy_weight().to_wu() + + tx.input[1].segwit_weight().to_wu() + + tx.input[2].legacy_weight().to_wu(), + segwit_count: 1, + legacy_count: 2, + }]; + + let target_ouputs = TargetOutputs { + value_sum: tx.output.iter().map(|output| output.value.to_sat()).sum(), + weight_sum: tx.output.iter().map(|output| output.weight().to_wu()).sum(), + n_outputs: tx.output.len(), + }; + + let mut coin_selector = CoinSelector::new(&candidates); + coin_selector.select_all(); + + assert_eq!( + coin_selector.weight(target_ouputs, DrainWeights::NONE), + tx.weight().to_wu() + ); +} + #[test] fn new_tr_keyspend_correct_weight() { // FROM https://mempool.space/tx/4936a1a4ea1a0085b9dc2a1d5b59d361f5b1b41241772f3e465153712b6d8dc0 From a37cef9f5132d79bc563d50de7f305008c445f35 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=BF=97=E5=AE=87?= Date: Wed, 12 Aug 2026 07:24:24 +0000 Subject: [PATCH 02/26] refactor!: replace Candidate::new with Candidate::new_segwit and new_legacy Replaces the boolean is_segwit parameter in Candidate::new with explicit new_segwit and new_legacy constructors. Clarifies in doc comments that satisfaction_weight is the additional weight required beyond TXIN_BASE_WEIGHT (which already accounts for a 1-byte scriptSigLen). --- CHANGELOG.md | 2 +- src/coin_selector.rs | 36 +++++++++++++++++++++++++++++------- 2 files changed, 30 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0696755..80a38b1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,6 @@ # Unreleased -- **Breaking:** Replace `Candidate`'s `input_count` and `is_segwit` fields with `segwit_count` and `legacy_count`, fixing `CoinSelector::input_weight` undercounting candidates that group multiple inputs: in a segwit transaction every legacy input still serializes an empty witness (1 WU), which was previously paid once per candidate instead of once per legacy input, so a group of N legacy inputs came out N-1 WU short. Splitting the count by script type also means a single candidate may now mix legacy and segwit inputs and still be priced exactly. +- **Breaking:** Replace `Candidate`'s `input_count` and `is_segwit` fields with `segwit_count` and `legacy_count`, fixing `CoinSelector::input_weight` undercounting candidates that group multiple inputs: in a segwit transaction every legacy input still serializes an empty witness (1 WU), which was previously paid once per candidate instead of once per legacy input, so a group of N legacy inputs came out N-1 WU short. Splitting the count by script type also means a single candidate may now mix legacy and segwit inputs and still be priced exactly. Replaces `Candidate::new` with `Candidate::new_segwit` and `Candidate::new_legacy`. - **Breaking:** `BnbMetric`'s `score`, `bound`, and `drain` take the `target: Target` as a parameter, and `CoinSelector::run_bnb`/`bnb_solutions` gain a leading `target` argument. Consequently `LowestFee` and `Changeless` no longer store a `target` field. This removes the target that `Changeless` previously had to keep in sync with its inner metric, and aligns the metric API with the rest of `CoinSelector`, where `target` is always passed in. - **Breaking:** `BnbMetric` metrics now decide the change output themselves. The trait gains a `drain(&mut self, cs) -> Drain` method; call it on a branch-and-bound solution (or the `LowestFee` metric directly) to get the change output the metric optimized against, instead of computing a separate `ChangePolicy`. - **Breaking:** `CoinSelector::run_bnb` now returns `(Ordf32, Drain)` instead of just `Ordf32`, handing back the change output the metric decided on for the winning selection. diff --git a/src/coin_selector.rs b/src/coin_selector.rs index 977a8e8..2b8f542 100644 --- a/src/coin_selector.rs +++ b/src/coin_selector.rs @@ -928,20 +928,42 @@ impl Candidate { /// Create a [`Candidate`] input that spends a single taproot keyspend output. pub fn new_tr_keyspend(value: u64) -> Self { let weight = TR_KEYSPEND_SATISFACTION_WEIGHT; - Self::new(value, weight, true) + Self::new_segwit(value, weight) } - /// Create a new [`Candidate`] that represents a single input. + /// Create a new [`Candidate`] that represents a single segwit input. /// - /// `satisfaction_weight` is the weight of `scriptSigLen + scriptSig + scriptWitnessLen + - /// scriptWitness`. - pub fn new(value: u64, satisfaction_weight: u64, is_segwit: bool) -> Candidate { + /// `satisfaction_weight` is the additional weight (in weight units) required to satisfy the input + /// beyond [`TXIN_BASE_WEIGHT`] (e.g. `scriptWitnessLen + scriptWitness` in WU at 1 WU/byte, plus + /// any `scriptSig` data and extra `scriptSigLen` varint bytes if nested/wrapped segwit). + /// + /// Note that [`TXIN_BASE_WEIGHT`] already accounts for the outpoint, `nSequence`, and 1 byte for + /// `scriptSigLen`. + pub fn new_segwit(value: u64, satisfaction_weight: u64) -> Candidate { + let weight = TXIN_BASE_WEIGHT + satisfaction_weight; + Candidate { + value, + weight, + segwit_count: 1, + legacy_count: 0, + } + } + + /// Create a new [`Candidate`] that represents a single legacy (non-segwit) input. + /// + /// `satisfaction_weight` is the additional weight (in weight units) required to satisfy the input + /// beyond [`TXIN_BASE_WEIGHT`] (e.g. `scriptSig` at 4 WU/byte, plus 4 WU per extra `scriptSigLen` + /// varint byte if `scriptSig` exceeds 252 bytes). + /// + /// Note that [`TXIN_BASE_WEIGHT`] already accounts for the outpoint, `nSequence`, and 1 byte for + /// `scriptSigLen`. + pub fn new_legacy(value: u64, satisfaction_weight: u64) -> Candidate { let weight = TXIN_BASE_WEIGHT + satisfaction_weight; Candidate { value, weight, - segwit_count: is_segwit as usize, - legacy_count: !is_segwit as usize, + segwit_count: 0, + legacy_count: 1, } } From 89ec889fe616bc5da853624a5a4eef6be86ab7f0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=BF=97=E5=AE=87?= Date: Tue, 4 Aug 2026 03:34:24 +0000 Subject: [PATCH 03/26] Give `CoinSelector` its target instead of threading it through every call A selector was built for one target and evaluated against it throughout, but every method took the target as a parameter, so nothing stopped `cs.excess(target_a, drain)` being followed by `cs.is_funded(target_b)`. The correctness arguments in the metrics are all stated at a fixed target -- `LowestFee::bound`'s proof that a changeless superset always costs more, `Changeless::change_unavoidable`'s assumption that the drain decision is monotone in the excess -- and were held together by convention rather than by types. `CoinSelector::new` now takes the target and owns it. Twenty signatures *lose* a parameter rather than gaining one: fifteen public methods (`excess`, `implied_fee`, `is_funded`, `drain`, `select_until_target_met`, the four `*_excess`, ...), plus `bnb_solutions` and `run_bnb`, plus all three `BnbMetric` methods. The crate had already reached this conclusion one layer down: `BnbIter` stored the target as a field, took it once in `BnbIter::new`, and then re-passed it into `metric.score` and `metric.bound` at every node. That field and the re-threading are both gone. This is a breaking change, and it reaches `BnbMetric`, so metrics implemented outside this crate need their signatures updated: fn score(&mut self, cs: &CoinSelector<'_>) -> Option; fn bound(&mut self, cs: &CoinSelector<'_>) -> Option; fn drain(&mut self, cs: &CoinSelector<'_>) -> Drain; `CoinSelector::target()` exposes the target for metrics that need to read it. Co-Authored-By: Claude Opus 5 --- README.md | 20 ++-- benches/coin_selector.rs | 7 +- src/bnb.rs | 17 ++-- src/coin_selector.rs | 208 ++++++++++++++++++-------------------- src/metrics/changeless.rs | 24 ++--- src/metrics/lowest_fee.rs | 103 +++++++++---------- tests/bnb.rs | 73 +++++++------ tests/changeless.rs | 6 +- tests/common.rs | 65 ++++++------ tests/lowest_fee.rs | 56 +++++----- tests/srd.rs | 35 +++---- tests/weight.rs | 54 ++++++++-- 12 files changed, 332 insertions(+), 336 deletions(-) diff --git a/README.md b/README.md index a03ab9b..8e723a1 100644 --- a/README.md +++ b/README.md @@ -53,13 +53,13 @@ let candidates = vec![ ]; // You can now select coins! -let mut coin_selector = CoinSelector::new(&candidates); +let mut coin_selector = CoinSelector::new(&candidates, target); coin_selector.select(0); -assert!(!coin_selector.is_funded(target), "we didn't select enough"); -println!("we didn't select enough yet we're missing: {}", coin_selector.missing(target)); +assert!(!coin_selector.is_funded(), "we didn't select enough"); +println!("we didn't select enough yet we're missing: {}", coin_selector.missing()); coin_selector.select(1); -assert!(coin_selector.is_funded(target), "we should have enough now"); +assert!(coin_selector.is_funded(), "we should have enough now"); // Now we need to know if we need a change output to drain the excess if we overshot too much // @@ -68,7 +68,7 @@ assert!(coin_selector.is_funded(target), "we should have enough now"); let drain_weights = DrainWeights::TR_KEYSPEND; // Our policy is to only add a change output if the value is over 1_000 sats let change_policy = ChangePolicy::min_value(drain_weights, 1_000); -let change = coin_selector.drain(target, change_policy); +let change = coin_selector.drain(change_policy); if change.is_some() { println!("We need to add our change output to the transaction with {} value", change.value); } else { @@ -126,14 +126,14 @@ let drain_weights = bdk_coin_select::DrainWeights::default(); // You could determine this by looking at the user's transaction history and taking an average of the feerate. let long_term_feerate = FeeRate::from_sat_per_vb(10.0); -let mut coin_selector = CoinSelector::new(&candidates); - let target = Target { fee: TargetFee::from_feerate(FeeRate::from_sat_per_vb(15.0)), outputs: TargetOutputs::fund_outputs(outputs.iter().map(|output| (output.weight().to_wu(), output.value.to_sat()))), max_weight: None, }; +let mut coin_selector = CoinSelector::new(&candidates, target); + // The feerate used to work out whether a change output would be dust (and so shouldn't be added). // The standard dust relay feerate is 3 sat/vb. let dust_relay_feerate = FeeRate::from_sat_per_vb(3.0); @@ -149,13 +149,13 @@ let mut metric = LowestFee { // We run the branch and bound algorithm with a max round limit of 100,000. // On success it returns the score along with the change output the metric decided on. -let change = match coin_selector.run_bnb(target, metric, 100_000) { +let change = match coin_selector.run_bnb(metric, 100_000) { Err(err) => { println!("failed to find a solution: {}", err); // fall back to naive selection - coin_selector.select_until_target_met(target).expect("a selection was impossible!"); + coin_selector.select_until_target_met().expect("a selection was impossible!"); // the metric still decides the change output for whatever we end up selecting - metric.drain(&coin_selector, target) + metric.drain(&coin_selector) } Ok((score, change)) => { println!("we found a solution with score {}", score); diff --git a/benches/coin_selector.rs b/benches/coin_selector.rs index 89d8d93..c48420e 100644 --- a/benches/coin_selector.rs +++ b/benches/coin_selector.rs @@ -56,7 +56,8 @@ fn bench_coin_selector_clone(c: &mut Criterion) { let mut group = c.benchmark_group("clone"); for &n in &[64usize, 256, 1024, 4096] { let candidates = make_candidates(n); - let mut selector = CoinSelector::new(&candidates); + let (target, _) = make_bnb_inputs(&candidates); + let mut selector = CoinSelector::new(&candidates, target); // Select ~10% of candidates so `selected` is non-trivial to copy. for i in (0..n).step_by(10) { selector.select(i); @@ -74,8 +75,8 @@ fn bench_run_bnb_lowest_fee(c: &mut Criterion) { group.sample_size(20); for &n in &[20usize, 50, 100, 200] { let candidates = make_candidates(n); - let selector = CoinSelector::new(&candidates); let (target, long_term_feerate) = make_bnb_inputs(&candidates); + let selector = CoinSelector::new(&candidates, target); group.bench_with_input(BenchmarkId::from_parameter(n), &n, |b, _| { b.iter_batched( || selector.clone(), @@ -85,7 +86,7 @@ fn bench_run_bnb_lowest_fee(c: &mut Criterion) { dust_relay_feerate: FeeRate::from_sat_per_vb(1.0), drain_weights: DrainWeights::TR_KEYSPEND, }; - let _ = sel.run_bnb(target, metric, black_box(100_000)); + let _ = sel.run_bnb(metric, black_box(100_000)); sel }, BatchSize::SmallInput, diff --git a/src/bnb.rs b/src/bnb.rs index 0498e5c..d20db4c 100644 --- a/src/bnb.rs +++ b/src/bnb.rs @@ -1,6 +1,6 @@ use core::cmp::Reverse; -use crate::{float::Ordf32, Drain, Target}; +use crate::{float::Ordf32, Drain}; use super::CoinSelector; use alloc::collections::BinaryHeap; @@ -11,8 +11,6 @@ use alloc::collections::BinaryHeap; pub(crate) struct BnbIter<'a, M: BnbMetric> { queue: BinaryHeap>, best: Option, - /// The target the metric scores selections against. - pub(crate) target: Target, /// The `BnBMetric` that will score each selection pub(crate) metric: M, } @@ -55,7 +53,7 @@ impl<'a, M: BnbMetric> Iterator for BnbIter<'a, M> { let mut return_val = None; if !branch.is_exclusion { - if let Some(score) = self.metric.score(&selector, self.target) { + if let Some(score) = self.metric.score(&selector) { let better = match self.best { Some(best_score) => score < best_score, None => true, @@ -73,11 +71,10 @@ impl<'a, M: BnbMetric> Iterator for BnbIter<'a, M> { } impl<'a, M: BnbMetric> BnbIter<'a, M> { - pub(crate) fn new(mut selector: CoinSelector<'a>, target: Target, metric: M) -> Self { + pub(crate) fn new(mut selector: CoinSelector<'a>, metric: M) -> Self { let mut iter = BnbIter { queue: BinaryHeap::default(), best: None, - target, metric, }; @@ -91,7 +88,7 @@ impl<'a, M: BnbMetric> BnbIter<'a, M> { } fn consider_adding_to_queue(&mut self, cs: &CoinSelector<'a>, is_exclusion: bool) { - let bound = self.metric.bound(cs, self.target); + let bound = self.metric.bound(cs); if let Some(bound) = bound { let is_good_enough = match self.best { Some(best) => best > bound, @@ -205,7 +202,7 @@ pub trait BnbMetric { /// Get the score of a given selection for `target`. /// /// If this returns `None`, the selection is invalid. - fn score(&mut self, cs: &CoinSelector<'_>, target: Target) -> Option; + fn score(&mut self, cs: &CoinSelector<'_>) -> Option; /// Get the lower bound score using a heuristic for `target`. /// @@ -214,13 +211,13 @@ pub trait BnbMetric { /// /// If this returns `None`, the current branch and all descendant branches will not have valid /// solutions. - fn bound(&mut self, cs: &CoinSelector<'_>, target: Target) -> Option; + fn bound(&mut self, cs: &CoinSelector<'_>) -> Option; /// The change output (a.k.a. drain) this metric decides on for the given selection and `target`, /// or [`Drain::NONE`] if it decides there should be no change. /// /// Call this on a branch-and-bound solution to get the change output the metric optimized against. - fn drain(&mut self, cs: &CoinSelector<'_>, target: Target) -> Drain; + fn drain(&mut self, cs: &CoinSelector<'_>) -> Drain; /// Returns whether the metric requies we order candidates by descending value per weight unit. fn requires_ordering_by_descending_value_pwu(&self) -> bool { diff --git a/src/coin_selector.rs b/src/coin_selector.rs index 2b8f542..c51d016 100644 --- a/src/coin_selector.rs +++ b/src/coin_selector.rs @@ -18,6 +18,7 @@ pub const CHANGE_LOWER: u64 = 50_000; #[derive(Debug, Clone)] pub struct CoinSelector<'a> { candidates: &'a [Candidate], + target: Target, selected: Bitset, banned: Bitset, candidate_order: Arc>, @@ -35,15 +36,24 @@ impl<'a> CoinSelector<'a> { /// /// Note that methods in `CoinSelector` will refer to inputs by the index in the `candidates` /// slice you pass in. - pub fn new(candidates: &'a [Candidate]) -> Self { + /// + /// `target` is fixed for the life of the selector. Everything it reports is measured against + /// that one target. + pub fn new(candidates: &'a [Candidate], target: Target) -> Self { Self { candidates, + target, selected: Bitset::with_capacity(candidates.len()), banned: Bitset::with_capacity(candidates.len()), candidate_order: Arc::new((0..candidates.len()).collect::>()), } } + /// What this selector is funding. + pub fn target(&self) -> Target { + self.target + } + /// Iterate over all the candidates in their currently sorted order. Each item has the original /// index with the candidate. pub fn candidates( @@ -128,10 +138,10 @@ impl<'a> CoinSelector<'a> { /// [`ban`]: Self::ban /// [`is_funded`]: Self::is_funded /// [`select_until_target_met`]: Self::select_until_target_met - pub fn is_fundable(&self, target: Target) -> bool { + pub fn is_fundable(&self) -> bool { let mut test = self.clone(); - test.select_all_effective(target.fee.rate); - test.is_funded(target) + test.select_all_effective(self.target.fee.rate); + test.is_funded() } /// Returns true if no candidates have been selected. @@ -189,15 +199,15 @@ impl<'a> CoinSelector<'a> { /// /// In order for the resulting transaction to be valid this must be 0 or above. If it's above 0 /// this means the transaction will overpay for what it needs to reach `target`. - pub fn excess(&self, target: Target, drain: Drain) -> i64 { - self.rate_excess(target, drain) - .min(self.absolute_excess(target, drain)) - .min(self.replacement_excess(target, drain)) + pub fn excess(&self, drain: Drain) -> i64 { + self.rate_excess(drain) + .min(self.absolute_excess(drain)) + .min(self.replacement_excess(drain)) } - /// How much extra value needs to be selected to reach the target. - pub fn missing(&self, target: Target) -> u64 { - let excess = self.excess(target, Drain::NONE); + /// How much extra value needs to be selected to reach the self.target. + pub fn missing(&self) -> u64 { + let excess = self.excess(Drain::NONE); if excess < 0 { excess.unsigned_abs() } else { @@ -205,56 +215,56 @@ impl<'a> CoinSelector<'a> { } } - /// How much the current selection overshoots the value need to satisfy `target.fee.rate` and - /// `target.value` (while ignoring `target.fee.absolute`). - pub fn rate_excess(&self, target: Target, drain: Drain) -> i64 { + /// How much the current selection overshoots the value need to satisfy `self.target.fee.rate` and + /// `self.target.value` (while ignoring `self.target.fee.absolute`). + pub fn rate_excess(&self, drain: Drain) -> i64 { self.selected_value() as i64 - - target.value() as i64 + - self.target.value() as i64 - drain.value as i64 - - self.implied_fee_from_feerate(target, drain.weights) as i64 + - self.implied_fee_from_feerate(drain.weights) as i64 } - /// Same as [rate_excess](Self::rate_excess) except `target.fee.rate` is applied to the + /// Same as [rate_excess](Self::rate_excess) except `self.target.fee.rate` is applied to the /// implied transaction's weight units directly without any conversion to vbytes. - pub fn rate_excess_wu(&self, target: Target, drain: Drain) -> i64 { + pub fn rate_excess_wu(&self, drain: Drain) -> i64 { self.selected_value() as i64 - - target.value() as i64 + - self.target.value() as i64 - drain.value as i64 - - self.implied_fee_from_feerate_wu(target, drain.weights) as i64 + - self.implied_fee_from_feerate_wu(drain.weights) as i64 } - /// How much the current selection overshoots the value needed to satisfy `target.fee.absolute` - /// and `target.value` (while ignoring `target.fee.rate`). - pub fn absolute_excess(&self, target: Target, drain: Drain) -> i64 { + /// How much the current selection overshoots the value needed to satisfy `self.target.fee.absolute` + /// and `self.target.value` (while ignoring `self.target.fee.rate`). + pub fn absolute_excess(&self, drain: Drain) -> i64 { self.selected_value() as i64 - - target.value() as i64 + - self.target.value() as i64 - drain.value as i64 - - target.fee.absolute as i64 + - self.target.fee.absolute as i64 } /// How much the current selection overshoots the value needed to satisfy RBF's rule 4. - pub fn replacement_excess(&self, target: Target, drain: Drain) -> i64 { + pub fn replacement_excess(&self, drain: Drain) -> i64 { let mut replacement_excess_needed = 0; - if let Some(replace) = target.fee.replace { + if let Some(replace) = self.target.fee.replace { replacement_excess_needed = - replace.min_fee_to_do_replacement(self.weight(target.outputs, drain.weights)) + replace.min_fee_to_do_replacement(self.weight(self.target.outputs, drain.weights)) } self.selected_value() as i64 - - target.value() as i64 + - self.target.value() as i64 - drain.value as i64 - replacement_excess_needed as i64 } /// Same as [replacement_excess](Self::replacement_excess) except the replacement fee /// is calculated using weight units directly without any conversion to vbytes. - pub fn replacement_excess_wu(&self, target: Target, drain: Drain) -> i64 { + pub fn replacement_excess_wu(&self, drain: Drain) -> i64 { let mut replacement_excess_needed = 0; - if let Some(replace) = target.fee.replace { - replacement_excess_needed = - replace.min_fee_to_do_replacement_wu(self.weight(target.outputs, drain.weights)) + if let Some(replace) = self.target.fee.replace { + replacement_excess_needed = replace + .min_fee_to_do_replacement_wu(self.weight(self.target.outputs, drain.weights)) } self.selected_value() as i64 - - target.value() as i64 + - self.target.value() as i64 - drain.value as i64 - replacement_excess_needed as i64 } @@ -279,33 +289,33 @@ impl<'a> CoinSelector<'a> { /// [`Replace`] constraints and returns the larger of the two. /// /// `drain_weight` can be 0 to indicate no draining output. - pub fn implied_fee(&self, target: Target, drain_weights: DrainWeights) -> u64 { + pub fn implied_fee(&self, drain_weights: DrainWeights) -> u64 { let mut implied_fee = self - .implied_fee_from_feerate(target, drain_weights) - .max(target.fee.absolute); + .implied_fee_from_feerate(drain_weights) + .max(self.target.fee.absolute); - if let Some(replace) = target.fee.replace { + if let Some(replace) = self.target.fee.replace { implied_fee = Ord::max( implied_fee, - replace.min_fee_to_do_replacement(self.weight(target.outputs, drain_weights)), + replace.min_fee_to_do_replacement(self.weight(self.target.outputs, drain_weights)), ); } implied_fee } - fn implied_fee_from_feerate(&self, target: Target, drain_weights: DrainWeights) -> u64 { - target + fn implied_fee_from_feerate(&self, drain_weights: DrainWeights) -> u64 { + self.target .fee .rate - .implied_fee(self.weight(target.outputs, drain_weights)) + .implied_fee(self.weight(self.target.outputs, drain_weights)) } - fn implied_fee_from_feerate_wu(&self, target: Target, drain_weights: DrainWeights) -> u64 { - target + fn implied_fee_from_feerate_wu(&self, drain_weights: DrainWeights) -> u64 { + self.target .fee .rate - .implied_fee_wu(self.weight(target.outputs, drain_weights)) + .implied_fee_wu(self.weight(self.target.outputs, drain_weights)) } /// The actual fee the selection would pay if it was used in a transaction that had @@ -384,29 +394,24 @@ impl<'a> CoinSelector<'a> { /// You can pass in an `excess_discount` which must be between `0.0..1.0`. Passing in `1.0` gives you no discount /// /// [waste metric]: https://bitcoin.stackexchange.com/questions/113622/what-does-waste-metric-mean-in-the-context-of-coin-selection - pub fn waste( - &self, - target: Target, - long_term_feerate: FeeRate, - drain: Drain, - excess_discount: f32, - ) -> f32 { + pub fn waste(&self, long_term_feerate: FeeRate, drain: Drain, excess_discount: f32) -> f32 { debug_assert!((0.0..=1.0).contains(&excess_discount)); - let mut waste = self.input_waste(target.fee.rate, long_term_feerate); + let mut waste = self.input_waste(self.target.fee.rate, long_term_feerate); if drain.is_none() { // We don't allow negative excess waste since negative excess just means you haven't // satisified target yet in which case you probably shouldn't be calling this function. - let mut excess_waste = self.excess(target, drain).max(0) as f32; + let mut excess_waste = self.excess(drain).max(0) as f32; // we allow caller to discount this waste depending on how wasteful excess actually is // to them. excess_waste *= excess_discount.clamp(0.0, 1.0); waste += excess_waste; } else { - waste += - drain - .weights - .waste(target.fee.rate, long_term_feerate, target.outputs.n_outputs); + waste += drain.weights.waste( + self.target.fee.rate, + long_term_feerate, + self.target.outputs.n_outputs, + ); } waste @@ -470,9 +475,9 @@ impl<'a> CoinSelector<'a> { /// Always `true` when `max_weight` is `None`. Note this is the *anti-monotone* half of /// feasibility (adding inputs adds weight), so it is kept separate from the monotone /// value-only [`is_funded`](Self::is_funded). - pub fn is_within_max_weight(&self, target: Target, drain_weights: DrainWeights) -> bool { - match target.max_weight { - Some(max_weight) => self.weight(target.outputs, drain_weights) <= max_weight, + pub fn is_within_max_weight(&self, drain_weights: DrainWeights) -> bool { + match self.target.max_weight { + Some(max_weight) => self.weight(self.target.outputs, drain_weights) <= max_weight, None => true, } } @@ -482,8 +487,8 @@ impl<'a> CoinSelector<'a> { /// /// This is **monotone**: selecting more never un-meets it. It deliberately does *not* include /// the weight cap — see [`is_within_max_weight`](Self::is_within_max_weight). - pub fn is_funded_with_drain(&self, target: Target, drain: Drain) -> bool { - self.excess(target, drain) >= 0 + pub fn is_funded_with_drain(&self, drain: Drain) -> bool { + self.excess(drain) >= 0 } /// Whether the selection covers the target **value** (net of input fees), i.e. [`excess`] is @@ -495,8 +500,8 @@ impl<'a> CoinSelector<'a> { /// [`excess`]: Self::excess /// [`is_within_max_weight`]: Self::is_within_max_weight /// [`is_funded_with_drain`]: Self::is_funded_with_drain - pub fn is_funded(&self, target: Target) -> bool { - self.is_funded_with_drain(target, Drain::NONE) + pub fn is_funded(&self) -> bool { + self.is_funded_with_drain(Drain::NONE) } /// Select all unselected candidates @@ -512,24 +517,18 @@ impl<'a> CoinSelector<'a> { /// constraints of `target` and respecting `change_policy`. /// /// If not change output should be added according to policy then it will return `None`. - pub fn drain_value(&self, target: Target, change_policy: ChangePolicy) -> Option { - let excess = self.excess( - target, - Drain { - weights: change_policy.drain_weights, - value: 0, - }, - ); + pub fn drain_value(&self, change_policy: ChangePolicy) -> Option { + let excess = self.excess(Drain { + weights: change_policy.drain_weights, + value: 0, + }); if excess > change_policy.min_value as i64 { debug_assert_eq!( - self.is_funded(target), - self.is_funded_with_drain( - target, - Drain { - weights: change_policy.drain_weights, - value: excess as u64 - } - ), + self.is_funded(), + self.is_funded_with_drain(Drain { + weights: change_policy.drain_weights, + value: excess as u64 + }), "if the target is met without a drain it must be met after adding the drain" ); Some(excess as u64) @@ -549,8 +548,8 @@ impl<'a> CoinSelector<'a> { /// [`is_funded_with_drain`]: Self::is_funded_with_drain /// [`is_funded`]: Self::is_funded #[must_use] - pub fn drain(&self, target: Target, change_policy: ChangePolicy) -> Drain { - match self.drain_value(target, change_policy) { + pub fn drain(&self, change_policy: ChangePolicy) -> Drain { + match self.drain_value(change_policy) { Some(value) => Drain { weights: change_policy.drain_weights, value, @@ -583,14 +582,13 @@ impl<'a> CoinSelector<'a> { /// - [`SelectError::MaxWeightExceeded`] if the value is met but the resulting selection exceeds /// [`Target::max_weight`]. Note this only reflects *this* in-order greedy selection; a /// different selection might still fit the cap (use branch and bound to search for one). - pub fn select_until_target_met(&mut self, target: Target) -> Result<(), SelectError> { - self.select_until(|cs| cs.is_funded(target)) - .ok_or_else(|| { - SelectError::InsufficientFunds(InsufficientFunds { - missing: self.excess(target, Drain::NONE).unsigned_abs(), - }) - })?; - if !self.is_within_max_weight(target, DrainWeights::NONE) { + pub fn select_until_target_met(&mut self) -> Result<(), SelectError> { + self.select_until(|cs| cs.is_funded()).ok_or_else(|| { + SelectError::InsufficientFunds(InsufficientFunds { + missing: self.excess(Drain::NONE).unsigned_abs(), + }) + })?; + if !self.is_within_max_weight(DrainWeights::NONE) { return Err(SelectError::MaxWeightExceeded); } Ok(()) @@ -623,7 +621,7 @@ impl<'a> CoinSelector<'a> { /// The change *amount* comes out random on its own: because candidates are added in random order /// and we stop as soon as the change reaches `change_lower`, the final change is wherever the /// last (random) input pushed it — at or above `change_lower`. So, like Core, we use a fixed - /// lower bound rather than randomizing the target. + /// lower bound rather than randomizing the self.target. /// /// On success it returns the [`Drain`] to attach, whose value is the achieved change (at least /// `change_lower`). Returns [`SelectError::InsufficientFunds`] if the target plus `change_lower` @@ -631,7 +629,7 @@ impl<'a> CoinSelector<'a> { /// met but the resulting selection exceeds the weight cap. /// /// `rng` shuffles the candidates; it yields uniform `u64`s, e.g. `|| my_rng.next_u64()`. Any - /// already-selected candidates are kept and counted toward the target. + /// already-selected candidates are kept and counted toward the self.target. /// /// [`run_bnb`]: Self::run_bnb /// [`LowestFee`]: crate::metrics::LowestFee @@ -640,7 +638,6 @@ impl<'a> CoinSelector<'a> { // the max-weight PR lands. pub fn select_srd( &mut self, - target: Target, drain_weights: DrainWeights, change_lower: u64, rng: impl FnMut() -> u64, @@ -651,14 +648,11 @@ impl<'a> CoinSelector<'a> { let mut excess = 0_i64; self.select_until(|cs| { - is_within_max_weight = cs.is_within_max_weight(target, drain_weights); - excess = cs.excess( - target, - Drain { - weights: drain_weights, - value: 0, - }, - ); + is_within_max_weight = cs.is_within_max_weight(drain_weights); + excess = cs.excess(Drain { + weights: drain_weights, + value: 0, + }); excess >= change_lower as i64 || !is_within_max_weight }) .ok_or_else(|| { @@ -691,10 +685,9 @@ impl<'a> CoinSelector<'a> { /// Most of the time, you would want to use [`CoinSelector::run_bnb`] instead. pub fn bnb_solutions( &self, - target: Target, metric: M, ) -> impl Iterator, Ordf32)>> { - crate::bnb::BnbIter::new(self.clone(), target, metric) + crate::bnb::BnbIter::new(self.clone(), metric) } /// Run branch and bound to minimize the score of the provided [`BnbMetric`]. @@ -706,11 +699,10 @@ impl<'a> CoinSelector<'a> { /// Use [`CoinSelector::bnb_solutions`] to access the branch and bound iterator directly. pub fn run_bnb( &mut self, - target: Target, metric: M, max_rounds: usize, ) -> Result<(Ordf32, Drain), NoBnbSolution> { - let mut iter = crate::bnb::BnbIter::new(self.clone(), target, metric); + let mut iter = crate::bnb::BnbIter::new(self.clone(), metric); let mut rounds = 0_usize; let best = iter .by_ref() @@ -719,7 +711,7 @@ impl<'a> CoinSelector<'a> { .flatten() .last(); if let Some((selector, score)) = best { - let drain = iter.metric.drain(&selector, target); + let drain = iter.metric.drain(&selector); *self = selector; return Ok((score, drain)); } @@ -732,7 +724,7 @@ impl<'a> CoinSelector<'a> { assert_eq!(rounds, max_rounds); // still-yielding ⟹ we truncated at the cap return Err(NoBnbSolution::RoundLimit { max_rounds, rounds }); } - if !self.is_fundable(target) { + if !self.is_fundable() { return Err(NoBnbSolution::InsufficientFunds); } Err(NoBnbSolution::MaxWeightExceeded) diff --git a/src/metrics/changeless.rs b/src/metrics/changeless.rs index a9c9e32..c2c9036 100644 --- a/src/metrics/changeless.rs +++ b/src/metrics/changeless.rs @@ -1,4 +1,4 @@ -use crate::{bnb::BnbMetric, float::Ordf32, CoinSelector, Drain, Target}; +use crate::{bnb::BnbMetric, float::Ordf32, CoinSelector, Drain}; /// Constrains an `inner` metric to only changeless solutions. /// @@ -26,50 +26,50 @@ impl Changeless { /// are next to each other, which [`requires_ordering_by_descending_value_pwu`] guarantees. /// /// [`requires_ordering_by_descending_value_pwu`]: BnbMetric::requires_ordering_by_descending_value_pwu - fn change_unavoidable(&mut self, cs: &CoinSelector<'_>, target: Target) -> bool { - if self.0.drain(cs, target).is_none() { + fn change_unavoidable(&mut self, cs: &CoinSelector<'_>) -> bool { + if self.0.drain(cs).is_none() { return false; } let mut least_excess = cs.clone(); cs.unselected() .rev() - .take_while(|(_, wv)| wv.effective_value(target.fee.rate) < 0.0) + .take_while(|(_, wv)| wv.effective_value(cs.target().fee.rate) < 0.0) .for_each(|(index, _)| { least_excess.select(index); }); - self.0.drain(&least_excess, target).is_some() + self.0.drain(&least_excess).is_some() } } impl BnbMetric for Changeless { - fn drain(&mut self, _cs: &CoinSelector<'_>, _target: Target) -> Drain { + fn drain(&mut self, _cs: &CoinSelector<'_>) -> Drain { // by definition a changeless selection never has a change output Drain::NONE } - fn score(&mut self, cs: &CoinSelector<'_>, target: Target) -> Option { + fn score(&mut self, cs: &CoinSelector<'_>) -> Option { // Reject selections that have change. We don't need an explicit target-met check: `inner` // returns `None` for invalid (e.g. not-target-met) selections. // // NOTE: for metrics whose `score` recomputes the drain (e.g. `LowestFee`), this evaluates // the drain decision twice per node. Sharing it would mean threading the drain into // `score`, which we avoid to keep metrics composable. - if self.0.drain(cs, target).is_some() { + if self.0.drain(cs).is_some() { return None; } - self.0.score(cs, target) + self.0.score(cs) } - fn bound(&mut self, cs: &CoinSelector<'_>, target: Target) -> Option { - if self.change_unavoidable(cs, target) { + fn bound(&mut self, cs: &CoinSelector<'_>) -> Option { + if self.change_unavoidable(cs) { // every descendant has change, so no changeless solution is reachable None } else { // the changeless-constrained optimum is no better than the inner metric's unconstrained // optimum, so the inner bound is a valid lower bound - self.0.bound(cs, target) + self.0.bound(cs) } } diff --git a/src/metrics/lowest_fee.rs b/src/metrics/lowest_fee.rs index 5499777..d990f71 100644 --- a/src/metrics/lowest_fee.rs +++ b/src/metrics/lowest_fee.rs @@ -1,11 +1,11 @@ -use crate::{float::Ordf32, BnbMetric, CoinSelector, Drain, DrainWeights, FeeRate, Target}; +use crate::{float::Ordf32, BnbMetric, CoinSelector, Drain, DrainWeights, FeeRate}; /// Metric that aims to minimize transaction fees. The future fee for spending the change output is /// included in this calculation. /// /// The fee is simply: /// -/// > `inputs - outputs` where `outputs = target.value + change_value` +/// > `inputs - outputs` where `outputs = cs.target().value + change_value` /// /// But the total value includes the cost of spending the change output if it exists: /// @@ -27,16 +27,13 @@ pub struct LowestFee { impl LowestFee { /// The value the change output should have, or `None` if this selection should be changeless. - fn drain_value(&self, cs: &CoinSelector<'_>, target: Target) -> Option { + fn drain_value(&self, cs: &CoinSelector<'_>) -> Option { // The change output pays for its own weight, so the value we'd actually recover is the // excess remaining after accounting for that weight. - let excess_with_drain_weight = cs.excess( - target, - Drain { - weights: self.drain_weights, - value: 0, - }, - ); + let excess_with_drain_weight = cs.excess(Drain { + weights: self.drain_weights, + value: 0, + }); // Adding change is only worth it if the value we'd recover exceeds the future cost of // spending it (i.e. it lowers the long-term fee). @@ -56,7 +53,7 @@ impl LowestFee { // ...and only if the change output would not push the tx over `max_weight`. If it would, // we refuse the drain and the excess goes to fee instead (a slightly conservative choice: // it can refuse change even when a no-change tx of this selection would fit). - if !cs.is_within_max_weight(target, self.drain_weights) { + if !cs.is_within_max_weight(self.drain_weights) { return None; } @@ -71,17 +68,15 @@ impl LowestFee { /// inside [`bound`](BnbMetric::bound): deferring the changeless rejection only loosens the lower /// bound and never makes it inadmissible, and `score` reuses the returned drain for its cap /// check so the drain is decided once. - fn fee_score(&self, cs: &CoinSelector<'_>, target: Target) -> Option<(Ordf32, Drain)> { - if !cs.is_funded(target) { + fn fee_score(&self, cs: &CoinSelector<'_>) -> Option<(Ordf32, Drain)> { + if !cs.is_funded() { return None; } - let drain = self - .drain_value(cs, target) - .map_or(Drain::NONE, |value| Drain { - weights: self.drain_weights, - value, - }); - let fee_for_the_tx = cs.fee(target.value(), drain.value); + let drain = self.drain_value(cs).map_or(Drain::NONE, |value| Drain { + weights: self.drain_weights, + value, + }); + let fee_for_the_tx = cs.fee(cs.target().value(), drain.value); assert!( fee_for_the_tx >= 0, "must not be called unless selection has met target: fee={}", @@ -96,36 +91,35 @@ impl LowestFee { } impl BnbMetric for LowestFee { - fn drain(&mut self, cs: &CoinSelector<'_>, target: Target) -> Drain { - self.drain_value(cs, target) - .map_or(Drain::NONE, |value| Drain { - weights: self.drain_weights, - value, - }) + fn drain(&mut self, cs: &CoinSelector<'_>) -> Drain { + self.drain_value(cs).map_or(Drain::NONE, |value| Drain { + weights: self.drain_weights, + value, + }) } - fn score(&mut self, cs: &CoinSelector<'_>, target: Target) -> Option { - let (score, drain) = self.fee_score(cs, target)?; + fn score(&mut self, cs: &CoinSelector<'_>) -> Option { + let (score, drain) = self.fee_score(cs)?; // A final selection must fit the weight cap. `drain_value` already refuses an over-cap // change, but a changeless selection can still be too heavy on its own. Reuse the drain // `fee_score` already decided rather than recomputing it here. - if !cs.is_within_max_weight(target, drain.weights) { + if !cs.is_within_max_weight(drain.weights) { return None; } Some(score) } - fn bound(&mut self, cs: &CoinSelector<'_>, target: Target) -> Option { + fn bound(&mut self, cs: &CoinSelector<'_>) -> Option { // Weight hard-prune: input weight only grows as this branch is extended, so the lightest // solution in the subtree is this selection with no drain. If even that busts `max_weight`, // the whole subtree is infeasible -> prune. (Also keeps `fee_score(cs).unwrap()` below // sound: a value-met but over-cap node would otherwise score `None`.) - if !cs.is_within_max_weight(target, DrainWeights::NONE) { + if !cs.is_within_max_weight(DrainWeights::NONE) { return None; } - if cs.is_funded(target) { - let current_score = self.fee_score(cs, target).unwrap().0; + if cs.is_funded() { + let current_score = self.fee_score(cs).unwrap().0; // `current_score` is already a valid lower bound for a selection that has change: a // descendant can never lower the fee by removing an existing (worthwhile) change @@ -146,17 +140,17 @@ impl BnbMetric for LowestFee { // `drain_value`, where `change_value` is `excess_with_drain_weight` and `spend_fee` is // `drain_spend_cost`). With `v >= 0` the difference is strictly positive: B always // costs more. - if self.drain_value(cs, target).is_none() { + if self.drain_value(cs).is_none() { // But a descendant might *add* a change output that improves the metric. This // happens when the current selection is changeless only because the change would be // dust: a descendant with more excess could clear the dust threshold and recover // value that is currently burned to fees. let cost_of_adding_change = self.drain_weights.waste( - target.fee.rate, + cs.target().fee.rate, self.long_term_feerate, - target.outputs.n_outputs, + cs.target().outputs.n_outputs, ); - let cost_of_no_change = cs.excess(target, Drain::NONE); + let cost_of_no_change = cs.excess(Drain::NONE); let best_score_with_change = Ordf32(current_score.0 - cost_of_no_change as f32 + cost_of_adding_change); @@ -165,10 +159,10 @@ impl BnbMetric for LowestFee { // of which only make the tx heavier. If there's no room for both under the cap the // improvement is unreachable down this branch, so don't credit it — keep // `current_score` (a tighter, still-admissible bound). - let change_is_reachable = match target.max_weight { + let change_is_reachable = match cs.target().max_weight { None => true, Some(max_weight) => cs.min_input_weight().map_or(false, |min_input_weight| { - cs.weight(target.outputs, self.drain_weights) + min_input_weight + cs.weight(cs.target().outputs, self.drain_weights) + min_input_weight <= max_weight }), }; @@ -179,20 +173,18 @@ impl BnbMetric for LowestFee { Some(current_score) } else { - // Step 1: select everything up until the input that hits the target. - let (mut cs, resize_index, to_resize) = cs - .clone() - .select_iter() - .find(|(cs, _, _)| cs.is_funded(target))?; + // Step 1: select everything up until the input that hits the cs.target(). + let (mut cs, resize_index, to_resize) = + cs.clone().select_iter().find(|(cs, _, _)| cs.is_funded())?; // If this selection is already perfect, return its score directly. - if cs.excess(target, Drain::NONE) == 0 { - return Some(self.fee_score(&cs, target).unwrap().0); + if cs.excess(Drain::NONE) == 0 { + return Some(self.fee_score(&cs).unwrap().0); }; cs.deselect(resize_index); // We need to find the minimum fee we'd pay if we satisfy the feerate constraint. We do - // this by imagining we had a perfect input that perfectly hit the target. The sats per + // this by imagining we had a perfect input that perfectly hit the cs.target(). The sats per // weight unit of this perfect input is that of `to_resize` but we'll do a scaled // resize of it to fit perfectly. // @@ -208,12 +200,13 @@ impl BnbMetric for LowestFee { // // In the perfect scenario, no additional fee would be required to pay for rounding up when converting from weight units to // vbytes and so all fee calculations below are performed on weight units directly. - let rate_excess = cs.rate_excess_wu(target, Drain::NONE) as f32; + let rate_excess = cs.rate_excess_wu(Drain::NONE) as f32; let mut scale = Ordf32(0.0); if rate_excess < 0.0 { let remaining_value_to_reach_feerate = rate_excess.abs(); - let effective_value_of_resized_input = to_resize.effective_value(target.fee.rate); + let effective_value_of_resized_input = + to_resize.effective_value(cs.target().fee.rate); if effective_value_of_resized_input > 0.0 { let feerate_scale = remaining_value_to_reach_feerate / effective_value_of_resized_input; @@ -225,8 +218,8 @@ impl BnbMetric for LowestFee { // We can use the same approach for replacement we just have to use the // incremental_relay_feerate. - if let Some(replace) = target.fee.replace { - let replace_excess = cs.replacement_excess_wu(target, Drain::NONE) as f32; + if let Some(replace) = cs.target().fee.replace { + let replace_excess = cs.replacement_excess_wu(Drain::NONE) as f32; if replace_excess < 0.0 { let remaining_value_to_reach_feerate = replace_excess.abs(); let effective_value_of_resized_input = @@ -243,7 +236,7 @@ impl BnbMetric for LowestFee { // Handle absolute fee constraint. Unlike feerate and replacement, the // absolute fee is a fixed amount (not weight-proportional), so we just // need enough raw value to cover the gap. - let absolute_excess = cs.absolute_excess(target, Drain::NONE) as f32; + let absolute_excess = cs.absolute_excess(Drain::NONE) as f32; if absolute_excess < 0.0 { let remaining = absolute_excess.abs(); if to_resize.value > 0 { @@ -260,8 +253,8 @@ impl BnbMetric for LowestFee { // no within-cap selection down this branch reaches the target -> prune. This is the // fractional relaxation, so it never prunes a branch with an (integer) within-cap // solution. - if let Some(max_weight) = target.max_weight { - if cs.weight(target.outputs, DrainWeights::NONE) as f32 + if let Some(max_weight) = cs.target().max_weight { + if cs.weight(cs.target().outputs, DrainWeights::NONE) as f32 + scale.0 * to_resize.weight as f32 > max_weight as f32 { @@ -272,7 +265,7 @@ impl BnbMetric for LowestFee { // `scale` could be 0 even if `is_funded` is `false` due to the latter being based on // rounded-up vbytes. let ideal_fee = scale.0 * to_resize.value as f32 + cs.selected_value() as f32 - - target.value() as f32; + - cs.target().value() as f32; assert!(ideal_fee >= 0.0); Some(Ordf32(ideal_fee)) diff --git a/tests/bnb.rs b/tests/bnb.rs index 3fea68a..7a0b668 100644 --- a/tests/bnb.rs +++ b/tests/bnb.rs @@ -32,8 +32,8 @@ struct MinExcessThenWeight; const EXCESS_RATIO: f32 = 1_000_000_f32; impl BnbMetric for MinExcessThenWeight { - fn score(&mut self, cs: &CoinSelector<'_>, target: Target) -> Option { - let excess = cs.excess(target, Drain::NONE); + fn score(&mut self, cs: &CoinSelector<'_>) -> Option { + let excess = cs.excess(Drain::NONE); if excess < 0 { None } else { @@ -43,13 +43,13 @@ impl BnbMetric for MinExcessThenWeight { } } - fn bound(&mut self, cs: &CoinSelector<'_>, target: Target) -> Option { + fn bound(&mut self, cs: &CoinSelector<'_>) -> Option { let mut cs = cs.clone(); - cs.select_until_target_met(target).ok()?; + cs.select_until_target_met().ok()?; Some(Ordf32(cs.input_weight() as f32)) } - fn drain(&mut self, _cs: &CoinSelector<'_>, _target: Target) -> Drain { + fn drain(&mut self, _cs: &CoinSelector<'_>) -> Drain { Drain::NONE } } @@ -65,20 +65,12 @@ fn bnb_finds_an_exact_solution_in_n_iter() { let mut wv = test_wv(&mut rng); let solution: Vec = (0..solution_len).map(|_| wv.next().unwrap()).collect(); - let solution_weight = { - let mut cs = CoinSelector::new(&solution); - cs.select_all(); - cs.input_weight() - }; - let target_value = solution.iter().map(|c| c.value).sum(); - let mut candidates = solution; + let mut candidates = solution.clone(); candidates.extend(wv.take(num_additional_canidates)); candidates.sort_unstable_by_key(|wv| core::cmp::Reverse(wv.value)); - let cs = CoinSelector::new(&candidates); - let target = Target { outputs: TargetOutputs { value_sum: target_value, @@ -90,7 +82,14 @@ fn bnb_finds_an_exact_solution_in_n_iter() { max_weight: None, }; - let solutions = cs.bnb_solutions(target, MinExcessThenWeight); + let solution_weight = { + let mut cs = CoinSelector::new(&solution, target); + cs.select_all(); + cs.input_weight() + }; + + let cs = CoinSelector::new(&candidates, target); + let solutions = cs.bnb_solutions(MinExcessThenWeight); let mut rounds = 0; let (best, score) = solutions @@ -113,8 +112,6 @@ fn bnb_finds_solution_if_possible_in_n_iter() { let wv = test_wv(&mut rng); let candidates = wv.take(num_inputs).collect::>(); - let cs = CoinSelector::new(&candidates); - let target = Target { outputs: TargetOutputs { value_sum: target_value, @@ -125,7 +122,8 @@ fn bnb_finds_solution_if_possible_in_n_iter() { max_weight: None, }; - let solutions = cs.bnb_solutions(target, MinExcessThenWeight); + let cs = CoinSelector::new(&candidates, target); + let solutions = cs.bnb_solutions(MinExcessThenWeight); let mut rounds = 0; let (sol, _score) = solutions @@ -136,7 +134,7 @@ fn bnb_finds_solution_if_possible_in_n_iter() { .expect("found a solution"); assert_eq!(rounds, 164); - let excess = sol.excess(target, Drain::NONE); + let excess = sol.excess(Drain::NONE); assert_eq!(excess, 0); } @@ -147,19 +145,18 @@ proptest! { let mut rng = TestRng::deterministic_rng(RngAlgorithm::ChaCha); let wv = test_wv(&mut rng); let candidates = wv.take(num_inputs).collect::>(); - let cs = CoinSelector::new(&candidates); let target = Target { outputs: TargetOutputs { value_sum: target_value, weight_sum: 0, n_outputs: 1 }, fee: TargetFee::ZERO, max_weight: None, }; - - let solutions = cs.bnb_solutions(target, MinExcessThenWeight); + let cs = CoinSelector::new(&candidates, target); + let solutions = cs.bnb_solutions(MinExcessThenWeight); match solutions.enumerate().filter_map(|(i, sol)| Some((i, sol?))).last() { Some((_i, (sol, _score))) => assert!(sol.selected_value() >= target_value), - _ => prop_assert!(!cs.is_fundable(target)), + _ => prop_assert!(!cs.is_fundable()), } } @@ -174,20 +171,25 @@ proptest! { let mut wv = test_wv(&mut rng); let solution: Vec = (0..solution_len).map(|_| wv.next().unwrap()).collect(); - let solution_weight = { - let mut cs = CoinSelector::new(&solution); - cs.select_all(); - cs.input_weight() - }; - let target_value = solution.iter().map(|c| c.value).sum(); - let mut candidates = solution; + let mut candidates = solution.clone(); candidates.extend(wv.take(num_additional_canidates)); - let mut cs = CoinSelector::new(&candidates); + let target = Target { + outputs: TargetOutputs { value_sum: target_value, weight_sum: 0, n_outputs: 1 }, + // we're trying to find an exact selection value so set fees to 0 + fee: TargetFee::ZERO, + max_weight: None, + }; + let solution_weight = { + let mut cs = CoinSelector::new(&solution, target); + cs.select_all(); + cs.input_weight() + }; + let mut cs = CoinSelector::new(&candidates, target); for i in 0..num_preselected.min(solution_len) { cs.select(i); } @@ -195,14 +197,7 @@ proptest! { // sort in descending value cs.sort_candidates_by_key(|(_, wv)| core::cmp::Reverse(wv.value)); - let target = Target { - outputs: TargetOutputs { value_sum: target_value, weight_sum: 0, n_outputs: 1 }, - // we're trying to find an exact selection value so set fees to 0 - fee: TargetFee::ZERO, - max_weight: None, - }; - - let solutions = cs.bnb_solutions(target, MinExcessThenWeight); + let solutions = cs.bnb_solutions(MinExcessThenWeight); let (_i, (best, _score)) = solutions .enumerate() diff --git a/tests/changeless.rs b/tests/changeless.rs index 2d43cb7..c92ee08 100644 --- a/tests/changeless.rs +++ b/tests/changeless.rs @@ -53,7 +53,6 @@ proptest! { let wv = test_wv(&mut rng); let candidates = wv.take(n_candidates).collect::>(); - let cs = CoinSelector::new(&candidates); let target = Target { outputs: TargetOutputs { @@ -68,6 +67,7 @@ proptest! { }, max_weight: None, }; + let cs = CoinSelector::new(&candidates, target); let make_metric = || { Changeless(LowestFee { @@ -77,7 +77,7 @@ proptest! { }) }; - let solutions = cs.bnb_solutions(target, make_metric()); + let solutions = cs.bnb_solutions(make_metric()); println!("candidates: {:#?}", cs.candidates().collect::>()); @@ -94,7 +94,7 @@ proptest! { None => { let mut cs = cs.clone(); let mut metric = make_metric(); - let has_solution = common::exhaustive_search(&mut cs, target, &mut metric).is_some(); + let has_solution = common::exhaustive_search(&mut cs, &mut metric).is_some(); dbg!(format!("{}", cs)); assert!(!has_solution); } diff --git a/tests/common.rs b/tests/common.rs index e508b04..1b35784 100644 --- a/tests/common.rs +++ b/tests/common.rs @@ -51,7 +51,7 @@ where let target = params.target(); - let mut selection = CoinSelector::new(&candidates); + let mut selection = CoinSelector::new(&candidates, target); let mut exp_selection = selection.clone(); if metric.requires_ordering_by_descending_value_pwu() { @@ -61,8 +61,8 @@ where println!("\texhaustive search:"); let now = std::time::Instant::now(); - let exp_result = exhaustive_search(&mut exp_selection, target, &mut metric); - let exp_change = metric.drain(&exp_selection, target); + let exp_result = exhaustive_search(&mut exp_selection, &mut metric); + let exp_change = metric.drain(&exp_selection); let exp_result_str = result_string(&exp_result.ok_or("no possible solution"), exp_change); println!( "\t\telapsed={:8}s result={}", @@ -72,7 +72,7 @@ where // bonus check: ensure replacement fee is respected if exp_result.is_some() { let selected_value = exp_selection.selected_value(); - let drain = metric.drain(&exp_selection, target); + let drain = metric.drain(&exp_selection); let target_value = target.value(); let replace_fee = params .replace @@ -87,8 +87,8 @@ where println!("\tbranch and bound:"); let now = std::time::Instant::now(); let mut bnb_metric = metric.clone(); - let result = bnb_search(&mut selection, target, metric, usize::MAX); - let change = bnb_metric.drain(&selection, target); + let result = bnb_search(&mut selection, metric, usize::MAX); + let change = bnb_metric.drain(&selection); let result_str = result_string(&result, change); println!( "\t\telapsed={:8}s result={}", @@ -112,7 +112,7 @@ where // bonus check: ensure replacement fee is respected let selected_value = selection.selected_value(); - let drain = bnb_metric.drain(&selection, target); + let drain = bnb_metric.drain(&selection); let target_value = target.value(); let replace_fee = params .replace @@ -148,7 +148,7 @@ where let target = params.target(); let init_cs = { - let mut cs = CoinSelector::new(&candidates); + let mut cs = CoinSelector::new(&candidates, target); if metric.requires_ordering_by_descending_value_pwu() { cs.sort_candidates_by_descending_value_pwu(); } @@ -157,12 +157,12 @@ where print_candidates(¶ms, &init_cs); for (cs, _) in ExhaustiveIter::new(&init_cs).into_iter().flatten() { - if let Some(lb_score) = metric.bound(&cs, target) { + if let Some(lb_score) = metric.bound(&cs) { // This is the branch's lower bound. In other words, this is the BEST selection // possible (can overshoot) traversing down this branch. Let's check that! - if let Some(score) = metric.score(&cs, target) { - let has_change = metric.drain(&cs, target).is_some(); + if let Some(score) = metric.score(&cs) { + let has_change = metric.drain(&cs).is_some(); prop_assert!( score >= lb_score, "checking branch: selection={} score={} change={} lb={}", @@ -178,9 +178,9 @@ where .flatten() .filter(|(_, inc)| *inc) { - if let Some(descendant_score) = metric.score(&descendant_cs, target) { - let parent_has_change = metric.drain(&cs, target).is_some(); - let descendant_has_change = metric.drain(&descendant_cs, target).is_some(); + if let Some(descendant_score) = metric.score(&descendant_cs) { + let parent_has_change = metric.drain(&cs).is_some(); + let descendant_has_change = metric.drain(&descendant_cs).is_some(); prop_assert!( descendant_score >= lb_score, " @@ -190,7 +190,7 @@ where cs, parent_has_change, lb_score, - cs.is_funded(target), + cs.is_funded(), descendant_cs, descendant_has_change, descendant_score, @@ -347,11 +347,7 @@ impl<'a> Iterator for ExhaustiveIter<'a> { } } -pub fn exhaustive_search( - cs: &mut CoinSelector, - target: Target, - metric: &mut M, -) -> Option<(Ordf32, usize)> +pub fn exhaustive_search(cs: &mut CoinSelector, metric: &mut M) -> Option<(Ordf32, usize)> where M: BnbMetric, { @@ -366,7 +362,7 @@ where .enumerate() .inspect(|(i, _)| rounds = *i) .filter(|(_, (_, inclusion))| *inclusion) - .filter_map(|(_, (cs, _))| metric.score(&cs, target).map(|score| (cs, score))); + .filter_map(|(_, (cs, _))| metric.score(&cs).map(|score| (cs, score))); for (child_cs, score) in iter { match &mut best { @@ -395,10 +391,8 @@ where /// [`CoinSelector::is_funded`] + [`CoinSelector::is_within_max_weight`], so it inherits the /// exact weight model and is independent of the BnB weight prune it audits. Exponential — small `n` /// only. -pub fn exact_selection_possible(cs: &CoinSelector, target: Target) -> bool { - let feasible = |s: &CoinSelector| { - s.is_funded(target) && s.is_within_max_weight(target, DrainWeights::NONE) - }; +pub fn exact_selection_possible(cs: &CoinSelector) -> bool { + let feasible = |s: &CoinSelector| s.is_funded() && s.is_within_max_weight(DrainWeights::NONE); // the current selection itself (no additions) is a valid subset and isn't yielded by the iter feasible(cs) || ExhaustiveIter::new(cs) @@ -408,7 +402,6 @@ pub fn exact_selection_possible(cs: &CoinSelector, target: Target) -> bool { pub fn bnb_search( cs: &mut CoinSelector, - target: Target, metric: M, max_rounds: usize, ) -> Result<(Ordf32, usize), NoBnbSolution> @@ -417,7 +410,7 @@ where { let mut rounds = 0_usize; let (selection, score) = cs - .bnb_solutions(target, metric) + .bnb_solutions(metric) .inspect(|_| rounds += 1) .take(max_rounds) .flatten() @@ -455,8 +448,8 @@ pub fn compare_against_benchmarks( let start = std::time::Instant::now(); let mut rng = TestRng::deterministic_rng(RngAlgorithm::ChaCha); let target = params.target(); - let cs = CoinSelector::new(&candidates); - let solutions = cs.bnb_solutions(target, metric.clone()); + let cs = CoinSelector::new(&candidates, target); + let solutions = cs.bnb_solutions(metric.clone()); let best = solutions .enumerate() @@ -472,7 +465,7 @@ pub fn compare_against_benchmarks( core::cmp::Reverse(Ordf32(wv.effective_value(target.fee.rate))) }); // we filter out failing onces below - let _ = naive_select.select_until_target_met(target); + let _ = naive_select.select_until_target_met(); naive_select }, { @@ -492,7 +485,7 @@ pub fn compare_against_benchmarks( // exists, so the comparison below isn't vacuous. let mut greedy = cs.clone(); greedy.sort_candidates_by_descending_value_pwu(); - let _ = greedy.select_until_target_met(target); + let _ = greedy.select_until_target_met(); greedy }, ]; @@ -508,11 +501,11 @@ pub fn compare_against_benchmarks( let cmp_benchmarks = cmp_benchmarks .into_iter() .filter_map(|cs| { - let score = metric.clone().score(&cs, target)?; + let score = metric.clone().score(&cs)?; Some((cs, score)) }) .collect::>(); - let sol_score = metric.score(&sol, target); + let sol_score = metric.score(&sol); for (_bench_id, (mut bench, bench_score)) in cmp_benchmarks.into_iter().enumerate() { prop_assert!( @@ -533,7 +526,7 @@ pub fn compare_against_benchmarks( None => { // Full feasibility (value *and* max_weight) is needed here; `is_fundable` // only covers value, so use the exact exhaustive oracle to assert impossibility. - prop_assert!(!exact_selection_possible(&cs, target)); + prop_assert!(!exact_selection_possible(&cs)); } } @@ -553,8 +546,8 @@ fn randomly_satisfy_target<'a, R: rand::Rng>( let mut last_score: Option = None; while let Some(next) = cs.unselected_indices().choose(rng) { cs.select(next); - if cs.is_funded(target) { - let curr_score = metric.score(&cs, target); + if cs.is_funded() { + let curr_score = metric.score(&cs); if let Some(last_score) = last_score { if curr_score.is_none() || curr_score.unwrap() > last_score { break; diff --git a/tests/lowest_fee.rs b/tests/lowest_fee.rs index f428d76..d80a6f6 100644 --- a/tests/lowest_fee.rs +++ b/tests/lowest_fee.rs @@ -90,11 +90,11 @@ proptest! { params.n_candidates ]; - let mut cs = CoinSelector::new(&candidates); + let mut cs = CoinSelector::new(&candidates, params.target()); let metric = params.lowest_fee_metric(); - let is_impossible = !cs.is_fundable(params.target()); - match common::bnb_search(&mut cs, params.target(), metric, params.n_candidates * 10) { + let is_impossible = !cs.is_fundable(); + match common::bnb_search(&mut cs, metric, params.n_candidates * 10) { Ok((score, rounds)) => { // the +1 is because the iterator will always try selecting nothing as a solution so we have // to do one extra iteration to try that @@ -161,10 +161,10 @@ proptest! { let target = params.target(); let metric = params.lowest_fee_metric(); - let exact_possible = common::exact_selection_possible(&CoinSelector::new(&candidates), target); + let exact_possible = common::exact_selection_possible(&CoinSelector::new(&candidates, target)); - let mut cs = CoinSelector::new(&candidates); - let bnb_found = common::bnb_search(&mut cs, target, metric, usize::MAX).is_ok(); + let mut cs = CoinSelector::new(&candidates, target); + let bnb_found = common::bnb_search(&mut cs, metric, usize::MAX).is_ok(); prop_assert_eq!( bnb_found, exact_possible, "bnb_found={} but exact_possible={} (weight prune may have dropped a feasible subtree)", @@ -194,23 +194,21 @@ fn combined_changeless_metric() { }; let candidates = common::gen_candidates(params.n_candidates); - let mut cs_a = CoinSelector::new(&candidates); - let mut cs_b = CoinSelector::new(&candidates); - let target = params.target(); + let mut cs_a = CoinSelector::new(&candidates, target); + let mut cs_b = CoinSelector::new(&candidates, target); let metric_lowest_fee = params.lowest_fee_metric(); let metric_changeless = Changeless(params.lowest_fee_metric()); // cs_a uses the unconstrained metric - let (score, rounds) = common::bnb_search(&mut cs_a, target, metric_lowest_fee, usize::MAX) - .expect("must find solution"); + let (score, rounds) = + common::bnb_search(&mut cs_a, metric_lowest_fee, usize::MAX).expect("must find solution"); println!("score={:?} rounds={}", score, rounds); // cs_b uses the changeless-constrained metric let (combined_score, combined_rounds) = - common::bnb_search(&mut cs_b, target, metric_changeless, usize::MAX) - .expect("must find solution"); + common::bnb_search(&mut cs_b, metric_changeless, usize::MAX).expect("must find solution"); println!("score={:?} rounds={}", combined_score, combined_rounds); assert!(combined_rounds >= rounds); @@ -255,7 +253,7 @@ fn does_not_create_change_below_spend_cost() { }, ]; - let mut cs = CoinSelector::new(&candidates); + let mut cs = CoinSelector::new(&candidates, target); let drain_weights = DrainWeights { output_weight: 100, @@ -269,17 +267,17 @@ fn does_not_create_change_below_spend_cost() { drain_weights, }; - let (score, _) = common::bnb_search(&mut cs, target, metric, 10).expect("finds solution"); + let (score, _) = common::bnb_search(&mut cs, metric, 10).expect("finds solution"); // The optimal selection is candidate 0 alone, and it must be changeless. let expected = { - let mut expected = CoinSelector::new(&candidates); + let mut expected = CoinSelector::new(&candidates, target); expected.select(0); expected }; assert_eq!(cs.selected_indices(), expected.selected_indices()); assert!( - metric.drain(&cs, target).is_none(), + metric.drain(&cs).is_none(), "optimal selection must be changeless" ); @@ -289,12 +287,7 @@ fn does_not_create_change_below_spend_cost() { with_extra_input.select(2); with_extra_input }; - assert!( - score - <= metric - .score(&with_extra_input, target) - .expect("target is met") - ); + assert!(score <= metric.score(&with_extra_input).expect("target is met")); } #[test] @@ -337,14 +330,13 @@ fn zero_fee_tx() { n_outputs: 1, }; - let mut cs = CoinSelector::new(&candidates); + let mut cs = CoinSelector::new(&candidates, target); let metric = LowestFee { long_term_feerate, dust_relay_feerate: FeeRate::from_sat_per_vb(1.0), drain_weights, }; - let (_score, _rounds) = - common::bnb_search(&mut cs, target, metric, 1000).expect("must find solution"); + let (_score, _rounds) = common::bnb_search(&mut cs, metric, 1000).expect("must find solution"); } // --- `run_bnb` failure classification (`NoBnbSolution` variants) --- @@ -378,14 +370,14 @@ fn err_outputs(value_sum: u64) -> TargetOutputs { fn run_bnb_reports_insufficient_funds() { // Two 100k inputs can't cover a 10M target: the value is simply unreachable. let candidates = [err_candidate(100_000), err_candidate(100_000)]; - let mut cs = CoinSelector::new(&candidates); let target = Target { outputs: err_outputs(10_000_000), fee: TargetFee::ZERO, max_weight: None, }; + let mut cs = CoinSelector::new(&candidates, target); assert_eq!( - cs.run_bnb(target, err_metric(), 100_000).unwrap_err(), + cs.run_bnb(err_metric(), 100_000).unwrap_err(), NoBnbSolution::InsufficientFunds, ); } @@ -399,14 +391,14 @@ fn run_bnb_reports_max_weight_exceeded() { err_candidate(100_000), err_candidate(100_000), ]; - let mut cs = CoinSelector::new(&candidates); let target = Target { outputs: err_outputs(250_000), fee: TargetFee::ZERO, max_weight: Some(1), }; + let mut cs = CoinSelector::new(&candidates, target); assert_eq!( - cs.run_bnb(target, err_metric(), 100_000).unwrap_err(), + cs.run_bnb(err_metric(), 100_000).unwrap_err(), NoBnbSolution::MaxWeightExceeded, ); } @@ -419,14 +411,14 @@ fn run_bnb_reports_round_limit() { err_candidate(100_000), err_candidate(100_000), ]; - let mut cs = CoinSelector::new(&candidates); let target = Target { outputs: err_outputs(250_000), fee: TargetFee::ZERO, max_weight: None, }; + let mut cs = CoinSelector::new(&candidates, target); assert_eq!( - cs.run_bnb(target, err_metric(), 0).unwrap_err(), + cs.run_bnb(err_metric(), 0).unwrap_err(), NoBnbSolution::RoundLimit { max_rounds: 0, rounds: 0, diff --git a/tests/srd.rs b/tests/srd.rs index e8348f1..4f93204 100644 --- a/tests/srd.rs +++ b/tests/srd.rs @@ -36,8 +36,8 @@ fn srd_success_yields_healthy_change_that_meets_target() { let mut successes = 0; for seed in 0..300u64 { - let mut cs = CoinSelector::new(&candidates); - let result = cs.select_srd(target, drain_weights, CHANGE_LOWER, splitmix64(seed)); + let mut cs = CoinSelector::new(&candidates, target); + let result = cs.select_srd(drain_weights, CHANGE_LOWER, splitmix64(seed)); if let Ok(drain) = result { successes += 1; @@ -49,18 +49,15 @@ fn srd_success_yields_healthy_change_that_meets_target() { ); assert_eq!(drain.weights, drain_weights); assert!( - cs.is_funded_with_drain(target, drain), + cs.is_funded_with_drain(drain), "seed {}: target not met with the returned drain", seed ); // The reported change equals the actual excess available to the drain. - let excess = cs.excess( - target, - Drain { - weights: drain_weights, - value: 0, - }, - ); + let excess = cs.excess(Drain { + weights: drain_weights, + value: 0, + }); assert_eq!(drain.value as i64, excess); } } @@ -95,8 +92,8 @@ fn srd_insufficient_funds() { let drain_weights = DrainWeights::TR_KEYSPEND; for seed in 0..50u64 { - let mut cs = CoinSelector::new(&candidates); - let result = cs.select_srd(target, drain_weights, CHANGE_LOWER, splitmix64(seed)); + let mut cs = CoinSelector::new(&candidates, target); + let result = cs.select_srd(drain_weights, CHANGE_LOWER, splitmix64(seed)); assert!( matches!(result, Err(SelectError::InsufficientFunds(_))), "seed {}: expected InsufficientFunds, got {:?}", @@ -129,9 +126,9 @@ fn srd_max_weight_exceeded() { }; // Weight of the smallest selection that reaches target + change_lower, with no cap. - let mut probe = CoinSelector::new(&candidates); + let mut probe = CoinSelector::new(&candidates, target(200_000, 5.0)); probe - .select_until(|cs| cs.excess(target(200_000, 5.0), drain) >= CHANGE_LOWER as i64) + .select_until(|cs| cs.excess(drain) >= CHANGE_LOWER as i64) .expect("candidates can cover target + change_lower"); let needed_weight = probe.weight(target(200_000, 5.0).outputs, drain_weights); @@ -142,8 +139,8 @@ fn srd_max_weight_exceeded() { }; for seed in 0..20u64 { - let mut cs = CoinSelector::new(&candidates); - let result = cs.select_srd(capped, drain_weights, CHANGE_LOWER, splitmix64(seed)); + let mut cs = CoinSelector::new(&candidates, capped); + let result = cs.select_srd(drain_weights, CHANGE_LOWER, splitmix64(seed)); assert!( matches!(result, Err(SelectError::MaxWeightExceeded)), "seed {}: expected MaxWeightExceeded, got {:?}", @@ -166,13 +163,13 @@ fn srd_adds_nothing_when_already_sufficient() { }; // Preselect enough that the change already exceeds `change_lower`. - let mut cs = CoinSelector::new(&candidates); - cs.select_until(|cs| cs.excess(target, drain) >= CHANGE_LOWER as i64) + let mut cs = CoinSelector::new(&candidates, target); + cs.select_until(|cs| cs.excess(drain) >= CHANGE_LOWER as i64) .expect("candidates can cover target + change_lower"); let before: Vec = cs.selected_indices().iter().collect(); let out = cs - .select_srd(target, drain_weights, CHANGE_LOWER, splitmix64(3)) + .select_srd(drain_weights, CHANGE_LOWER, splitmix64(3)) .expect("already sufficient"); let after: Vec = cs.selected_indices().iter().collect(); diff --git a/tests/weight.rs b/tests/weight.rs index 5ad619c..b7e617d 100644 --- a/tests/weight.rs +++ b/tests/weight.rs @@ -1,6 +1,8 @@ #![allow(clippy::zero_prefixed_literal)] -use bdk_coin_select::{Candidate, CoinSelector, Drain, DrainWeights, TargetOutputs}; +use bdk_coin_select::{ + Candidate, CoinSelector, Drain, DrainWeights, Target, TargetFee, TargetOutputs, +}; use bitcoin::{consensus::Decodable, ScriptBuf, Transaction}; fn hex_val(c: u8) -> u8 { @@ -66,7 +68,12 @@ fn segwit_one_input_one_output() { n_outputs: tx.output.len(), }; - let mut coin_selector = CoinSelector::new(&candidates); + let target = Target { + outputs: target_ouputs, + fee: TargetFee::ZERO, + max_weight: None, + }; + let mut coin_selector = CoinSelector::new(&candidates, target); coin_selector.select_all(); assert_eq!( @@ -103,13 +110,17 @@ fn segwit_two_inputs_one_output() { }) .collect::>(); - let mut coin_selector = CoinSelector::new(&candidates); - let target_ouputs = TargetOutputs { value_sum: tx.output.iter().map(|output| output.value.to_sat()).sum(), weight_sum: tx.output.iter().map(|output| output.weight().to_wu()).sum(), n_outputs: tx.output.len(), }; + let target = Target { + outputs: target_ouputs, + fee: TargetFee::ZERO, + max_weight: None, + }; + let mut coin_selector = CoinSelector::new(&candidates, target); coin_selector.select_all(); @@ -153,7 +164,12 @@ fn legacy_three_inputs() { n_outputs: tx.output.len(), }; - let mut coin_selector = CoinSelector::new(&candidates); + let target = Target { + outputs: target_ouputs, + fee: TargetFee::ZERO, + max_weight: None, + }; + let mut coin_selector = CoinSelector::new(&candidates, target); coin_selector.select_all(); assert_eq!( @@ -211,7 +227,12 @@ fn legacy_three_inputs_one_segwit() { n_outputs: tx.output.len(), }; - let mut coin_selector = CoinSelector::new(&candidates); + let target = Target { + outputs: target_ouputs, + fee: TargetFee::ZERO, + max_weight: None, + }; + let mut coin_selector = CoinSelector::new(&candidates, target); coin_selector.select_all(); assert_eq!( @@ -245,7 +266,12 @@ fn legacy_three_inputs_grouped() { n_outputs: tx.output.len(), }; - let mut coin_selector = CoinSelector::new(&candidates); + let target = Target { + outputs: target_ouputs, + fee: TargetFee::ZERO, + max_weight: None, + }; + let mut coin_selector = CoinSelector::new(&candidates, target); coin_selector.select_all(); assert_eq!( @@ -283,7 +309,12 @@ fn legacy_pair_grouped_with_segwit_input() { n_outputs: tx.output.len(), }; - let mut coin_selector = CoinSelector::new(&candidates); + let target = Target { + outputs: target_ouputs, + fee: TargetFee::ZERO, + max_weight: None, + }; + let mut coin_selector = CoinSelector::new(&candidates, target); coin_selector.select_all(); assert_eq!( @@ -315,7 +346,12 @@ fn mixed_group_all_inputs_one_candidate() { n_outputs: tx.output.len(), }; - let mut coin_selector = CoinSelector::new(&candidates); + let target = Target { + outputs: target_ouputs, + fee: TargetFee::ZERO, + max_weight: None, + }; + let mut coin_selector = CoinSelector::new(&candidates, target); coin_selector.select_all(); assert_eq!( From 5f8384ae15ebe220608c3b52c6818cb4164f9615 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=BF=97=E5=AE=87?= Date: Fri, 14 Aug 2026 04:52:22 +0000 Subject: [PATCH 04/26] feat!: introduce SelectionProblem; CoinSelector borrows it Move the fixed target, candidates, and optional ancestor graph into one immutable problem object. CoinSelector now borrows that object, keeping all calculations tied to the same inputs and allowing ancestry metadata to remain separate from Candidate. Provide new_no_ancestors for prebuilt candidates and new for constructing candidates from input groups and their unconfirmed transaction graph. --- README.md | 10 +- benches/coin_selector.rs | 10 +- src/coin_selector.rs | 138 ++++++++-------- src/lib.rs | 2 + src/selection_problem.rs | 348 +++++++++++++++++++++++++++++++++++++++ tests/bnb.rs | 21 ++- tests/changeless.rs | 10 +- tests/common.rs | 11 +- tests/lowest_fee.rs | 48 +++--- tests/srd.rs | 22 ++- tests/weight.rs | 24 ++- 11 files changed, 514 insertions(+), 130 deletions(-) create mode 100644 src/selection_problem.rs diff --git a/README.md b/README.md index 8e723a1..fcc5843 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,7 @@ ```rust use std::str::FromStr; -use bdk_coin_select::{ CoinSelector, Candidate, TR_KEYSPEND_TXIN_WEIGHT, Drain, FeeRate, Target, ChangePolicy, TargetOutputs, TargetFee, DrainWeights}; +use bdk_coin_select::{ CoinSelector, Candidate, SelectionProblem, TR_KEYSPEND_TXIN_WEIGHT, Drain, FeeRate, Target, ChangePolicy, TargetOutputs, TargetFee, DrainWeights}; use bitcoin::{ Amount, Address, Network, Transaction, TxIn, TxOut }; let recipient_addr: Address = "tb1pvjf9t34fznr53u5tqhejz4nr69luzkhlvsdsdfq9pglutrpve2xq7hps46" @@ -53,7 +53,8 @@ let candidates = vec![ ]; // You can now select coins! -let mut coin_selector = CoinSelector::new(&candidates, target); +let problem = SelectionProblem::new_no_ancestors(target, candidates); +let mut coin_selector = CoinSelector::new(&problem); coin_selector.select(0); assert!(!coin_selector.is_funded(), "we didn't select enough"); @@ -88,7 +89,7 @@ metric by implementing the [`BnbMetric`] yourself but we don't recommend this. ```rust use std::str::FromStr; -use bdk_coin_select::{ BnbMetric, Candidate, CoinSelector, FeeRate, Target, TargetFee, TargetOutputs, TR_KEYSPEND_TXIN_WEIGHT}; +use bdk_coin_select::{ BnbMetric, Candidate, CoinSelector, FeeRate, SelectionProblem, Target, TargetFee, TargetOutputs, TR_KEYSPEND_TXIN_WEIGHT}; use bdk_coin_select::metrics::LowestFee; use bitcoin::{ Address, Amount, Network, Transaction, TxIn, TxOut }; @@ -132,7 +133,8 @@ let target = Target { max_weight: None, }; -let mut coin_selector = CoinSelector::new(&candidates, target); +let problem = SelectionProblem::new_no_ancestors(target, candidates); +let mut coin_selector = CoinSelector::new(&problem); // The feerate used to work out whether a change output would be dust (and so shouldn't be added). // The standard dust relay feerate is 3 sat/vb. diff --git a/benches/coin_selector.rs b/benches/coin_selector.rs index c48420e..bac1de1 100644 --- a/benches/coin_selector.rs +++ b/benches/coin_selector.rs @@ -14,8 +14,8 @@ #![allow(clippy::incompatible_msrv)] use bdk_coin_select::{ - metrics::LowestFee, Candidate, CoinSelector, DrainWeights, FeeRate, Target, TargetFee, - TargetOutputs, TR_SPK_WEIGHT, TXIN_BASE_WEIGHT, TXOUT_BASE_WEIGHT, + metrics::LowestFee, Candidate, CoinSelector, DrainWeights, FeeRate, SelectionProblem, Target, + TargetFee, TargetOutputs, TR_SPK_WEIGHT, TXIN_BASE_WEIGHT, TXOUT_BASE_WEIGHT, }; use criterion::{criterion_group, criterion_main, BatchSize, BenchmarkId, Criterion}; use std::hint::black_box; @@ -57,7 +57,8 @@ fn bench_coin_selector_clone(c: &mut Criterion) { for &n in &[64usize, 256, 1024, 4096] { let candidates = make_candidates(n); let (target, _) = make_bnb_inputs(&candidates); - let mut selector = CoinSelector::new(&candidates, target); + let problem = SelectionProblem::new_no_ancestors(target, candidates.iter().copied()); + let mut selector = CoinSelector::new(&problem); // Select ~10% of candidates so `selected` is non-trivial to copy. for i in (0..n).step_by(10) { selector.select(i); @@ -76,7 +77,8 @@ fn bench_run_bnb_lowest_fee(c: &mut Criterion) { for &n in &[20usize, 50, 100, 200] { let candidates = make_candidates(n); let (target, long_term_feerate) = make_bnb_inputs(&candidates); - let selector = CoinSelector::new(&candidates, target); + let problem_2 = SelectionProblem::new_no_ancestors(target, candidates.iter().copied()); + let selector = CoinSelector::new(&problem_2); group.bench_with_input(BenchmarkId::from_parameter(n), &n, |b, _| { b.iter_batched( || selector.clone(), diff --git a/src/coin_selector.rs b/src/coin_selector.rs index c51d016..8ccc5a2 100644 --- a/src/coin_selector.rs +++ b/src/coin_selector.rs @@ -1,7 +1,9 @@ use super::*; #[allow(unused)] // some bug in <= 1.48.0 sees this as unused when it isn't use crate::float::FloatExt; -use crate::{bitset::Bitset, bnb::BnbMetric, float::Ordf32, ChangePolicy, FeeRate, Target}; +use crate::{ + bitset::Bitset, bnb::BnbMetric, float::Ordf32, ChangePolicy, FeeRate, SelectionProblem, Target, +}; use alloc::{sync::Arc, vec::Vec}; /// The minimum change amount Bitcoin Core's `SelectCoinsSRD` targets; a sensible default for the @@ -17,41 +19,40 @@ pub const CHANGE_LOWER: u64 = 50_000; /// [`bnb_solutions`]: CoinSelector::bnb_solutions #[derive(Debug, Clone)] pub struct CoinSelector<'a> { - candidates: &'a [Candidate], - target: Target, + problem: &'a SelectionProblem, selected: Bitset, banned: Bitset, candidate_order: Arc>, } impl<'a> CoinSelector<'a> { - /// Creates a new coin selector from some candidate inputs and a `base_weight`. + /// Creates a new coin selector for `problem`. /// - /// The `base_weight` is the weight of the transaction without any inputs and without a change - /// output. + /// The [`SelectionProblem`] is fixed for the life of the selector: target, candidates, and any + /// ancestor-bump data. Methods refer to candidates by index into + /// [`SelectionProblem::candidates`]. /// /// The `CoinSelector` does not keep track of the final transaction's output count. The caller /// is responsible for including the potential output-count varint weight change in the /// corresponding [`DrainWeights`]. - /// - /// Note that methods in `CoinSelector` will refer to inputs by the index in the `candidates` - /// slice you pass in. - /// - /// `target` is fixed for the life of the selector. Everything it reports is measured against - /// that one target. - pub fn new(candidates: &'a [Candidate], target: Target) -> Self { + pub fn new(problem: &'a SelectionProblem) -> Self { + let n = problem.len(); Self { - candidates, - target, - selected: Bitset::with_capacity(candidates.len()), - banned: Bitset::with_capacity(candidates.len()), - candidate_order: Arc::new((0..candidates.len()).collect::>()), + problem, + selected: Bitset::with_capacity(n), + banned: Bitset::with_capacity(n), + candidate_order: Arc::new((0..n).collect::>()), } } /// What this selector is funding. pub fn target(&self) -> Target { - self.target + self.problem.target() + } + + /// The selection problem this selector is solving. + pub fn problem(&self) -> &'a SelectionProblem { + self.problem } /// Iterate over all the candidates in their currently sorted order. Each item has the original @@ -59,19 +60,18 @@ impl<'a> CoinSelector<'a> { pub fn candidates( &self, ) -> impl DoubleEndedIterator + ExactSizeIterator + '_ { - self.candidate_order - .iter() - .map(move |i| (*i, self.candidates[*i])) + let cands = self.problem.candidates(); + self.candidate_order.iter().map(move |i| (*i, cands[*i])) } - /// Get the candidate at `index`. `index` refers to its position in the original `candidates` - /// slice passed into [`CoinSelector::new`]. + /// Get the candidate at `index`. `index` refers to its position in + /// [`SelectionProblem::candidates`]. pub fn candidate(&self, index: usize) -> Candidate { - self.candidates[index] + self.problem.candidate(index) } /// Deselect a candidate at `index`. `index` refers to its position in the original `candidates` - /// slice passed into [`CoinSelector::new`]. + /// slice of [`SelectionProblem::candidates`]. pub fn deselect(&mut self, index: usize) -> bool { self.selected.remove(index) } @@ -84,9 +84,9 @@ impl<'a> CoinSelector<'a> { } /// Select the input at `index`. `index` refers to its position in the original `candidates` - /// slice passed into [`CoinSelector::new`]. + /// slice of [`SelectionProblem::candidates`]. pub fn select(&mut self, index: usize) -> bool { - assert!(index < self.candidates.len()); + assert!(index < self.problem.len()); self.selected.insert(index) } @@ -104,7 +104,7 @@ impl<'a> CoinSelector<'a> { /// Ban an input from being selected. Banning the input means it won't show up in [`unselected`] /// or [`unselected_indices`]. Note it can still be manually selected. /// - /// `index` refers to its position in the original `candidates` slice passed into [`CoinSelector::new`]. + /// `index` refers to its position in the original `candidates` slice of [`SelectionProblem::candidates`]. /// /// [`unselected`]: Self::unselected /// [`unselected_indices`]: Self::unselected_indices @@ -120,7 +120,7 @@ impl<'a> CoinSelector<'a> { } /// Is the input at `index` selected. `index` refers to its position in the original - /// `candidates` slice passed into [`CoinSelector::new`]. + /// `candidates` slice of [`SelectionProblem::candidates`]. pub fn is_selected(&self, index: usize) -> bool { self.selected.contains(index) } @@ -140,7 +140,7 @@ impl<'a> CoinSelector<'a> { /// [`select_until_target_met`]: Self::select_until_target_met pub fn is_fundable(&self) -> bool { let mut test = self.clone(); - test.select_all_effective(self.target.fee.rate); + test.select_all_effective(self.target().fee.rate); test.is_funded() } @@ -181,7 +181,7 @@ impl<'a> CoinSelector<'a> { pub fn selected_value(&self) -> u64 { self.selected .iter() - .map(|index| self.candidates[index].value) + .map(|index| self.problem.candidates()[index].value) .sum() } @@ -205,7 +205,7 @@ impl<'a> CoinSelector<'a> { .min(self.replacement_excess(drain)) } - /// How much extra value needs to be selected to reach the self.target. + /// How much extra value needs to be selected to reach the self.target(). pub fn missing(&self) -> u64 { let excess = self.excess(Drain::NONE); if excess < 0 { @@ -215,42 +215,42 @@ impl<'a> CoinSelector<'a> { } } - /// How much the current selection overshoots the value need to satisfy `self.target.fee.rate` and - /// `self.target.value` (while ignoring `self.target.fee.absolute`). + /// How much the current selection overshoots the value need to satisfy `self.target().fee.rate` and + /// `self.target().value` (while ignoring `self.target().fee.absolute`). pub fn rate_excess(&self, drain: Drain) -> i64 { self.selected_value() as i64 - - self.target.value() as i64 + - self.target().value() as i64 - drain.value as i64 - self.implied_fee_from_feerate(drain.weights) as i64 } - /// Same as [rate_excess](Self::rate_excess) except `self.target.fee.rate` is applied to the + /// Same as [rate_excess](Self::rate_excess) except `self.target().fee.rate` is applied to the /// implied transaction's weight units directly without any conversion to vbytes. pub fn rate_excess_wu(&self, drain: Drain) -> i64 { self.selected_value() as i64 - - self.target.value() as i64 + - self.target().value() as i64 - drain.value as i64 - self.implied_fee_from_feerate_wu(drain.weights) as i64 } - /// How much the current selection overshoots the value needed to satisfy `self.target.fee.absolute` - /// and `self.target.value` (while ignoring `self.target.fee.rate`). + /// How much the current selection overshoots the value needed to satisfy `self.target().fee.absolute` + /// and `self.target().value` (while ignoring `self.target().fee.rate`). pub fn absolute_excess(&self, drain: Drain) -> i64 { self.selected_value() as i64 - - self.target.value() as i64 + - self.target().value() as i64 - drain.value as i64 - - self.target.fee.absolute as i64 + - self.target().fee.absolute as i64 } /// How much the current selection overshoots the value needed to satisfy RBF's rule 4. pub fn replacement_excess(&self, drain: Drain) -> i64 { let mut replacement_excess_needed = 0; - if let Some(replace) = self.target.fee.replace { + if let Some(replace) = self.target().fee.replace { replacement_excess_needed = - replace.min_fee_to_do_replacement(self.weight(self.target.outputs, drain.weights)) + replace.min_fee_to_do_replacement(self.weight(self.target().outputs, drain.weights)) } self.selected_value() as i64 - - self.target.value() as i64 + - self.target().value() as i64 - drain.value as i64 - replacement_excess_needed as i64 } @@ -259,12 +259,12 @@ impl<'a> CoinSelector<'a> { /// is calculated using weight units directly without any conversion to vbytes. pub fn replacement_excess_wu(&self, drain: Drain) -> i64 { let mut replacement_excess_needed = 0; - if let Some(replace) = self.target.fee.replace { + if let Some(replace) = self.target().fee.replace { replacement_excess_needed = replace - .min_fee_to_do_replacement_wu(self.weight(self.target.outputs, drain.weights)) + .min_fee_to_do_replacement_wu(self.weight(self.target().outputs, drain.weights)) } self.selected_value() as i64 - - self.target.value() as i64 + - self.target().value() as i64 - drain.value as i64 - replacement_excess_needed as i64 } @@ -292,12 +292,13 @@ impl<'a> CoinSelector<'a> { pub fn implied_fee(&self, drain_weights: DrainWeights) -> u64 { let mut implied_fee = self .implied_fee_from_feerate(drain_weights) - .max(self.target.fee.absolute); + .max(self.target().fee.absolute); - if let Some(replace) = self.target.fee.replace { + if let Some(replace) = self.target().fee.replace { implied_fee = Ord::max( implied_fee, - replace.min_fee_to_do_replacement(self.weight(self.target.outputs, drain_weights)), + replace + .min_fee_to_do_replacement(self.weight(self.target().outputs, drain_weights)), ); } @@ -305,17 +306,17 @@ impl<'a> CoinSelector<'a> { } fn implied_fee_from_feerate(&self, drain_weights: DrainWeights) -> u64 { - self.target + self.target() .fee .rate - .implied_fee(self.weight(self.target.outputs, drain_weights)) + .implied_fee(self.weight(self.target().outputs, drain_weights)) } fn implied_fee_from_feerate_wu(&self, drain_weights: DrainWeights) -> u64 { - self.target + self.target() .fee .rate - .implied_fee_wu(self.weight(self.target.outputs, drain_weights)) + .implied_fee_wu(self.weight(self.target().outputs, drain_weights)) } /// The actual fee the selection would pay if it was used in a transaction that had @@ -349,7 +350,7 @@ impl<'a> CoinSelector<'a> { where F: FnMut((usize, Candidate), (usize, Candidate)) -> core::cmp::Ordering, { - let candidates = &self.candidates; + let candidates = self.problem.candidates(); Arc::make_mut(&mut self.candidate_order) .sort_by(|a, b| cmp((*a, candidates[*a]), (*b, candidates[*b]))) } @@ -396,7 +397,7 @@ impl<'a> CoinSelector<'a> { /// [waste metric]: https://bitcoin.stackexchange.com/questions/113622/what-does-waste-metric-mean-in-the-context-of-coin-selection pub fn waste(&self, long_term_feerate: FeeRate, drain: Drain, excess_discount: f32) -> f32 { debug_assert!((0.0..=1.0).contains(&excess_discount)); - let mut waste = self.input_waste(self.target.fee.rate, long_term_feerate); + let mut waste = self.input_waste(self.target().fee.rate, long_term_feerate); if drain.is_none() { // We don't allow negative excess waste since negative excess just means you haven't @@ -408,9 +409,9 @@ impl<'a> CoinSelector<'a> { waste += excess_waste; } else { waste += drain.weights.waste( - self.target.fee.rate, + self.target().fee.rate, long_term_feerate, - self.target.outputs.n_outputs, + self.target().outputs.n_outputs, ); } @@ -421,9 +422,8 @@ impl<'a> CoinSelector<'a> { pub fn selected( &self, ) -> impl ExactSizeIterator + DoubleEndedIterator + '_ { - self.selected - .iter() - .map(move |index| (index, self.candidates[index])) + let cands = self.problem.candidates(); + self.selected.iter().map(move |index| (index, cands[index])) } /// The unselected candidates with their index. @@ -432,8 +432,8 @@ impl<'a> CoinSelector<'a> { /// /// [`sort_candidates_by`]: Self::sort_candidates_by pub fn unselected(&self) -> impl DoubleEndedIterator + '_ { - self.unselected_indices() - .map(move |i| (i, self.candidates[i])) + let cands = self.problem.candidates(); + self.unselected_indices().map(move |i| (i, cands[i])) } /// The weight of the lightest unselected (addable) candidate, or `None` when nothing is left to @@ -476,8 +476,8 @@ impl<'a> CoinSelector<'a> { /// feasibility (adding inputs adds weight), so it is kept separate from the monotone /// value-only [`is_funded`](Self::is_funded). pub fn is_within_max_weight(&self, drain_weights: DrainWeights) -> bool { - match self.target.max_weight { - Some(max_weight) => self.weight(self.target.outputs, drain_weights) <= max_weight, + match self.target().max_weight { + Some(max_weight) => self.weight(self.target().outputs, drain_weights) <= max_weight, None => true, } } @@ -566,7 +566,7 @@ impl<'a> CoinSelector<'a> { let cand_index = self.candidate_order[i]; if self.selected.contains(cand_index) || self.banned.contains(cand_index) - || self.candidates[cand_index].effective_value(feerate) <= 0.0 + || self.problem.candidates()[cand_index].effective_value(feerate) <= 0.0 { continue; } @@ -621,7 +621,7 @@ impl<'a> CoinSelector<'a> { /// The change *amount* comes out random on its own: because candidates are added in random order /// and we stop as soon as the change reaches `change_lower`, the final change is wherever the /// last (random) input pushed it — at or above `change_lower`. So, like Core, we use a fixed - /// lower bound rather than randomizing the self.target. + /// lower bound rather than randomizing the self.target(). /// /// On success it returns the [`Drain`] to attach, whose value is the achieved change (at least /// `change_lower`). Returns [`SelectError::InsufficientFunds`] if the target plus `change_lower` @@ -629,7 +629,7 @@ impl<'a> CoinSelector<'a> { /// met but the resulting selection exceeds the weight cap. /// /// `rng` shuffles the candidates; it yields uniform `u64`s, e.g. `|| my_rng.next_u64()`. Any - /// already-selected candidates are kept and counted toward the self.target. + /// already-selected candidates are kept and counted toward the self.target(). /// /// [`run_bnb`]: Self::run_bnb /// [`LowestFee`]: crate::metrics::LowestFee diff --git a/src/lib.rs b/src/lib.rs index 34c86ad..77bb5dd 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -28,6 +28,8 @@ mod target; pub use target::*; mod drain; pub use drain::*; +mod selection_problem; +pub use selection_problem::*; /// Txin "base" fields include `outpoint` (32+4) and `nSequence` (4) and 1 byte for the scriptSig /// length. diff --git a/src/selection_problem.rs b/src/selection_problem.rs new file mode 100644 index 0000000..0050168 --- /dev/null +++ b/src/selection_problem.rs @@ -0,0 +1,348 @@ +use alloc::collections::BTreeMap; +use alloc::vec::Vec; + +use crate::bitset::Bitset; +use crate::{Candidate, CoinSelector, Target}; + +/// An unconfirmed ancestor that may need bumping to the target feerate (CPFP). +/// +/// `Txid` is whatever the caller keys transactions by. This crate has no `bitcoin` dependency. +#[derive(Debug, Clone)] +pub struct AncestorToBump { + /// Caller-chosen id for this transaction. + pub txid: Txid, + /// Weight of this transaction in weight units. + pub weight: u64, + /// Fee this transaction already pays, in satoshis. + pub fee: u64, + /// Direct parents only; transitive ancestors are derived when building a [`SelectionProblem`]. + pub parents: Vec, +} + +/// One or more UTXOs that must be spent together, described on their own terms. +/// +/// Everything here is intrinsic to the coins. Hand these to [`SelectionProblem::new`], which pairs +/// each group with the ancestors it drags in. +pub type InputGroup = Vec>; + +/// A single UTXO, before it is folded into a [`Candidate`]. +#[derive(Debug, Clone, Copy)] +pub struct Input { + /// Value of the UTXO in satoshis. + pub value: u64, + /// Input weight as for [`Candidate::weight`] (legacy inputs omit the empty-witness byte). + pub weight: u64, + /// Whether this input is segwit. + pub is_segwit: bool, + /// Transaction that created this UTXO (may be unconfirmed). + pub residing_txid: Txid, +} + +impl From> for InputGroup { + fn from(input: Input) -> Self { + alloc::vec![input] + } +} + +/// Target, candidates, and (optional) ancestor-bump data for one coin-selection run. +/// +/// Build with [`SelectionProblem::new_no_ancestors`] when nothing is unconfirmed, or +/// [`SelectionProblem::new`] when spending unconfirmed UTXOs. Pass a reference to +/// [`CoinSelector::new`]. +/// +/// Ancestor bump figures are stored here (not on [`Candidate`]) so candidates stay a plain +/// description of inputs. They are not yet folded into fee/excess calculations; that is a +/// follow-up. Unknown parent ids are treated as confirmed and ignored. There is no mempool +/// "mine" step — deficits are computed against the full ancestor set and may overestimate +/// what Bitcoin Core would charge. +#[derive(Debug, Clone)] +pub struct SelectionProblem { + target: Target, + candidates: Vec, + /// Weight and fee of each ancestor, after txids are dropped. + ancestors: Vec<(u64, u64)>, + /// Per-candidate set of ancestor indices dragged in by selecting that candidate. + drags_in: Vec, + /// Per-candidate local bump fee (sats) at [`Target::fee`](crate::TargetFee)'s rate. + local_bump: Vec, +} + +impl SelectionProblem { + /// A problem with no unconfirmed ancestors. + /// + /// `candidates` are taken as-is. + pub fn new_no_ancestors( + target: Target, + candidates: impl IntoIterator, + ) -> Self { + let candidates: Vec = candidates.into_iter().collect(); + let n = candidates.len(); + Self { + target, + candidates, + ancestors: Vec::new(), + drags_in: (0..n).map(|_| Bitset::with_capacity(0)).collect(), + local_bump: alloc::vec![0; n], + } + } + + /// Build candidates from input groups and the unconfirmed ancestors they may drag in. + /// + /// For each input group, the residing txids and their transitive parents (restricted to + /// `ancestors_to_bump`) form that candidate's `drags_in` set. `local_bump` is the fee still + /// owed so those ancestors meet `target.fee.rate`, as if this were the only selected + /// candidate. + pub fn new(target: Target, input_groups: G, ancestors_to_bump: A) -> Self + where + Txid: Copy + Ord + Eq, + G: IntoIterator, + G::Item: Into>, + A: IntoIterator, + A::Item: Into>, + { + let ancestors: Vec> = + ancestors_to_bump.into_iter().map(Into::into).collect(); + + let txid_to_anc: BTreeMap = ancestors + .iter() + .enumerate() + .map(|(i, a)| (a.txid, i)) + .collect(); + + let n_anc = ancestors.len(); + let mut candidates = Vec::new(); + let mut drags_in = Vec::new(); + let mut local_bump = Vec::new(); + + for input_group in input_groups { + let mut cand = Candidate { + value: 0, + weight: 0, + segwit_count: 0, + legacy_count: 0, + }; + let mut dragged = Bitset::with_capacity(n_anc); + + for input in input_group.into() { + cand.value += input.value; + cand.weight += input.weight; + match input.is_segwit { + true => cand.segwit_count += 1, + false => cand.legacy_count += 1, + } + + let mut txid_stack = alloc::vec![input.residing_txid]; + while let Some(txid) = txid_stack.pop() { + if let Some(&anc_i) = txid_to_anc.get(&txid) { + if dragged.insert(anc_i) { + txid_stack.extend(ancestors[anc_i].parents.iter().copied()); + } + } + } + } + + let (w, f) = dragged.iter().fold((0_u64, 0_u64), |(w, f), anc_i| { + let a = &ancestors[anc_i]; + (w + a.weight, f + a.fee) + }); + let bump = target.fee.rate.implied_fee_wu(w).saturating_sub(f); + + candidates.push(cand); + drags_in.push(dragged); + local_bump.push(bump); + } + + Self { + target, + candidates, + ancestors: ancestors.into_iter().map(|a| (a.weight, a.fee)).collect(), + drags_in, + local_bump, + } + } + + /// What this problem is funding. + pub fn target(&self) -> Target { + self.target + } + + /// All candidates, in construction order. + pub fn candidates(&self) -> &[Candidate] { + &self.candidates + } + + /// Candidate at `index`. + pub fn candidate(&self, index: usize) -> Candidate { + self.candidates[index] + } + + /// Number of candidates. + pub fn len(&self) -> usize { + self.candidates.len() + } + + /// Whether there are no candidates. + pub fn is_empty(&self) -> bool { + self.candidates.is_empty() + } + + /// Ancestor units as `(weight, fee)` pairs. + pub fn ancestors(&self) -> &[(u64, u64)] { + &self.ancestors + } + + /// Ancestor indices dragged in by selecting candidate `index`. + pub fn drags_in(&self, index: usize) -> &Bitset { + &self.drags_in[index] + } + + /// Local (per-candidate) bump fee for candidate `index`, in satoshis. + pub fn local_bump(&self, index: usize) -> u64 { + self.local_bump[index] + } + + /// A [`CoinSelector`] over this problem. + pub fn selector(&self) -> CoinSelector<'_> { + CoinSelector::new(self) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::{FeeRate, TargetFee, TargetOutputs}; + + fn target(feerate_sat_vb: f32) -> Target { + Target { + fee: TargetFee { + rate: FeeRate::from_sat_per_vb(feerate_sat_vb), + absolute: 0, + replace: None, + }, + outputs: TargetOutputs { + value_sum: 0, + weight_sum: 0, + n_outputs: 0, + }, + max_weight: None, + } + } + + #[test] + fn no_ancestors_round_trip() { + let cands = [ + Candidate::new_segwit(100_000, 100), + Candidate::new_legacy(50_000, 200), + ]; + let p = SelectionProblem::new_no_ancestors(target(10.0), cands); + assert_eq!(p.len(), 2); + assert_eq!(p.candidate(0).value, 100_000); + assert_eq!(p.candidate(1).legacy_count, 1); + assert!(p.ancestors().is_empty()); + assert_eq!(p.local_bump(0), 0); + assert_eq!(p.local_bump(1), 0); + assert!(p.drags_in(0).is_empty()); + } + + #[test] + fn transitive_parents() { + // UTXO on C; C parents B; B parents A. All unconfirmed. + let ancestors = [ + AncestorToBump { + txid: "A", + weight: 400, + fee: 0, + parents: vec![], + }, + AncestorToBump { + txid: "B", + weight: 400, + fee: 0, + parents: vec!["A"], + }, + AncestorToBump { + txid: "C", + weight: 400, + fee: 0, + parents: vec!["B"], + }, + ]; + let inputs = [Input { + value: 10_000, + weight: 272, + is_segwit: true, + residing_txid: "C", + }]; + let p = SelectionProblem::new(target(10.0), inputs, ancestors); + assert_eq!(p.len(), 1); + let dragged: Vec<_> = p.drags_in(0).iter().collect(); + assert_eq!(dragged, vec![0, 1, 2]); // A, B, C + } + + #[test] + fn shared_ancestor_in_both_drags_in() { + let ancestors = [AncestorToBump { + txid: "P", + weight: 1_000, + fee: 0, + parents: vec![], + }]; + let inputs = [ + Input { + value: 10_000, + weight: 272, + is_segwit: true, + residing_txid: "P", + }, + Input { + value: 20_000, + weight: 272, + is_segwit: true, + residing_txid: "P", + }, + ]; + let p = SelectionProblem::new(target(10.0), inputs, ancestors); + assert!(p.drags_in(0).contains(0)); + assert!(p.drags_in(1).contains(0)); + assert_eq!(p.local_bump(0), p.local_bump(1)); + assert!(p.local_bump(0) > 0); + } + + #[test] + fn overpaying_ancestor_zero_bump() { + // weight 400 wu at 1 sat/vb => ~100 sats implied; fee already 10_000 + let ancestors = [AncestorToBump { + txid: "P", + weight: 400, + fee: 10_000, + parents: vec![], + }]; + let inputs = [Input { + value: 10_000, + weight: 272, + is_segwit: true, + residing_txid: "P", + }]; + let p = SelectionProblem::new(target(1.0), inputs, ancestors); + assert_eq!(p.local_bump(0), 0); + } + + #[test] + fn unknown_parent_ignored() { + let ancestors = [AncestorToBump { + txid: "child", + weight: 400, + fee: 0, + parents: vec!["confirmed_parent"], + }]; + let inputs = [Input { + value: 10_000, + weight: 272, + is_segwit: true, + residing_txid: "child", + }]; + let p = SelectionProblem::new(target(10.0), inputs, ancestors); + let dragged: Vec<_> = p.drags_in(0).iter().collect(); + assert_eq!(dragged, vec![0]); // only child + } +} diff --git a/tests/bnb.rs b/tests/bnb.rs index 7a0b668..2604dbb 100644 --- a/tests/bnb.rs +++ b/tests/bnb.rs @@ -1,6 +1,7 @@ mod common; use bdk_coin_select::{ - float::Ordf32, BnbMetric, Candidate, CoinSelector, Drain, Target, TargetFee, TargetOutputs, + float::Ordf32, BnbMetric, Candidate, CoinSelector, Drain, SelectionProblem, Target, TargetFee, + TargetOutputs, }; #[macro_use] extern crate alloc; @@ -83,12 +84,14 @@ fn bnb_finds_an_exact_solution_in_n_iter() { }; let solution_weight = { - let mut cs = CoinSelector::new(&solution, target); + let problem = SelectionProblem::new_no_ancestors(target, solution.iter().copied()); + let mut cs = CoinSelector::new(&problem); cs.select_all(); cs.input_weight() }; - let cs = CoinSelector::new(&candidates, target); + let problem_2 = SelectionProblem::new_no_ancestors(target, candidates.iter().copied()); + let cs = CoinSelector::new(&problem_2); let solutions = cs.bnb_solutions(MinExcessThenWeight); let mut rounds = 0; @@ -122,7 +125,8 @@ fn bnb_finds_solution_if_possible_in_n_iter() { max_weight: None, }; - let cs = CoinSelector::new(&candidates, target); + let problem_3 = SelectionProblem::new_no_ancestors(target, candidates.iter().copied()); + let cs = CoinSelector::new(&problem_3); let solutions = cs.bnb_solutions(MinExcessThenWeight); let mut rounds = 0; @@ -151,7 +155,8 @@ proptest! { fee: TargetFee::ZERO, max_weight: None, }; - let cs = CoinSelector::new(&candidates, target); + let problem_4 = SelectionProblem::new_no_ancestors(target, candidates.iter().copied()); + let cs = CoinSelector::new(&problem_4); let solutions = cs.bnb_solutions(MinExcessThenWeight); match solutions.enumerate().filter_map(|(i, sol)| Some((i, sol?))).last() { @@ -184,12 +189,14 @@ proptest! { }; let solution_weight = { - let mut cs = CoinSelector::new(&solution, target); + let problem_5 = SelectionProblem::new_no_ancestors(target, solution.iter().copied()); + let mut cs = CoinSelector::new(&problem_5); cs.select_all(); cs.input_weight() }; - let mut cs = CoinSelector::new(&candidates, target); + let problem_6 = SelectionProblem::new_no_ancestors(target, candidates.iter().copied()); + let mut cs = CoinSelector::new(&problem_6); for i in 0..num_preselected.min(solution_len) { cs.select(i); } diff --git a/tests/changeless.rs b/tests/changeless.rs index c92ee08..d25f10f 100644 --- a/tests/changeless.rs +++ b/tests/changeless.rs @@ -3,7 +3,8 @@ mod common; use bdk_coin_select::{ float::Ordf32, metrics::{Changeless, LowestFee}, - Candidate, CoinSelector, DrainWeights, FeeRate, Target, TargetFee, TargetOutputs, + Candidate, CoinSelector, DrainWeights, FeeRate, SelectionProblem, Target, TargetFee, + TargetOutputs, }; use proptest::{prelude::*, proptest, test_runner::*}; use rand::{prelude::IteratorRandom, Rng, RngCore}; @@ -21,9 +22,7 @@ fn test_wv(mut rng: impl RngCore) -> impl Iterator { } proptest! { - #![proptest_config(ProptestConfig { - ..Default::default() - })] + #![proptest_config(ProptestConfig::default())] #[test] #[cfg(not(debug_assertions))] // too slow if compiling for debug @@ -67,7 +66,8 @@ proptest! { }, max_weight: None, }; - let cs = CoinSelector::new(&candidates, target); + let problem = SelectionProblem::new_no_ancestors(target, candidates.iter().copied()); + let cs = CoinSelector::new(&problem); let make_metric = || { Changeless(LowestFee { diff --git a/tests/common.rs b/tests/common.rs index 1b35784..3977851 100644 --- a/tests/common.rs +++ b/tests/common.rs @@ -2,7 +2,7 @@ use bdk_coin_select::{ float::Ordf32, metrics::LowestFee, BnbMetric, Candidate, CoinSelector, Drain, DrainWeights, - FeeRate, NoBnbSolution, Replace, Target, TargetFee, TargetOutputs, + FeeRate, NoBnbSolution, Replace, SelectionProblem, Target, TargetFee, TargetOutputs, }; use proptest::{ prelude::*, @@ -51,7 +51,8 @@ where let target = params.target(); - let mut selection = CoinSelector::new(&candidates, target); + let problem = SelectionProblem::new_no_ancestors(target, candidates.iter().copied()); + let mut selection = CoinSelector::new(&problem); let mut exp_selection = selection.clone(); if metric.requires_ordering_by_descending_value_pwu() { @@ -147,8 +148,9 @@ where let target = params.target(); + let problem_2 = SelectionProblem::new_no_ancestors(target, candidates.iter().copied()); let init_cs = { - let mut cs = CoinSelector::new(&candidates, target); + let mut cs = CoinSelector::new(&problem_2); if metric.requires_ordering_by_descending_value_pwu() { cs.sort_candidates_by_descending_value_pwu(); } @@ -448,7 +450,8 @@ pub fn compare_against_benchmarks( let start = std::time::Instant::now(); let mut rng = TestRng::deterministic_rng(RngAlgorithm::ChaCha); let target = params.target(); - let cs = CoinSelector::new(&candidates, target); + let problem_3 = SelectionProblem::new_no_ancestors(target, candidates.iter().copied()); + let cs = CoinSelector::new(&problem_3); let solutions = cs.bnb_solutions(metric.clone()); let best = solutions diff --git a/tests/lowest_fee.rs b/tests/lowest_fee.rs index d80a6f6..b2e509c 100644 --- a/tests/lowest_fee.rs +++ b/tests/lowest_fee.rs @@ -3,14 +3,12 @@ mod common; use bdk_coin_select::metrics::{Changeless, LowestFee}; use bdk_coin_select::{ BnbMetric, Candidate, ChangePolicy, CoinSelector, Drain, DrainWeights, FeeRate, NoBnbSolution, - Replace, Target, TargetFee, TargetOutputs, TX_FIXED_FIELD_WEIGHT, + Replace, SelectionProblem, Target, TargetFee, TargetOutputs, TX_FIXED_FIELD_WEIGHT, }; use proptest::prelude::*; proptest! { - #![proptest_config(ProptestConfig { - ..Default::default() - })] + #![proptest_config(ProptestConfig::default())] #[test] #[cfg(not(debug_assertions))] // too slow if compiling for debug @@ -90,7 +88,8 @@ proptest! { params.n_candidates ]; - let mut cs = CoinSelector::new(&candidates, params.target()); + let problem = SelectionProblem::new_no_ancestors(params.target(), candidates.iter().copied()); + let mut cs = CoinSelector::new(&problem); let metric = params.lowest_fee_metric(); let is_impossible = !cs.is_fundable(); @@ -161,9 +160,11 @@ proptest! { let target = params.target(); let metric = params.lowest_fee_metric(); - let exact_possible = common::exact_selection_possible(&CoinSelector::new(&candidates, target)); + let problem_2 = SelectionProblem::new_no_ancestors(target, candidates.iter().copied()); + let exact_possible = common::exact_selection_possible(&CoinSelector::new(&problem_2)); - let mut cs = CoinSelector::new(&candidates, target); + let problem_3 = SelectionProblem::new_no_ancestors(target, candidates.iter().copied()); + let mut cs = CoinSelector::new(&problem_3); let bnb_found = common::bnb_search(&mut cs, metric, usize::MAX).is_ok(); prop_assert_eq!( bnb_found, exact_possible, @@ -195,8 +196,10 @@ fn combined_changeless_metric() { let candidates = common::gen_candidates(params.n_candidates); let target = params.target(); - let mut cs_a = CoinSelector::new(&candidates, target); - let mut cs_b = CoinSelector::new(&candidates, target); + let problem_4 = SelectionProblem::new_no_ancestors(target, candidates.iter().copied()); + let mut cs_a = CoinSelector::new(&problem_4); + let problem_5 = SelectionProblem::new_no_ancestors(target, candidates.iter().copied()); + let mut cs_b = CoinSelector::new(&problem_5); let metric_lowest_fee = params.lowest_fee_metric(); let metric_changeless = Changeless(params.lowest_fee_metric()); @@ -231,7 +234,7 @@ fn does_not_create_change_below_spend_cost() { max_weight: None, }; - let candidates = vec![ + let candidates = [ Candidate { value: 100_000, weight: 100, @@ -253,7 +256,8 @@ fn does_not_create_change_below_spend_cost() { }, ]; - let mut cs = CoinSelector::new(&candidates, target); + let problem_6 = SelectionProblem::new_no_ancestors(target, candidates.iter().copied()); + let mut cs = CoinSelector::new(&problem_6); let drain_weights = DrainWeights { output_weight: 100, @@ -270,11 +274,9 @@ fn does_not_create_change_below_spend_cost() { let (score, _) = common::bnb_search(&mut cs, metric, 10).expect("finds solution"); // The optimal selection is candidate 0 alone, and it must be changeless. - let expected = { - let mut expected = CoinSelector::new(&candidates, target); - expected.select(0); - expected - }; + let problem_7 = SelectionProblem::new_no_ancestors(target, candidates.iter().copied()); + let mut expected = CoinSelector::new(&problem_7); + expected.select(0); assert_eq!(cs.selected_indices(), expected.selected_indices()); assert!( metric.drain(&cs).is_none(), @@ -309,7 +311,7 @@ fn zero_fee_tx() { max_weight: None, }; - let candidates = vec![ + let candidates = [ Candidate { value: 100_000, weight: 100, @@ -330,7 +332,8 @@ fn zero_fee_tx() { n_outputs: 1, }; - let mut cs = CoinSelector::new(&candidates, target); + let problem_8 = SelectionProblem::new_no_ancestors(target, candidates.iter().copied()); + let mut cs = CoinSelector::new(&problem_8); let metric = LowestFee { long_term_feerate, dust_relay_feerate: FeeRate::from_sat_per_vb(1.0), @@ -375,7 +378,8 @@ fn run_bnb_reports_insufficient_funds() { fee: TargetFee::ZERO, max_weight: None, }; - let mut cs = CoinSelector::new(&candidates, target); + let problem_9 = SelectionProblem::new_no_ancestors(target, candidates.iter().copied()); + let mut cs = CoinSelector::new(&problem_9); assert_eq!( cs.run_bnb(err_metric(), 100_000).unwrap_err(), NoBnbSolution::InsufficientFunds, @@ -396,7 +400,8 @@ fn run_bnb_reports_max_weight_exceeded() { fee: TargetFee::ZERO, max_weight: Some(1), }; - let mut cs = CoinSelector::new(&candidates, target); + let problem_10 = SelectionProblem::new_no_ancestors(target, candidates.iter().copied()); + let mut cs = CoinSelector::new(&problem_10); assert_eq!( cs.run_bnb(err_metric(), 100_000).unwrap_err(), NoBnbSolution::MaxWeightExceeded, @@ -416,7 +421,8 @@ fn run_bnb_reports_round_limit() { fee: TargetFee::ZERO, max_weight: None, }; - let mut cs = CoinSelector::new(&candidates, target); + let problem_11 = SelectionProblem::new_no_ancestors(target, candidates.iter().copied()); + let mut cs = CoinSelector::new(&problem_11); assert_eq!( cs.run_bnb(err_metric(), 0).unwrap_err(), NoBnbSolution::RoundLimit { diff --git a/tests/srd.rs b/tests/srd.rs index 4f93204..3d8cadc 100644 --- a/tests/srd.rs +++ b/tests/srd.rs @@ -2,8 +2,8 @@ mod common; use bdk_coin_select::{ - Candidate, CoinSelector, Drain, DrainWeights, FeeRate, SelectError, Target, TargetFee, - TargetOutputs, CHANGE_LOWER, TR_SPK_WEIGHT, TXOUT_BASE_WEIGHT, + Candidate, CoinSelector, Drain, DrainWeights, FeeRate, SelectError, SelectionProblem, Target, + TargetFee, TargetOutputs, CHANGE_LOWER, TR_SPK_WEIGHT, TXOUT_BASE_WEIGHT, }; /// Deterministic, dependency-free `u64` source (SplitMix64) so we can drive `select_srd` without a @@ -36,7 +36,8 @@ fn srd_success_yields_healthy_change_that_meets_target() { let mut successes = 0; for seed in 0..300u64 { - let mut cs = CoinSelector::new(&candidates, target); + let problem = SelectionProblem::new_no_ancestors(target, candidates.iter().copied()); + let mut cs = CoinSelector::new(&problem); let result = cs.select_srd(drain_weights, CHANGE_LOWER, splitmix64(seed)); if let Ok(drain) = result { @@ -68,7 +69,7 @@ fn srd_success_yields_healthy_change_that_meets_target() { #[test] fn srd_insufficient_funds() { // 3 * 50_000 = 150_000 total, well below target (200_000) + CHANGE_LOWER (50_000) + fees. - let candidates = vec![ + let candidates = [ Candidate { value: 50_000, weight: 100, @@ -92,7 +93,8 @@ fn srd_insufficient_funds() { let drain_weights = DrainWeights::TR_KEYSPEND; for seed in 0..50u64 { - let mut cs = CoinSelector::new(&candidates, target); + let problem_2 = SelectionProblem::new_no_ancestors(target, candidates.iter().copied()); + let mut cs = CoinSelector::new(&problem_2); let result = cs.select_srd(drain_weights, CHANGE_LOWER, splitmix64(seed)); assert!( matches!(result, Err(SelectError::InsufficientFunds(_))), @@ -126,7 +128,9 @@ fn srd_max_weight_exceeded() { }; // Weight of the smallest selection that reaches target + change_lower, with no cap. - let mut probe = CoinSelector::new(&candidates, target(200_000, 5.0)); + let problem_3 = + SelectionProblem::new_no_ancestors(target(200_000, 5.0), candidates.iter().copied()); + let mut probe = CoinSelector::new(&problem_3); probe .select_until(|cs| cs.excess(drain) >= CHANGE_LOWER as i64) .expect("candidates can cover target + change_lower"); @@ -139,7 +143,8 @@ fn srd_max_weight_exceeded() { }; for seed in 0..20u64 { - let mut cs = CoinSelector::new(&candidates, capped); + let problem_4 = SelectionProblem::new_no_ancestors(capped, candidates.iter().copied()); + let mut cs = CoinSelector::new(&problem_4); let result = cs.select_srd(drain_weights, CHANGE_LOWER, splitmix64(seed)); assert!( matches!(result, Err(SelectError::MaxWeightExceeded)), @@ -163,7 +168,8 @@ fn srd_adds_nothing_when_already_sufficient() { }; // Preselect enough that the change already exceeds `change_lower`. - let mut cs = CoinSelector::new(&candidates, target); + let problem_5 = SelectionProblem::new_no_ancestors(target, candidates.iter().copied()); + let mut cs = CoinSelector::new(&problem_5); cs.select_until(|cs| cs.excess(drain) >= CHANGE_LOWER as i64) .expect("candidates can cover target + change_lower"); let before: Vec = cs.selected_indices().iter().collect(); diff --git a/tests/weight.rs b/tests/weight.rs index b7e617d..65548e7 100644 --- a/tests/weight.rs +++ b/tests/weight.rs @@ -1,7 +1,8 @@ #![allow(clippy::zero_prefixed_literal)] use bdk_coin_select::{ - Candidate, CoinSelector, Drain, DrainWeights, Target, TargetFee, TargetOutputs, + Candidate, CoinSelector, Drain, DrainWeights, SelectionProblem, Target, TargetFee, + TargetOutputs, }; use bitcoin::{consensus::Decodable, ScriptBuf, Transaction}; @@ -73,7 +74,8 @@ fn segwit_one_input_one_output() { fee: TargetFee::ZERO, max_weight: None, }; - let mut coin_selector = CoinSelector::new(&candidates, target); + let problem = SelectionProblem::new_no_ancestors(target, candidates.iter().copied()); + let mut coin_selector = CoinSelector::new(&problem); coin_selector.select_all(); assert_eq!( @@ -120,7 +122,8 @@ fn segwit_two_inputs_one_output() { fee: TargetFee::ZERO, max_weight: None, }; - let mut coin_selector = CoinSelector::new(&candidates, target); + let problem_2 = SelectionProblem::new_no_ancestors(target, candidates.iter().copied()); + let mut coin_selector = CoinSelector::new(&problem_2); coin_selector.select_all(); @@ -169,7 +172,8 @@ fn legacy_three_inputs() { fee: TargetFee::ZERO, max_weight: None, }; - let mut coin_selector = CoinSelector::new(&candidates, target); + let problem_3 = SelectionProblem::new_no_ancestors(target, candidates.iter().copied()); + let mut coin_selector = CoinSelector::new(&problem_3); coin_selector.select_all(); assert_eq!( @@ -232,7 +236,8 @@ fn legacy_three_inputs_one_segwit() { fee: TargetFee::ZERO, max_weight: None, }; - let mut coin_selector = CoinSelector::new(&candidates, target); + let problem_4 = SelectionProblem::new_no_ancestors(target, candidates.iter().copied()); + let mut coin_selector = CoinSelector::new(&problem_4); coin_selector.select_all(); assert_eq!( @@ -271,7 +276,8 @@ fn legacy_three_inputs_grouped() { fee: TargetFee::ZERO, max_weight: None, }; - let mut coin_selector = CoinSelector::new(&candidates, target); + let problem_5 = SelectionProblem::new_no_ancestors(target, candidates.iter().copied()); + let mut coin_selector = CoinSelector::new(&problem_5); coin_selector.select_all(); assert_eq!( @@ -314,7 +320,8 @@ fn legacy_pair_grouped_with_segwit_input() { fee: TargetFee::ZERO, max_weight: None, }; - let mut coin_selector = CoinSelector::new(&candidates, target); + let problem_6 = SelectionProblem::new_no_ancestors(target, candidates.iter().copied()); + let mut coin_selector = CoinSelector::new(&problem_6); coin_selector.select_all(); assert_eq!( @@ -351,7 +358,8 @@ fn mixed_group_all_inputs_one_candidate() { fee: TargetFee::ZERO, max_weight: None, }; - let mut coin_selector = CoinSelector::new(&candidates, target); + let problem_7 = SelectionProblem::new_no_ancestors(target, candidates.iter().copied()); + let mut coin_selector = CoinSelector::new(&problem_7); coin_selector.select_all(); assert_eq!( From baa3dd4268a131964869f99b3e0d560cdd756eba Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=BF=97=E5=AE=87?= Date: Wed, 12 Aug 2026 23:32:02 +0000 Subject: [PATCH 05/26] feat: charge selections for the ancestors they drag in MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Selecting an unconfirmed coin means paying to bump its ancestors. The feerate obligation includes the shortfall of the union of ancestors the selected candidates drag in (each charged once; weight and fee netted; saturates at 0). Score is still the child fee — the bump is already inside it. With ancestors, LowestFee falls back to a loose but admissible fee floor; tightening is a follow-up. BnB only batch-bans look-alikes with the same drags_in; Changeless disables its prune when ancestors are present. --- src/bnb.rs | 11 +- src/coin_selector.rs | 111 ++++- src/metrics/changeless.rs | 9 + src/metrics/lowest_fee.rs | 39 ++ src/selection_problem.rs | 65 ++- tests/ancestor.proptest-regressions | 7 + tests/ancestor.rs | 613 ++++++++++++++++++++++++++++ 7 files changed, 832 insertions(+), 23 deletions(-) create mode 100644 tests/ancestor.proptest-regressions create mode 100644 tests/ancestor.rs diff --git a/src/bnb.rs b/src/bnb.rs index d20db4c..245384a 100644 --- a/src/bnb.rs +++ b/src/bnb.rs @@ -137,12 +137,19 @@ impl<'a, M: BnbMetric> BnbIter<'a, M> { inclusion_cs.select(next_index); self.consider_adding_to_queue(&inclusion_cs, false); - // for the exclusion branch, we keep banning if candidates have the same weight and value + // For the exclusion branch, we keep banning candidates that are interchangeable with the one + // we just excluded: same value and weight, and dragging in exactly the same unconfirmed + // ancestors (two coins of equal value and weight are *not* interchangeable if one of them + // drags in an ancestor that needs bumping). Candidates are only compared until the first + // mismatch, since this exploits them being adjacent in the sorted order. let mut is_first_ban = true; let mut exclusion_cs = cs.clone(); let to_ban = (next.value, next.weight); + let to_ban_drags_in = cs.problem().drags_in(next_index); for (next_index, next) in cs.unselected() { - if (next.value, next.weight) != to_ban { + if (next.value, next.weight) != to_ban + || cs.problem().drags_in(next_index) != to_ban_drags_in + { break; } let (_index, _candidate) = exclusion_cs diff --git a/src/coin_selector.rs b/src/coin_selector.rs index 8ccc5a2..f66599a 100644 --- a/src/coin_selector.rs +++ b/src/coin_selector.rs @@ -129,12 +129,18 @@ impl<'a> CoinSelector<'a> { /// enough value is reachable for [`is_funded`] to hold. Respects [`ban`]ned candidates. /// /// Selecting *all* effective inputs maximizes the value available, so if that can't meet the - /// target value, nothing can. Monotone, hence exact. + /// target value, nothing can. /// /// NOTE: this does **not** account for [`Target::max_weight`] — a `true` result can still be /// infeasible under the weight cap. Use [`select_until_target_met`] or branch and bound (both of /// which enforce the cap) to actually build a selection. /// + /// NOTE: this is exact only when [`SelectionProblem::has_ancestors`] is `false`. With + /// unconfirmed ancestors, funding is not monotone (an input can drag in an ancestor that costs + /// more than the input is worth, and inputs sharing an ancestor pay for it once between them), + /// so the all-effective selection is no longer guaranteed to be the best case: this becomes a + /// heuristic and can answer either way. Use branch and bound to decide feasibility exactly. + /// /// [`ban`]: Self::ban /// [`is_funded`]: Self::is_funded /// [`select_until_target_met`]: Self::select_until_target_met @@ -185,6 +191,43 @@ impl<'a> CoinSelector<'a> { .sum() } + /// The unconfirmed ancestors the current selection drags in (indices into + /// [`SelectionProblem::ancestors`]). + /// + /// This is the **union** over the selected candidates, so an ancestor shared by several of them + /// appears once. Derived from `selected` on demand: deselecting a candidate keeps an ancestor + /// that another selected candidate still drags in. + pub fn selected_ancestors(&self) -> Bitset { + let mut union = Bitset::with_capacity(self.problem.ancestors().len()); + if self.problem.has_ancestors() { + for cand_index in self.selected.iter() { + for anc_index in self.problem.drags_in(cand_index).iter() { + union.insert(anc_index); + } + } + } + union + } + + /// The fee (sats) this selection must pay *on top of* its own feerate obligation so the + /// unconfirmed ancestors it drags in reach `target.fee.rate` (CPFP). + /// + /// Computed over the [union](Self::selected_ancestors) of dragged-in ancestors — never by + /// summing [`SelectionProblem::local_bump`], which would charge a shared ancestor once per + /// candidate. Netted over that union, so an overpaying ancestor offsets an underpaying one, and + /// saturating at 0 (an ancestor that overpays never funds the child). + /// + /// Note this makes funding **non-monotone**: selecting a candidate that drags in an + /// underpaying ancestor can lower [`excess`](Self::excess). It also means the bump is not + /// additive over candidates, and a descendant selection can owe *less* than its parent (by + /// dragging in an ancestor that already overpays). + pub fn ancestor_bump(&self) -> u64 { + if !self.problem.has_ancestors() { + return 0; + } + self.problem.ancestor_bump(&self.selected_ancestors()) + } + /// Current weight of transaction implied by the selection. /// /// If you don't have any drain outputs (only target outputs) just set drain_weights to @@ -217,6 +260,8 @@ impl<'a> CoinSelector<'a> { /// How much the current selection overshoots the value need to satisfy `self.target().fee.rate` and /// `self.target().value` (while ignoring `self.target().fee.absolute`). + /// + /// The feerate obligation includes the [`ancestor_bump`](Self::ancestor_bump). pub fn rate_excess(&self, drain: Drain) -> i64 { self.selected_value() as i64 - self.target().value() as i64 @@ -272,6 +317,9 @@ impl<'a> CoinSelector<'a> { /// The feerate the transaction would have if we were to use this selection of inputs to achieve /// the `target`'s value and weight. It is essentially telling you what target feerate you currently have. /// + /// This is the *child* transaction's feerate: the fee and weight of any unconfirmed ancestors + /// this selection drags in are not included, so it is not the package feerate. + /// /// Returns `None` if the feerate would be negative or infinity. pub fn implied_feerate(&self, target_outputs: TargetOutputs, drain: Drain) -> Option { let numerator = @@ -288,6 +336,9 @@ impl<'a> CoinSelector<'a> { /// This compares the fee calculated from the target feerate with the fee calculated from the /// [`Replace`] constraints and returns the larger of the two. /// + /// The feerate component includes the [`ancestor_bump`](Self::ancestor_bump); the absolute and + /// replacement components are child-transaction constraints and are left alone. + /// /// `drain_weight` can be 0 to indicate no draining output. pub fn implied_fee(&self, drain_weights: DrainWeights) -> u64 { let mut implied_fee = self @@ -310,6 +361,7 @@ impl<'a> CoinSelector<'a> { .fee .rate .implied_fee(self.weight(self.target().outputs, drain_weights)) + + self.ancestor_bump() } fn implied_fee_from_feerate_wu(&self, drain_weights: DrainWeights) -> u64 { @@ -317,6 +369,30 @@ impl<'a> CoinSelector<'a> { .fee .rate .implied_fee_wu(self.weight(self.target().outputs, drain_weights)) + + self.ancestor_bump() + } + + /// A lower bound on the fee that this selection — and every selection extending it — must pay, + /// **ignoring** what the ancestors owe. + /// + /// Every term is monotone in the tx weight and so can only grow as more inputs (or a drain) are + /// added, and [`ancestor_bump`](Self::ancestor_bump) is non-negative, so this floor holds for + /// the whole subtree. The bump is deliberately excluded: it is *not* monotone, so a descendant + /// can owe less than this selection does. + /// + /// Weight-unit (un-rounded) fees are used throughout, which can only make the floor smaller. + pub(crate) fn fee_floor(&self) -> u64 { + let weight = self.weight(self.target().outputs, DrainWeights::NONE); + let mut floor = self + .target() + .fee + .rate + .implied_fee_wu(weight) + .max(self.target().fee.absolute); + if let Some(replace) = self.target().fee.replace { + floor = floor.max(replace.min_fee_to_do_replacement_wu(weight)); + } + floor } /// The actual fee the selection would pay if it was used in a transaction that had @@ -328,6 +404,9 @@ impl<'a> CoinSelector<'a> { } /// The value of the current selected inputs minus the fee needed to pay for the selected inputs + /// + /// Only the selected inputs' own weight is charged; any [`ancestor_bump`](Self::ancestor_bump) + /// they drag in is not. pub fn effective_value(&self, feerate: FeeRate) -> i64 { self.selected_value() as i64 - (self.input_weight() as f32 * feerate.spwu()).ceil() as i64 } @@ -485,19 +564,22 @@ impl<'a> CoinSelector<'a> { /// Whether the selection covers the target value (i.e. [`excess`](Self::excess) is /// non-negative), ignoring [`Target::max_weight`]. /// - /// This is **monotone**: selecting more never un-meets it. It deliberately does *not* include - /// the weight cap — see [`is_within_max_weight`](Self::is_within_max_weight). + /// This is **monotone** — selecting more never un-meets it — *unless* the problem has + /// unconfirmed ancestors, in which case adding an input can drag in an ancestor whose bump + /// exceeds the input's value (see [`ancestor_bump`](Self::ancestor_bump)). It deliberately does + /// not include the weight cap — see [`is_within_max_weight`](Self::is_within_max_weight). pub fn is_funded_with_drain(&self, drain: Drain) -> bool { self.excess(drain) >= 0 } /// Whether the selection covers the target **value** (net of input fees), i.e. [`excess`] is - /// non-negative. **Monotone** (selecting more never un-meets it), and it deliberately does - /// *not* check [`Target::max_weight`] — that is the separate, anti-monotone - /// [`is_within_max_weight`]. See [`is_funded_with_drain`] for the version that - /// accounts for a specific `drain`. + /// non-negative. Monotone unless the problem has unconfirmed ancestors (see + /// [`is_funded_with_drain`] and [`ancestor_bump`]), and it deliberately does *not* check + /// [`Target::max_weight`] — that is the separate, anti-monotone [`is_within_max_weight`]. See + /// [`is_funded_with_drain`] for the version that accounts for a specific `drain`. /// /// [`excess`]: Self::excess + /// [`ancestor_bump`]: Self::ancestor_bump /// [`is_within_max_weight`]: Self::is_within_max_weight /// [`is_funded_with_drain`]: Self::is_funded_with_drain pub fn is_funded(&self) -> bool { @@ -561,6 +643,10 @@ impl<'a> CoinSelector<'a> { /// Select all candidates with an *effective value* greater than 0 at the provided `feerate`. /// /// A candidate if effective if it provides more value than it takes to pay for at `feerate`. + /// + /// This looks at each candidate's own value and weight only: a candidate that pays for itself + /// but drags in an unconfirmed ancestor still counts as effective, even if the resulting + /// [`ancestor_bump`](Self::ancestor_bump) outweighs it. pub fn select_all_effective(&mut self, feerate: FeeRate) { for i in 0..self.candidate_order.len() { let cand_index = self.candidate_order[i]; @@ -582,6 +668,10 @@ impl<'a> CoinSelector<'a> { /// - [`SelectError::MaxWeightExceeded`] if the value is met but the resulting selection exceeds /// [`Target::max_weight`]. Note this only reflects *this* in-order greedy selection; a /// different selection might still fit the cap (use branch and bound to search for one). + /// + /// With unconfirmed ancestors the same caveat applies to + /// [`SelectError::InsufficientFunds`]: selecting everything can fail to meet the target while + /// some subset (one that drags in fewer ancestors) would meet it. pub fn select_until_target_met(&mut self) -> Result<(), SelectError> { self.select_until(|cs| cs.is_funded()).ok_or_else(|| { SelectError::InsufficientFunds(InsufficientFunds { @@ -719,7 +809,8 @@ impl<'a> CoinSelector<'a> { // No solution. If the iterator still has an item we stopped at the round limit and a // solution may still exist with a larger `max_rounds`. Otherwise the tree was fully // explored, so no selection satisfies the target — a genuine infeasibility, split into - // value vs weight. + // value vs weight. (With unconfirmed ancestors `is_fundable` is only a heuristic, so the + // split between the two can be wrong — the infeasibility itself is not.) if iter.next().is_some() { assert_eq!(rounds, max_rounds); // still-yielding ⟹ we truncated at the cap return Err(NoBnbSolution::RoundLimit { max_rounds, rounds }); @@ -834,6 +925,10 @@ impl std::error::Error for SelectError {} #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum NoBnbSolution { /// The candidates can't cover the target value, so no selection is possible. + /// + /// With unconfirmed ancestors this is decided by the heuristic [`CoinSelector::is_fundable`], so + /// it may be reported where [`MaxWeightExceeded`](Self::MaxWeightExceeded) fits better, and vice + /// versa. Either way the search was exhaustive: there is no solution. InsufficientFunds, /// Some selection covers the target value, but every one of them exceeds /// [`Target::max_weight`]. diff --git a/src/metrics/changeless.rs b/src/metrics/changeless.rs index c2c9036..dce049c 100644 --- a/src/metrics/changeless.rs +++ b/src/metrics/changeless.rs @@ -25,8 +25,17 @@ impl Changeless { /// NOTE: this relies on candidates being sorted so that all negative effective value candidates /// are next to each other, which [`requires_ordering_by_descending_value_pwu`] guarantees. /// + /// NOTE: with unconfirmed ancestors this reasoning breaks down — a candidate's marginal cost is + /// not its own value and weight (it also drags in ancestors, possibly ones already paid for), so + /// the selection built here need not be the one with the smallest excess. We give up the prune + /// rather than risk discarding a branch that does contain a changeless solution. + /// /// [`requires_ordering_by_descending_value_pwu`]: BnbMetric::requires_ordering_by_descending_value_pwu fn change_unavoidable(&mut self, cs: &CoinSelector<'_>) -> bool { + if cs.problem().has_ancestors() { + return false; + } + if self.0.drain(cs).is_none() { return false; } diff --git a/src/metrics/lowest_fee.rs b/src/metrics/lowest_fee.rs index d990f71..dfe6711 100644 --- a/src/metrics/lowest_fee.rs +++ b/src/metrics/lowest_fee.rs @@ -15,6 +15,19 @@ use crate::{float::Ordf32, BnbMetric, CoinSelector, Drain, DrainWeights, FeeRate /// output: change is added whenever doing so lowers the long-term fee (i.e. the recovered excess /// outweighs the future cost of spending the change) and the resulting change value is above the /// dust threshold implied by `dust_relay_feerate`. +/// +/// # Unconfirmed ancestors +/// +/// When the [`SelectionProblem`] has unconfirmed ancestors, the fee a selection must pay includes +/// the [`CoinSelector::ancestor_bump`] of the ancestors it drags in, so the search naturally prefers +/// coins that drag in nothing (or that share an already-paid-for ancestor). The score itself is +/// still the child transaction's fee — the bump is inside it, not added on top. +/// +/// The bound is much looser in that case (see [`bound`](BnbMetric::bound)): the tight bounds assume +/// funding is monotone and that a candidate costs its own weight, neither of which survives shared +/// or overpaying ancestors. Correctness is kept; the search just explores more. +/// +/// [`SelectionProblem`]: crate::SelectionProblem #[derive(Clone, Copy)] pub struct LowestFee { /// The estimated feerate needed to spend our change output later. @@ -68,6 +81,11 @@ impl LowestFee { /// inside [`bound`](BnbMetric::bound): deferring the changeless rejection only loosens the lower /// bound and never makes it inadmissible, and `score` reuses the returned drain for its cap /// check so the drain is decided once. + /// + /// The score is the *child* transaction's fee (plus the future cost of spending its change). + /// Any [`CoinSelector::ancestor_bump`] is not added on top: it is already inside the child's fee, + /// because covering it is what [`CoinSelector::is_funded`] demands and what the change + /// calculation gives up. fn fee_score(&self, cs: &CoinSelector<'_>) -> Option<(Ordf32, Drain)> { if !cs.is_funded() { return None; @@ -114,10 +132,31 @@ impl BnbMetric for LowestFee { // solution in the subtree is this selection with no drain. If even that busts `max_weight`, // the whole subtree is infeasible -> prune. (Also keeps `fee_score(cs).unwrap()` below // sound: a value-met but over-cap node would otherwise score `None`.) + // + // Ancestor weight is *not* part of this: `max_weight` caps the child transaction only. if !cs.is_within_max_weight(DrainWeights::NONE) { return None; } + // Everything below assumes funding is monotone and that a candidate's cost is its own + // weight — both false once unconfirmed ancestors are in play, where a candidate's marginal + // cost depends on which ancestors the selection already drags in: + // + // - A funded node's score is not a lower bound for its descendants: a descendant can drag + // in an *overpaying* ancestor, which lowers the netted bump (see + // `CoinSelector::ancestor_bump`) and so lowers the fee it must pay. + // - The unfunded relaxation below resizes the best value-per-weight candidate. With + // ancestors, value-per-weight is not the true marginal funding efficiency (a candidate + // sharing an already-paid-for ancestor is cheaper than its weight suggests), and its + // `None` returns would claim infeasibility off the back of "select everything and it's + // still unfunded", which no longer implies anything about subsets. + // + // So fall back to the fee floor: monotone in weight, ignores the (non-monotone) bump + // entirely, and never claims infeasibility. Loose, but admissible. + if cs.problem().has_ancestors() { + return Some(Ordf32(cs.fee_floor() as f32)); + } + if cs.is_funded() { let current_score = self.fee_score(cs).unwrap().0; diff --git a/src/selection_problem.rs b/src/selection_problem.rs index 0050168..550bfc4 100644 --- a/src/selection_problem.rs +++ b/src/selection_problem.rs @@ -2,7 +2,7 @@ use alloc::collections::BTreeMap; use alloc::vec::Vec; use crate::bitset::Bitset; -use crate::{Candidate, CoinSelector, Target}; +use crate::{Candidate, CoinSelector, FeeRate, Target}; /// An unconfirmed ancestor that may need bumping to the target feerate (CPFP). /// @@ -51,10 +51,14 @@ impl From> for InputGroup { /// [`CoinSelector::new`]. /// /// Ancestor bump figures are stored here (not on [`Candidate`]) so candidates stay a plain -/// description of inputs. They are not yet folded into fee/excess calculations; that is a -/// follow-up. Unknown parent ids are treated as confirmed and ignored. There is no mempool -/// "mine" step — deficits are computed against the full ancestor set and may overestimate +/// description of inputs. Unknown parent ids are treated as confirmed and ignored. There is no +/// mempool "mine" step — deficits are computed against the full ancestor set and may overestimate /// what Bitcoin Core would charge. +/// +/// What a selection actually owes is [`ancestor_bump`](Self::ancestor_bump) over the **union** of +/// the ancestors its selected candidates drag in; see +/// [`CoinSelector::ancestor_bump`](crate::CoinSelector::ancestor_bump), which is what fee and +/// excess calculations use. #[derive(Debug, Clone)] pub struct SelectionProblem { target: Target, @@ -65,6 +69,20 @@ pub struct SelectionProblem { drags_in: Vec, /// Per-candidate local bump fee (sats) at [`Target::fee`](crate::TargetFee)'s rate. local_bump: Vec, + /// Whether any candidate drags in at least one ancestor. + has_ancestors: bool, +} + +/// The fee still owed so the ancestors in `set` meet `rate`, over the whole set at once. +/// +/// Weights and fees are netted across the set, so an overpaying ancestor subsidizes an underpaying +/// one and the result saturates at 0 (the child is never credited). +fn bump_of(ancestors: &[(u64, u64)], rate: FeeRate, set: &Bitset) -> u64 { + let (weight, fee) = set.iter().fold((0_u64, 0_u64), |(w, f), anc_i| { + let (anc_w, anc_f) = ancestors[anc_i]; + (w + anc_w, f + anc_f) + }); + rate.implied_fee_wu(weight).saturating_sub(fee) } impl SelectionProblem { @@ -83,6 +101,7 @@ impl SelectionProblem { ancestors: Vec::new(), drags_in: (0..n).map(|_| Bitset::with_capacity(0)).collect(), local_bump: alloc::vec![0; n], + has_ancestors: false, } } @@ -91,7 +110,7 @@ impl SelectionProblem { /// For each input group, the residing txids and their transitive parents (restricted to /// `ancestors_to_bump`) form that candidate's `drags_in` set. `local_bump` is the fee still /// owed so those ancestors meet `target.fee.rate`, as if this were the only selected - /// candidate. + /// candidate — see [`local_bump`](Self::local_bump) for why that figure must not be summed. pub fn new(target: Target, input_groups: G, ancestors_to_bump: A) -> Self where Txid: Copy + Ord + Eq, @@ -110,9 +129,11 @@ impl SelectionProblem { .collect(); let n_anc = ancestors.len(); + let anc_weight_fee: Vec<(u64, u64)> = ancestors.iter().map(|a| (a.weight, a.fee)).collect(); let mut candidates = Vec::new(); let mut drags_in = Vec::new(); let mut local_bump = Vec::new(); + let mut has_ancestors = false; for input_group in input_groups { let mut cand = Candidate { @@ -141,23 +162,19 @@ impl SelectionProblem { } } - let (w, f) = dragged.iter().fold((0_u64, 0_u64), |(w, f), anc_i| { - let a = &ancestors[anc_i]; - (w + a.weight, f + a.fee) - }); - let bump = target.fee.rate.implied_fee_wu(w).saturating_sub(f); - + has_ancestors |= !dragged.is_empty(); + local_bump.push(bump_of(&anc_weight_fee, target.fee.rate, &dragged)); candidates.push(cand); drags_in.push(dragged); - local_bump.push(bump); } Self { target, candidates, - ancestors: ancestors.into_iter().map(|a| (a.weight, a.fee)).collect(), + ancestors: anc_weight_fee, drags_in, local_bump, + has_ancestors, } } @@ -191,12 +208,34 @@ impl SelectionProblem { &self.ancestors } + /// Whether any candidate drags in an unconfirmed ancestor. + /// + /// `false` means every fee calculation reduces to the plain (child-only) case, which lets + /// branch and bound use the tighter bounds that assume monotone funding. + pub fn has_ancestors(&self) -> bool { + self.has_ancestors + } + /// Ancestor indices dragged in by selecting candidate `index`. pub fn drags_in(&self, index: usize) -> &Bitset { &self.drags_in[index] } + /// The fee still owed so the ancestors in `set` meet [`Target::fee`](crate::TargetFee)'s rate. + /// + /// `set` indexes [`ancestors`](Self::ancestors). Weight and fee are netted over the whole set, + /// so each ancestor is charged exactly once no matter how many candidates drag it in, and an + /// overpaying ancestor offsets an underpaying one. Saturates at 0. + pub fn ancestor_bump(&self, set: &Bitset) -> u64 { + bump_of(&self.ancestors, self.target.fee.rate, set) + } + /// Local (per-candidate) bump fee for candidate `index`, in satoshis. + /// + /// This is what candidate `index` would owe *on its own*. It is informational only: these + /// figures must never be summed over a selection, because candidates sharing an ancestor would + /// each pay for it. Use [`ancestor_bump`](Self::ancestor_bump) over the union instead (which is + /// what [`CoinSelector`] does). pub fn local_bump(&self, index: usize) -> u64 { self.local_bump[index] } diff --git a/tests/ancestor.proptest-regressions b/tests/ancestor.proptest-regressions new file mode 100644 index 0000000..a4f1579 --- /dev/null +++ b/tests/ancestor.proptest-regressions @@ -0,0 +1,7 @@ +# Seeds for failure cases proptest has generated in the past. It is +# automatically read and these particular cases re-run before any +# novel cases are generated. +# +# It is recommended to check this file in to source control so that +# everyone who runs the test benefits from these saved cases. +cc 89e69058dd34f3215542f3ec44fbccc338577a6bfab488505a5e83dc5c1e88f6 # shrinks to spec = AncestorProblemSpec { candidates: [(1000, 200, 0), (1000, 200, 4)], ancestors: [(200, 0, 0)], target_value: 10000, feerate: 1.0, max_weight: None } diff --git a/tests/ancestor.rs b/tests/ancestor.rs new file mode 100644 index 0000000..54e8c2f --- /dev/null +++ b/tests/ancestor.rs @@ -0,0 +1,613 @@ +#![allow(unused_imports)] +//! Coin selection over candidates that drag in unconfirmed ancestors (CPFP). +//! +//! The invariant under test is that a selection's fee obligation includes the bump owed by the +//! **union** of the ancestors its selected candidates drag in — each ancestor charged exactly once, +//! weights and fees netted over the union — and that `LowestFee` branch and bound stays correct +//! under the resulting non-monotone funding. + +mod common; + +use bdk_coin_select::{ + float::Ordf32, + metrics::{Changeless, LowestFee}, + AncestorToBump, BnbMetric, Candidate, CoinSelector, Drain, DrainWeights, FeeRate, Input, + SelectionProblem, Target, TargetFee, TargetOutputs, TX_FIXED_FIELD_WEIGHT, +}; +use proptest::prelude::*; + +/// Not a txid of any ancestor we pass in, so inputs residing on it are treated as confirmed. +const CONFIRMED: &str = "confirmed"; + +const P2WPKH_INPUT_WEIGHT: u64 = 272; + +fn target(feerate_sat_per_vb: f32, value: u64) -> Target { + Target { + fee: TargetFee { + rate: FeeRate::from_sat_per_vb(feerate_sat_per_vb), + absolute: 0, + replace: None, + }, + outputs: TargetOutputs { + value_sum: value, + weight_sum: 100, + n_outputs: 1, + }, + max_weight: None, + } +} + +fn input(value: u64, residing_txid: &'static str) -> Input<&'static str> { + Input { + value, + weight: P2WPKH_INPUT_WEIGHT, + is_segwit: true, + residing_txid, + } +} + +fn ancestor( + txid: &'static str, + weight: u64, + fee: u64, + parents: Vec<&'static str>, +) -> AncestorToBump<&'static str> { + AncestorToBump { + txid, + weight, + fee, + parents, + } +} + +fn metric() -> LowestFee { + LowestFee { + long_term_feerate: FeeRate::from_sat_per_vb(1.0), + dust_relay_feerate: FeeRate::from_sat_per_vb(1.0), + drain_weights: DrainWeights::TR_KEYSPEND, + } +} + +/// The bump is charged on top of the child's own feerate obligation, so it eats exactly that much +/// excess relative to the same selection with nothing unconfirmed behind it. +#[test] +fn bump_is_charged_on_top_of_the_childs_own_fee() { + let t = target(10.0, 90_000); + // 1000 wu at 10 sat/vb (2.5 sat/wu) => 2500 sats owed, and the ancestor pays nothing. + let problem = + SelectionProblem::new(t, [input(100_000, "P")], [ancestor("P", 1_000, 0, vec![])]); + let mut cs = problem.selector(); + cs.select(0); + + assert_eq!(cs.ancestor_bump(), 2_500); + + let no_ancestors = SelectionProblem::new_no_ancestors( + t, + [Candidate { + value: 100_000, + weight: P2WPKH_INPUT_WEIGHT, + segwit_count: 1, + legacy_count: 0, + }], + ); + let mut clean_cs = no_ancestors.selector(); + clean_cs.select(0); + + assert_eq!(clean_cs.ancestor_bump(), 0); + assert_eq!( + cs.weight(t.outputs, DrainWeights::NONE), + clean_cs.weight(t.outputs, DrainWeights::NONE) + ); + assert_eq!( + cs.excess(Drain::NONE), + clean_cs.excess(Drain::NONE) - 2_500, + "the bump is the only difference between the two selections" + ); + assert_eq!( + cs.implied_fee(DrainWeights::NONE), + clean_cs.implied_fee(DrainWeights::NONE) + 2_500 + ); +} + +/// An unconfirmed ancestor can cost more than the coin sitting on it is worth: funding is no longer +/// monotone in the selection. +#[test] +fn dragged_in_ancestor_can_unfund_a_selection() { + let t = target(10.0, 90_000); + let problem = SelectionProblem::new( + t, + [input(100_000, CONFIRMED), input(100_000, "P")], + // 100_000 wu at 2.5 sat/wu => 250_000 sats owed: far more than the coin is worth. + [ancestor("P", 100_000, 0, vec![])], + ); + + let mut clean_only = problem.selector(); + clean_only.select(0); + assert!(clean_only.is_funded()); + + let mut both = problem.selector(); + both.select(0); + both.select(1); + assert!( + !both.is_funded(), + "adding a coin with an expensive ancestor un-funds a funded selection" + ); +} + +/// A shared ancestor is paid for once, no matter how many selected candidates drag it in — summing +/// the per-candidate `local_bump` figures would pay for it twice. +#[test] +fn shared_ancestor_is_charged_once() { + let t = target(10.0, 10_000); + let problem = SelectionProblem::new( + t, + [input(50_000, "P"), input(60_000, "P")], + [ancestor("P", 1_000, 0, vec![])], + ); + + assert_eq!(problem.local_bump(0), 2_500); + assert_eq!(problem.local_bump(1), 2_500); + + let mut cs = problem.selector(); + cs.select(0); + cs.select(1); + + assert_eq!(cs.selected_ancestors().len(), 1); + assert_eq!(cs.ancestor_bump(), 2_500); + assert_ne!( + cs.ancestor_bump(), + problem.local_bump(0) + problem.local_bump(1) + ); +} + +/// Deselecting one of two candidates that share an ancestor keeps the ancestor: it is still dragged +/// in by the other one. +#[test] +fn deselecting_keeps_an_ancestor_another_candidate_still_drags_in() { + let t = target(10.0, 10_000); + let problem = SelectionProblem::new( + t, + [ + input(50_000, "P"), + input(60_000, "P"), + input(70_000, CONFIRMED), + ], + [ancestor("P", 1_000, 0, vec![])], + ); + let mut cs = problem.selector(); + + cs.select(0); + cs.select(1); + assert_eq!(cs.ancestor_bump(), 2_500); + + cs.deselect(0); + assert_eq!(cs.ancestor_bump(), 2_500, "candidate 1 still drags in P"); + + cs.select(2); + assert_eq!( + cs.ancestor_bump(), + 2_500, + "a confirmed coin drags in nothing" + ); + + cs.deselect(1); + assert_eq!(cs.ancestor_bump(), 0, "nothing selected drags in P anymore"); +} + +/// The whole transitive chain is charged, and fees are netted across it (not per ancestor). +#[test] +fn transitive_ancestors_are_netted_as_one_package() { + let t = target(10.0, 10_000); + let problem = SelectionProblem::new( + t, + [input(50_000, "B")], + [ + ancestor("A", 400, 0, vec![]), + ancestor("B", 400, 1_000, vec!["A"]), + ], + ); + let mut cs = problem.selector(); + cs.select(0); + + // Union: weight 800 => 2000 sats owed at 2.5 sat/wu, of which B already paid 1000. + assert_eq!(cs.selected_ancestors().len(), 2); + assert_eq!(cs.ancestor_bump(), 1_000); +} + +/// Dragging in an ancestor that overpays *lowers* what the selection owes, because the deficit is +/// netted over the union. This is what makes a funded selection's fee a bad lower bound for its +/// descendants. +#[test] +fn overpaying_ancestor_offsets_an_underpaying_one() { + let t = target(1.0, 10_000); // 0.25 sat/wu + let problem = SelectionProblem::new( + t, + [input(50_000, "RICH"), input(50_000, "POOR")], + [ + ancestor("RICH", 400, 10_000, vec![]), + ancestor("POOR", 400, 0, vec![]), + ], + ); + + let mut poor_only = problem.selector(); + poor_only.select(1); + assert_eq!(poor_only.ancestor_bump(), 100); + + let mut rich_only = problem.selector(); + rich_only.select(0); + assert_eq!(rich_only.ancestor_bump(), 0, "never credits the child"); + + let mut both = problem.selector(); + both.select(0); + both.select(1); + assert_eq!( + both.ancestor_bump(), + 0, + "RICH's surplus covers POOR's deficit, so the superset owes less" + ); +} + +/// Ancestor weight is not part of the child transaction, so it must not count against +/// [`Target::max_weight`]. +#[test] +fn ancestor_weight_does_not_count_against_max_weight() { + let mut t = target(10.0, 10_000); + let heavy = 100_000; + let problem = SelectionProblem::new( + t, + [input(50_000, "P")], + [ancestor("P", heavy, heavy, vec![])], + ); + let mut cs = problem.selector(); + cs.select(0); + + let child_weight = cs.weight(t.outputs, DrainWeights::NONE); + assert!(child_weight < heavy); + + t.max_weight = Some(child_weight); + let capped = SelectionProblem::new( + t, + [input(50_000, "P")], + [ancestor("P", heavy, heavy, vec![])], + ); + let mut capped_cs = capped.selector(); + capped_cs.select(0); + assert!(capped_cs.is_within_max_weight(DrainWeights::NONE)); +} + +/// Two coins of equal value and weight are *not* interchangeable when only one of them drags in an +/// ancestor, so branch and bound must not ban them as a group. +/// +/// Here the only fundable selection is the clean coin alone, and it sits *after* the coin with the +/// expensive ancestor in the search order (equal value-per-weight, so the sort is stable). If the +/// exclusion branch banned it along with its look-alike, the search would report no solution. +#[test] +fn look_alikes_with_different_ancestors_are_not_banned_together() { + let t = target(10.0, 90_000); + let problem = SelectionProblem::new( + t, + [input(100_000, "P"), input(100_000, CONFIRMED)], + [ancestor("P", 100_000, 0, vec![])], + ); + + assert_eq!(problem.candidate(0).value, problem.candidate(1).value); + assert_eq!(problem.candidate(0).weight, problem.candidate(1).weight); + + let mut cs = problem.selector(); + let (_score, _drain) = cs + .run_bnb(metric(), 100_000) + .expect("the clean coin funds the target on its own"); + + assert!(cs.is_selected(1)); + assert!(!cs.is_selected(0)); +} + +/// The bump has to be inside the fee the metric reports, not added on top of it. +#[test] +fn score_is_the_childs_fee_which_already_covers_the_bump() { + let t = target(10.0, 90_000); + let problem = + SelectionProblem::new(t, [input(100_000, "P")], [ancestor("P", 1_000, 0, vec![])]); + let mut cs = problem.selector(); + cs.select(0); + + let mut m = metric(); + let score = m.score(&cs).expect("funded"); + let drain = m.drain(&cs); + assert_eq!( + score, + Ordf32( + (cs.fee(t.value(), drain.value) as u64 + drain.weights.spend_fee(m.long_term_feerate)) + as f32 + ) + ); + assert!( + cs.fee(t.value(), drain.value) as u64 >= cs.ancestor_bump(), + "a funded selection's child fee covers the bump" + ); +} + +/// A changeless solution can be reachable *only* by adding a coin whose ancestor eats the excess, +/// which the `Changeless` wrapper's prune cannot see. +/// +/// That prune asks "does the reachable selection with the least excess still have change?", and +/// builds it by adding the remaining coins with negative effective value. Here the coin that kills +/// the change looks profitable on its own (1000 sats for 200 wu) — it only shrinks the excess +/// because it drags in an ancestor owing 10_800 sats. So the prune concludes change is unavoidable +/// and would discard the one changeless solution there is. +#[test] +fn changeless_solution_reachable_only_via_an_ancestor_is_not_pruned() { + let t = target(1.0, 100_000); + let problem = SelectionProblem::new( + t, + [ + Input { + value: 110_000, + weight: 200, + is_segwit: true, + residing_txid: CONFIRMED, + }, + Input { + value: 1_000, + weight: 200, + is_segwit: true, + residing_txid: "P", + }, + ], + // 43_200 wu at 0.25 sat/wu => 10_800 sats owed. + [ancestor("P", 43_200, 0, vec![])], + ); + + let mut m = metric(); + + // The coin that drags in the ancestor is *not* one the prune would pick up: on its own it is + // worth more than it costs to spend. + assert!(problem.candidate(1).effective_value(t.fee.rate) > 0.0); + + let mut clean_only = problem.selector(); + clean_only.select(0); + assert!(clean_only.is_funded()); + assert!( + m.drain(&clean_only).is_some(), + "the clean coin on its own overshoots enough to warrant change" + ); + + let mut both = problem.selector(); + both.select(0); + both.select(1); + assert_eq!(both.ancestor_bump(), 10_800); + assert!(both.is_funded(), "still funded after paying the bump"); + assert!( + m.drain(&both).is_none(), + "the bump leaves too little excess to be worth a change output" + ); + + // So the only changeless solution is both coins together, reachable only *through* the node + // that has change. + let mut cs = problem.selector(); + let (score, drain) = cs + .run_bnb(Changeless(metric()), 100_000) + .expect("the changeless solution must not be pruned"); + assert!(drain.is_none()); + assert!(cs.is_selected(0) && cs.is_selected(1)); + assert_eq!(score, Ordf32(11_000.0)); +} + +// --- randomized cross-checks --- + +/// Spec for a randomly generated ancestor problem. Indices are taken modulo the relevant length so +/// any combination of generated numbers describes a valid (acyclic) problem. +#[derive(Debug, Clone)] +struct AncestorProblemSpec { + /// `(value, weight, residing_txid_selector)` per candidate. + candidates: Vec<(u64, u64, usize)>, + /// `(weight, fee, parent_selector)` per unconfirmed ancestor. + ancestors: Vec<(u64, u64, usize)>, + target_value: u64, + feerate: f32, + max_weight: Option, +} + +impl AncestorProblemSpec { + fn build(&self) -> SelectionProblem { + let n_anc = self.ancestors.len(); + + let ancestors: Vec> = self + .ancestors + .iter() + .enumerate() + .map(|(i, &(weight, fee, parent_sel))| { + // Parents are strictly earlier ancestors (keeps the graph acyclic); selecting `i` + // itself means "no unconfirmed parent". + let parent = parent_sel % (i + 1); + AncestorToBump { + txid: i, + weight, + fee, + parents: if parent == i { vec![] } else { vec![parent] }, + } + }) + .collect(); + + let inputs: Vec> = self + .candidates + .iter() + .map(|&(value, weight, residing_sel)| Input { + value, + weight, + is_segwit: true, + // `n_anc` means the coin sits on a confirmed tx (no matching txid). + residing_txid: residing_sel % (n_anc + 1), + }) + .collect(); + + let mut t = target(self.feerate, self.target_value); + t.max_weight = self.max_weight; + SelectionProblem::new(t, inputs, ancestors) + } +} + +fn spec_strategy() -> impl Strategy { + ( + prop::collection::vec((1_000u64..200_000, 200u64..1_000, 0usize..8), 1..6), + prop::collection::vec((200u64..4_000, 0u64..3_000, 0usize..8), 0..4), + 10_000u64..400_000, + 1.0f32..30.0, + proptest::option::of(400u64..3_000), + ) + .prop_map( + |(candidates, ancestors, target_value, feerate, max_weight)| AncestorProblemSpec { + candidates, + ancestors, + target_value, + feerate, + max_weight, + }, + ) +} + +/// Independently computed bump for a selection, straight from the definition: union the ancestor +/// sets of the selected candidates, sum weight and fee over that union, and take the shortfall. +fn expected_bump(problem: &SelectionProblem, cs: &CoinSelector<'_>, feerate: FeeRate) -> u64 { + let mut union = std::collections::BTreeSet::new(); + for i in cs.selected_indices().iter() { + union.extend(problem.drags_in(i).iter()); + } + let (weight, fee) = union + .iter() + .map(|&i| problem.ancestors()[i]) + .fold((0u64, 0u64), |(w, f), (aw, af)| (w + aw, f + af)); + feerate.implied_fee_wu(weight).saturating_sub(fee) +} + +proptest! { + /// Every selection's bump must equal the union-derived figure — in particular it must never be + /// the sum of the per-candidate `local_bump`s when ancestors are shared. + #[test] + fn bump_matches_union_definition(spec in spec_strategy()) { + let problem = spec.build(); + let feerate = problem.target().fee.rate; + let cs = problem.selector(); + + prop_assert_eq!(cs.ancestor_bump(), expected_bump(&problem, &cs, feerate)); + + for (node, _) in common::ExhaustiveIter::new(&cs).into_iter().flatten() { + prop_assert_eq!( + node.ancestor_bump(), + expected_bump(&problem, &node, feerate), + "selection={}", node + ); + } + } + + /// The bound must never exceed the score of any selection in its subtree (else branch and bound + /// can prune the optimum), and `None` must really mean "nothing in this subtree is valid". + #[test] + fn bound_is_admissible_with_ancestors(spec in spec_strategy()) { + let problem = spec.build(); + let mut metric = metric(); + + let mut root = problem.selector(); + if metric.requires_ordering_by_descending_value_pwu() { + root.sort_candidates_by_descending_value_pwu(); + } + + let nodes = std::iter::once(root.clone()).chain( + common::ExhaustiveIter::new(&root) + .into_iter() + .flatten() + .map(|(node, _)| node), + ); + + for node in nodes { + let bound = metric.bound(&node); + let subtree = std::iter::once(node.clone()).chain( + common::ExhaustiveIter::new(&node) + .into_iter() + .flatten() + .filter(|(_, inclusion)| *inclusion) + .map(|(descendant, _)| descendant), + ); + + for descendant in subtree { + let score = metric.score(&descendant); + match bound { + Some(lb) => if let Some(score) = score { + prop_assert!( + score >= lb, + "bound too tight: node={} lb={} descendant={} score={}", + node, lb, descendant, score + ); + }, + None => prop_assert!( + score.is_none(), + "pruned a subtree with a solution: node={} descendant={} score={:?}", + node, descendant, score + ), + } + } + } + } + + /// With unlimited rounds, branch and bound must land on the same optimum as brute force — both + /// the score and the feasibility verdict. + #[test] + fn bnb_finds_the_brute_force_optimum(spec in spec_strategy()) { + let problem = spec.build(); + + let mut exhaustive_cs = problem.selector(); + let mut exhaustive_metric = metric(); + let expected = common::exhaustive_search(&mut exhaustive_cs, &mut exhaustive_metric); + + let mut bnb_cs = problem.selector(); + let found = common::bnb_search(&mut bnb_cs, metric(), usize::MAX); + + match (expected, found) { + (Some((expected_score, _)), Ok((score, _))) => { + prop_assert_eq!(score, expected_score, "bnb={} exhaustive={}", bnb_cs, exhaustive_cs); + } + (None, Err(_)) => {} + (expected, found) => prop_assert!( + false, + "disagreement: exhaustive={:?} bnb={:?}", + expected.map(|(score, _)| score), + found.map(|(score, _)| score), + ), + } + } + + /// Same for the changeless-constrained metric, whose extra prune ("every reachable selection + /// would have change") also leans on a candidate costing only its own weight. + /// + /// NOTE: `max_weight` is forced off here. `Changeless` disagrees with brute force on + /// capped problems *without* any ancestors too (`LowestFee` reports a selection as changeless + /// when change would bust the cap, a route to changelessness that `Changeless`'s + /// excess-monotone prune doesn't consider), so that is a separate, pre-existing issue rather + /// than something ancestors introduce. + #[test] + fn changeless_bnb_finds_the_brute_force_optimum( + spec in spec_strategy().prop_map(|spec| AncestorProblemSpec { max_weight: None, ..spec }), + ) { + let problem = spec.build(); + + let mut exhaustive_cs = problem.selector(); + let mut exhaustive_metric = Changeless(metric()); + let expected = common::exhaustive_search(&mut exhaustive_cs, &mut exhaustive_metric); + + let mut bnb_cs = problem.selector(); + let found = common::bnb_search(&mut bnb_cs, Changeless(metric()), usize::MAX); + + match (expected, found) { + (Some((expected_score, _)), Ok((score, _))) => { + prop_assert_eq!(score, expected_score, "bnb={} exhaustive={}", bnb_cs, exhaustive_cs); + } + (None, Err(_)) => {} + (expected, found) => prop_assert!( + false, + "disagreement: exhaustive={:?} bnb={:?}", + expected.map(|(score, _)| score), + found.map(|(score, _)| score), + ), + } + } +} From ce2a557f138ed026d05115a2b096ae80c8f3c9d5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=BF=97=E5=AE=87?= Date: Fri, 14 Aug 2026 04:52:22 +0000 Subject: [PATCH 06/26] perf: speed up ancestor-aware LowestFee Precompute ancestors reachable through exactly one candidate as summed private packages. Keep bitset de-duplication only for ancestors shared by multiple candidates, preserving exact union accounting while reducing the common-path work in every fee calculation. Add Criterion coverage for private and shared ancestry at 20, 50, and 100 candidates, plus exhaustive regressions for the optimized representation. --- benches/coin_selector.rs | 110 ++++++++++- src/coin_selector.rs | 177 +++++++++++++++-- src/metrics/lowest_fee.rs | 8 +- src/selection_problem.rs | 132 ++++++++++--- tests/ancestor.proptest-regressions | 2 + tests/ancestor.rs | 297 ++++++++++++++++++++++++++++ 6 files changed, 671 insertions(+), 55 deletions(-) diff --git a/benches/coin_selector.rs b/benches/coin_selector.rs index bac1de1..7a13dc0 100644 --- a/benches/coin_selector.rs +++ b/benches/coin_selector.rs @@ -1,10 +1,13 @@ //! Benchmarks for `CoinSelector`. //! -//! Two groups: +//! Three groups: //! - `clone`: direct cost of `CoinSelector::clone()`, the operation `Bitset` //! was introduced to make cheap. //! - `run_bnb_lowest_fee`: end-to-end Branch-and-Bound throughput on a //! deterministic synthetic pool using the `LowestFee` metric. +//! - `run_bnb_lowest_fee_ancestors`: the same, but where the coins sit on unconfirmed ancestors that +//! need bumping — covering both the private and shared ancestor paths, which cost different +//! amounts per fee calculation. //! //! Run with `cargo bench`. Filter with `cargo bench -- `. @@ -14,8 +17,9 @@ #![allow(clippy::incompatible_msrv)] use bdk_coin_select::{ - metrics::LowestFee, Candidate, CoinSelector, DrainWeights, FeeRate, SelectionProblem, Target, - TargetFee, TargetOutputs, TR_SPK_WEIGHT, TXIN_BASE_WEIGHT, TXOUT_BASE_WEIGHT, + metrics::LowestFee, AncestorToBump, Candidate, CoinSelector, DrainWeights, FeeRate, Input, + SelectionProblem, Target, TargetFee, TargetOutputs, TR_SPK_WEIGHT, TXIN_BASE_WEIGHT, + TXOUT_BASE_WEIGHT, }; use criterion::{criterion_group, criterion_main, BatchSize, BenchmarkId, Criterion}; use std::hint::black_box; @@ -98,5 +102,103 @@ fn bench_run_bnb_lowest_fee(c: &mut Criterion) { group.finish(); } -criterion_group!(benches, bench_coin_selector_clone, bench_run_bnb_lowest_fee); +/// Deterministic synthetic pool where every third coin sits on an unconfirmed chain that still owes +/// fees, so every fee calculation has to work out the bump. +/// +/// With `share`, all such coins sit on the *same* chain, which is the case that cannot be folded into +/// the candidates up front and has to be de-duplicated per selection. +fn make_ancestor_problem(n: usize, share: bool) -> SelectionProblem { + const P2WPKH_SAT_W: u64 = 107; + const CONFIRMED: usize = usize::MAX; + + let mut ancestors = Vec::new(); + let mut residing = Vec::with_capacity(n); + let mut shared_tip = None; + for i in 0..n { + if i % 3 != 0 { + residing.push(CONFIRMED); + continue; + } + match (share, shared_tip) { + (true, Some(tip)) => residing.push(tip), + _ => { + // A two-long chain: an unpaid parent and a tip that pays a little. + let parent = ancestors.len(); + ancestors.push(AncestorToBump { + txid: parent, + weight: 800, + fee: 0, + parents: vec![], + }); + let tip = ancestors.len(); + ancestors.push(AncestorToBump { + txid: tip, + weight: 800, + fee: 200, + parents: vec![parent], + }); + residing.push(tip); + shared_tip = Some(tip); + } + } + } + + let inputs = (0..n).map(|i| { + let value = 1_000 + i as u64 * 137 + (i * i) as u64; + Input { + value, + weight: TXIN_BASE_WEIGHT + P2WPKH_SAT_W, + is_segwit: true, + residing_txid: residing[i], + } + }); + + let total: u64 = (0..n) + .map(|i| 1_000 + i as u64 * 137 + (i * i) as u64) + .sum(); + let target = Target { + fee: TargetFee::from_feerate(FeeRate::from_sat_per_vb(2.0)), + outputs: TargetOutputs::fund_outputs([(TXOUT_BASE_WEIGHT + TR_SPK_WEIGHT, total / 2)]), + max_weight: None, + }; + SelectionProblem::new(target, inputs, ancestors) +} + +fn bench_run_bnb_lowest_fee_ancestors(c: &mut Criterion) { + let mut group = c.benchmark_group("run_bnb_lowest_fee_ancestors"); + group.sample_size(20); + for &share in &[false, true] { + let kind = match share { + false => "private", + true => "shared", + }; + for &n in &[20usize, 50, 100] { + let problem = make_ancestor_problem(n, share); + let selector = CoinSelector::new(&problem); + group.bench_with_input(BenchmarkId::new(kind, n), &n, |b, _| { + b.iter_batched( + || selector.clone(), + |mut sel| { + let metric = LowestFee { + long_term_feerate: FeeRate::from_sat_per_vb(10.0), + dust_relay_feerate: FeeRate::from_sat_per_vb(1.0), + drain_weights: DrainWeights::TR_KEYSPEND, + }; + let _ = sel.run_bnb(metric, black_box(100_000)); + sel + }, + BatchSize::SmallInput, + ); + }); + } + } + group.finish(); +} + +criterion_group!( + benches, + bench_coin_selector_clone, + bench_run_bnb_lowest_fee, + bench_run_bnb_lowest_fee_ancestors +); criterion_main!(benches); diff --git a/src/coin_selector.rs b/src/coin_selector.rs index f66599a..795f147 100644 --- a/src/coin_selector.rs +++ b/src/coin_selector.rs @@ -212,10 +212,15 @@ impl<'a> CoinSelector<'a> { /// The fee (sats) this selection must pay *on top of* its own feerate obligation so the /// unconfirmed ancestors it drags in reach `target.fee.rate` (CPFP). /// - /// Computed over the [union](Self::selected_ancestors) of dragged-in ancestors — never by - /// summing [`SelectionProblem::local_bump`], which would charge a shared ancestor once per - /// candidate. Netted over that union, so an overpaying ancestor offsets an underpaying one, and - /// saturating at 0 (an ancestor that overpays never funds the child). + /// Charged over the ancestors this selection drags in, taken **once each** — never by summing + /// [`SelectionProblem::local_bump`], which would charge a shared ancestor once per candidate. + /// Weight and fee are netted across them, so an ancestor paying above the rate offsets one paying + /// below it, and the result saturates at 0 (an ancestor that overpays never funds the child). + /// + /// Most ancestors are reachable through a single candidate, and + /// [`SelectionProblem`] has already folded those into a per-candidate + /// [`private_ancestors`](SelectionProblem::private_ancestors) pair, so all this does is add them + /// up. Only ancestors several candidates can reach still need de-duplicating here. /// /// Note this makes funding **non-monotone**: selecting a candidate that drags in an /// underpaying ancestor can lower [`excess`](Self::excess). It also means the bump is not @@ -225,7 +230,151 @@ impl<'a> CoinSelector<'a> { if !self.problem.has_ancestors() { return 0; } - self.problem.ancestor_bump(&self.selected_ancestors()) + + let (mut weight, mut fee) = (0_u64, 0_u64); + if self.problem.has_private_ancestors() { + for cand_index in self.selected.iter() { + let (private_weight, private_fee) = self.problem.private_ancestors(cand_index); + weight += private_weight; + fee += private_fee; + } + } + + if self.problem.has_shared_ancestors() { + let shared = self.selected_shared_ancestors(); + for anc_index in shared.iter() { + let (shared_weight, shared_fee) = self.problem.ancestors()[anc_index]; + weight += shared_weight; + fee += shared_fee; + } + } + + self.target() + .fee + .rate + .implied_fee_wu(weight) + .saturating_sub(fee) + } + + /// The unconfirmed ancestors that are not dragged in yet but could still be, i.e. those of the + /// [`unselected`](Self::unselected) candidates. Respects [`ban`](Self::ban). + /// + /// These are exactly the ancestors a descendant of this selection can add. + pub fn addable_ancestors(&self) -> Bitset { + let mut union = Bitset::with_capacity(self.problem.ancestors().len()); + if self.problem.has_ancestors() { + let already = self.selected_ancestors(); + for cand_index in self.unselected_indices() { + for anc_index in self.problem.drags_in(cand_index).iter() { + if !already.contains(anc_index) { + union.insert(anc_index); + } + } + } + } + union + } + + /// The least [`ancestor_bump`](Self::ancestor_bump) this selection — or any selection extending + /// it — could still owe. + /// + /// This is **not** the bump of the current selection. A later coin can drag in an ancestor that + /// already overpays the target rate; that surplus nets against the deficit, so a descendant can + /// owe *less*. This method credits every still-reachable surplus and floors at zero: + /// + /// ```text + /// bump of this selection, and of every selection that adds more coins + /// >= max(0, currently_owed − reachable_surplus) + /// ``` + /// + /// where `currently_owed` is `rate · ancestor_weight − ancestor_fee` of this selection, and + /// `reachable_surplus` is how much still-addable ancestors overpay the target rate. + /// + /// Surplus cannot be picked up ancestor by ancestor: ancestors arrive by selecting a + /// *candidate*, which drags in its whole transitive set. So `reachable_surplus` is accumulated + /// per group that must arrive together — the split [`SelectionProblem`] already computed: + /// + /// - Ancestors only one candidate can reach ([`private_ancestors`]) are netted as a group, and + /// contribute only if the group as a whole is in surplus. A chain whose tip overpays but which + /// nets to a deficit therefore offers nothing. + /// - Ancestors several candidates can reach ([`shared_drags_in`]) are credited individually, + /// since which candidate brings them — and what else it brings — is not pinned down. + /// + /// This is still a relaxation: those groups may not be reachable *together*, and reaching them at + /// all means adding candidates (and their child weight). Both only push the real figure up. When + /// nothing reachable overpays, the bound equals the current bump. + /// + /// Computed in floating point and floored, so it can sit a fraction of a satoshi below the exact + /// value — in the safe direction. + /// + /// [`private_ancestors`]: SelectionProblem::private_ancestors + /// [`shared_drags_in`]: SelectionProblem::shared_drags_in + pub fn ancestor_bump_lower_bound(&self) -> u64 { + if !self.problem.has_ancestors() { + return 0; + } + let spwu = self.target().fee.rate.spwu() as f64; + // What a group of ancestors still owes; negative means it pays above the target rate. + let owes = |(weight, fee): (u64, u64)| weight as f64 * spwu - fee as f64; + + // Ancestors only one candidate can reach are netted as a group, so they need no + // de-duplicating: what this selection owes for them is a plain sum, and the most a descendant + // could shed is one group at a time. + let mut owed = 0.0; + let mut shed = 0.0; + if self.problem.has_private_ancestors() { + for cand_index in self.selected.iter() { + owed += owes(self.problem.private_ancestors(cand_index)); + } + for cand_index in self.unselected_indices() { + shed += (-owes(self.problem.private_ancestors(cand_index))).max(0.0); + } + } + + // Only ancestors several candidates can reach have to be gathered up, and they are credited + // individually since no single candidate owns them. + if self.problem.has_shared_ancestors() { + let selected_shared = self.selected_shared_ancestors(); + for anc_index in selected_shared.iter() { + owed += owes(self.problem.ancestors()[anc_index]); + } + + let mut addable_shared = Bitset::with_capacity(self.problem.ancestors().len()); + for cand_index in self.unselected_indices() { + for anc_index in self.problem.shared_drags_in(cand_index).iter() { + if !selected_shared.contains(anc_index) { + addable_shared.insert(anc_index); + } + } + } + for anc_index in addable_shared.iter() { + shed += (-owes(self.problem.ancestors()[anc_index])).max(0.0); + } + } + + let bound = owed - shed; + if bound <= 0.0 { + 0 + } else { + bound as u64 // truncating a positive float is the floor, i.e. rounds down + } + } + + /// The ancestors this selection drags in that several candidates could have dragged in, taken + /// once each. Empty unless [`SelectionProblem::has_shared_ancestors`]. + fn selected_shared_ancestors(&self) -> Bitset { + let mut shared = Bitset::with_capacity(match self.problem.has_shared_ancestors() { + true => self.problem.ancestors().len(), + false => 0, + }); + if self.problem.has_shared_ancestors() { + for cand_index in self.selected.iter() { + for anc_index in self.problem.shared_drags_in(cand_index).iter() { + shared.insert(anc_index); + } + } + } + shared } /// Current weight of transaction implied by the selection. @@ -372,23 +521,19 @@ impl<'a> CoinSelector<'a> { + self.ancestor_bump() } - /// A lower bound on the fee that this selection — and every selection extending it — must pay, - /// **ignoring** what the ancestors owe. + /// A lower bound on the fee that this selection — and every selection extending it — must pay. /// /// Every term is monotone in the tx weight and so can only grow as more inputs (or a drain) are - /// added, and [`ancestor_bump`](Self::ancestor_bump) is non-negative, so this floor holds for - /// the whole subtree. The bump is deliberately excluded: it is *not* monotone, so a descendant - /// can owe less than this selection does. + /// added. What the ancestors owe is *not* monotone, so this credits only + /// [`ancestor_bump_lower_bound`](Self::ancestor_bump_lower_bound) — the least any descendant + /// could owe — rather than this selection's actual [`ancestor_bump`](Self::ancestor_bump). /// /// Weight-unit (un-rounded) fees are used throughout, which can only make the floor smaller. pub(crate) fn fee_floor(&self) -> u64 { let weight = self.weight(self.target().outputs, DrainWeights::NONE); - let mut floor = self - .target() - .fee - .rate - .implied_fee_wu(weight) - .max(self.target().fee.absolute); + let mut floor = (self.target().fee.rate.implied_fee_wu(weight) + + self.ancestor_bump_lower_bound()) + .max(self.target().fee.absolute); if let Some(replace) = self.target().fee.replace { floor = floor.max(replace.min_fee_to_do_replacement_wu(weight)); } diff --git a/src/metrics/lowest_fee.rs b/src/metrics/lowest_fee.rs index dfe6711..49a9e2d 100644 --- a/src/metrics/lowest_fee.rs +++ b/src/metrics/lowest_fee.rs @@ -23,7 +23,7 @@ use crate::{float::Ordf32, BnbMetric, CoinSelector, Drain, DrainWeights, FeeRate /// coins that drag in nothing (or that share an already-paid-for ancestor). The score itself is /// still the child transaction's fee — the bump is inside it, not added on top. /// -/// The bound is much looser in that case (see [`bound`](BnbMetric::bound)): the tight bounds assume +/// The bound is looser in that case (see [`bound`](BnbMetric::bound)): the tight bounds assume /// funding is monotone and that a candidate costs its own weight, neither of which survives shared /// or overpaying ancestors. Correctness is kept; the search just explores more. /// @@ -151,8 +151,10 @@ impl BnbMetric for LowestFee { // `None` returns would claim infeasibility off the back of "select everything and it's // still unfunded", which no longer implies anything about subsets. // - // So fall back to the fee floor: monotone in weight, ignores the (non-monotone) bump - // entirely, and never claims infeasibility. Loose, but admissible. + // So fall back to the fee floor, which is monotone in weight and never claims + // infeasibility. It still credits what the ancestors owe, but only the least any descendant + // could owe (`CoinSelector::ancestor_bump_lower_bound`) rather than what this selection owes + // — which is the whole bump whenever no reachable ancestor overpays. if cs.problem().has_ancestors() { return Some(Ordf32(cs.fee_floor() as f32)); } diff --git a/src/selection_problem.rs b/src/selection_problem.rs index 550bfc4..1a3d403 100644 --- a/src/selection_problem.rs +++ b/src/selection_problem.rs @@ -55,10 +55,10 @@ impl From> for InputGroup { /// mempool "mine" step — deficits are computed against the full ancestor set and may overestimate /// what Bitcoin Core would charge. /// -/// What a selection actually owes is [`ancestor_bump`](Self::ancestor_bump) over the **union** of -/// the ancestors its selected candidates drag in; see -/// [`CoinSelector::ancestor_bump`](crate::CoinSelector::ancestor_bump), which is what fee and -/// excess calculations use. +/// What a selection actually owes is +/// [`CoinSelector::ancestor_bump`](crate::CoinSelector::ancestor_bump): the shortfall of the +/// ancestors its selected candidates drag in, each charged once, weight and fee netted over the +/// union. #[derive(Debug, Clone)] pub struct SelectionProblem { target: Target, @@ -66,11 +66,24 @@ pub struct SelectionProblem { /// Weight and fee of each ancestor, after txids are dropped. ancestors: Vec<(u64, u64)>, /// Per-candidate set of ancestor indices dragged in by selecting that candidate. + /// + /// Empty when the problem has no ancestors (see [`has_ancestors`](Self::has_ancestors)); use + /// [`drags_in`](Self::drags_in) rather than indexing this directly. drags_in: Vec, - /// Per-candidate local bump fee (sats) at [`Target::fee`](crate::TargetFee)'s rate. - local_bump: Vec, - /// Whether any candidate drags in at least one ancestor. - has_ancestors: bool, + /// Summed weight and fee of the ancestors *only* this candidate can drag in. + /// + /// No other candidate reaches them, so they arrive exactly when this candidate is selected. + /// Summed rather than reduced to a bump because the target rate must be applied to the total + /// weight of the whole selection once, and because an ancestor paying above the rate has to be + /// able to subsidize one paying below it. + private: Vec<(u64, u64)>, + /// [`drags_in`](Self::drags_in) restricted to ancestors reachable via several candidates, which + /// are the only ones that still need de-duplicating at selection time. + shared_drags_in: Vec, + /// Whether any ancestor is reachable via exactly one candidate. + has_private_ancestors: bool, + /// Whether any ancestor is reachable via more than one candidate. + has_shared_ancestors: bool, } /// The fee still owed so the ancestors in `set` meet `rate`, over the whole set at once. @@ -100,17 +113,19 @@ impl SelectionProblem { candidates, ancestors: Vec::new(), drags_in: (0..n).map(|_| Bitset::with_capacity(0)).collect(), - local_bump: alloc::vec![0; n], - has_ancestors: false, + private: alloc::vec![(0, 0); n], + shared_drags_in: (0..n).map(|_| Bitset::with_capacity(0)).collect(), + has_private_ancestors: false, + has_shared_ancestors: false, } } /// Build candidates from input groups and the unconfirmed ancestors they may drag in. /// /// For each input group, the residing txids and their transitive parents (restricted to - /// `ancestors_to_bump`) form that candidate's `drags_in` set. `local_bump` is the fee still - /// owed so those ancestors meet `target.fee.rate`, as if this were the only selected - /// candidate — see [`local_bump`](Self::local_bump) for why that figure must not be summed. + /// `ancestors_to_bump`) form that candidate's `drags_in` set. Ancestors only one candidate can + /// reach are folded into [`private_ancestors`](Self::private_ancestors); the rest stay in + /// [`shared_drags_in`](Self::shared_drags_in) to be de-duplicated per selection. pub fn new(target: Target, input_groups: G, ancestors_to_bump: A) -> Self where Txid: Copy + Ord + Eq, @@ -132,8 +147,6 @@ impl SelectionProblem { let anc_weight_fee: Vec<(u64, u64)> = ancestors.iter().map(|a| (a.weight, a.fee)).collect(); let mut candidates = Vec::new(); let mut drags_in = Vec::new(); - let mut local_bump = Vec::new(); - let mut has_ancestors = false; for input_group in input_groups { let mut cand = Candidate { @@ -162,19 +175,50 @@ impl SelectionProblem { } } - has_ancestors |= !dragged.is_empty(); - local_bump.push(bump_of(&anc_weight_fee, target.fee.rate, &dragged)); candidates.push(cand); drags_in.push(dragged); } + // An ancestor no other candidate can reach arrives exactly when this one is selected, so its + // weight and fee can be folded into the candidate now. The rest still have to be + // de-duplicated at selection time. + let mut reachable_by = alloc::vec![0_u32; n_anc]; + for dragged in &drags_in { + for anc_i in dragged.iter() { + reachable_by[anc_i] += 1; + } + } + let mut private = Vec::with_capacity(drags_in.len()); + let mut shared_drags_in = Vec::with_capacity(drags_in.len()); + let mut has_private_ancestors = false; + let mut has_shared_ancestors = false; + for dragged in &drags_in { + let mut private_weight_fee = (0_u64, 0_u64); + let mut shared = Bitset::with_capacity(n_anc); + for anc_i in dragged.iter() { + if reachable_by[anc_i] == 1 { + let (weight, fee) = anc_weight_fee[anc_i]; + private_weight_fee.0 += weight; + private_weight_fee.1 += fee; + has_private_ancestors = true; + } else { + shared.insert(anc_i); + has_shared_ancestors = true; + } + } + private.push(private_weight_fee); + shared_drags_in.push(shared); + } + Self { target, candidates, ancestors: anc_weight_fee, drags_in, - local_bump, - has_ancestors, + private, + shared_drags_in, + has_private_ancestors, + has_shared_ancestors, } } @@ -213,7 +257,7 @@ impl SelectionProblem { /// `false` means every fee calculation reduces to the plain (child-only) case, which lets /// branch and bound use the tighter bounds that assume monotone funding. pub fn has_ancestors(&self) -> bool { - self.has_ancestors + self.has_private_ancestors || self.has_shared_ancestors } /// Ancestor indices dragged in by selecting candidate `index`. @@ -221,23 +265,47 @@ impl SelectionProblem { &self.drags_in[index] } - /// The fee still owed so the ancestors in `set` meet [`Target::fee`](crate::TargetFee)'s rate. + /// Summed `(weight, fee)` of the ancestors only candidate `index` can drag in. + /// + /// Deliberately not reduced to a bump: the target rate applies to the total ancestor weight of + /// the whole selection at once, and an ancestor paying above the rate must be able to subsidize + /// one paying below it. See [`CoinSelector::ancestor_bump`](crate::CoinSelector::ancestor_bump). + pub fn private_ancestors(&self, index: usize) -> (u64, u64) { + self.private[index] + } + + /// [`drags_in`](Self::drags_in) restricted to the ancestors that several candidates can reach. + /// + /// Those are the only ones that can be dragged in twice over, so they are the only ones a + /// selection has to de-duplicate; the rest are folded into + /// [`private_ancestors`](Self::private_ancestors). + pub fn shared_drags_in(&self, index: usize) -> &Bitset { + &self.shared_drags_in[index] + } + + /// Whether any ancestor is reachable via exactly one candidate. + /// + /// When `false`, every ancestor is shared and [`private_ancestors`](Self::private_ancestors) is + /// `(0, 0)` throughout, so summing it can be skipped. + pub fn has_private_ancestors(&self) -> bool { + self.has_private_ancestors + } + + /// Whether any ancestor is reachable via more than one candidate. /// - /// `set` indexes [`ancestors`](Self::ancestors). Weight and fee are netted over the whole set, - /// so each ancestor is charged exactly once no matter how many candidates drag it in, and an - /// overpaying ancestor offsets an underpaying one. Saturates at 0. - pub fn ancestor_bump(&self, set: &Bitset) -> u64 { - bump_of(&self.ancestors, self.target.fee.rate, set) + /// When `false`, what a selection owes is a plain sum over its selected candidates — nothing has + /// to be de-duplicated. + pub fn has_shared_ancestors(&self) -> bool { + self.has_shared_ancestors } - /// Local (per-candidate) bump fee for candidate `index`, in satoshis. + /// The fee still owed so the ancestors only this candidate would drag in meet + /// [`Target::fee`](crate::TargetFee)'s rate, as if it were the only selected candidate. /// - /// This is what candidate `index` would owe *on its own*. It is informational only: these - /// figures must never be summed over a selection, because candidates sharing an ancestor would - /// each pay for it. Use [`ancestor_bump`](Self::ancestor_bump) over the union instead (which is - /// what [`CoinSelector`] does). + /// Informational: must never be summed over a selection (shared ancestors would be charged + /// twice). What a selection owes is [`CoinSelector::ancestor_bump`](crate::CoinSelector::ancestor_bump). pub fn local_bump(&self, index: usize) -> u64 { - self.local_bump[index] + bump_of(&self.ancestors, self.target.fee.rate, self.drags_in(index)) } /// A [`CoinSelector`] over this problem. diff --git a/tests/ancestor.proptest-regressions b/tests/ancestor.proptest-regressions index a4f1579..a9d66c7 100644 --- a/tests/ancestor.proptest-regressions +++ b/tests/ancestor.proptest-regressions @@ -5,3 +5,5 @@ # It is recommended to check this file in to source control so that # everyone who runs the test benefits from these saved cases. cc 89e69058dd34f3215542f3ec44fbccc338577a6bfab488505a5e83dc5c1e88f6 # shrinks to spec = AncestorProblemSpec { candidates: [(1000, 200, 0), (1000, 200, 4)], ancestors: [(200, 0, 0)], target_value: 10000, feerate: 1.0, max_weight: None } +cc a13749585a44ffd51e890b50e8c58d6fc505895069ea32e5f211cc18fb96eacc # shrinks to spec = AncestorProblemSpec { candidates: [(24030, 664, 0), (114687, 721, 1), (58432, 646, 3), (66845, 200, 1)], ancestors: [(200, 1117, 0), (1977, 0, 1)], target_value: 172127, feerate: 2.2867246, max_weight: None } +cc 0028100a2247a35fd2ec3aa9bace2d5c5062812f0378553aac4da2e7016bf5c1 # shrinks to spec = AncestorProblemSpec { candidates: [(104036, 200, 0), (1000, 200, 1), (132930, 200, 2)], ancestors: [(600, 0, 0), (200, 233, 0)], target_value: 237606, feerate: 1.2605457, max_weight: None } diff --git a/tests/ancestor.rs b/tests/ancestor.rs index 54e8c2f..bf80b89 100644 --- a/tests/ancestor.rs +++ b/tests/ancestor.rs @@ -393,6 +393,269 @@ fn changeless_solution_reachable_only_via_an_ancestor_is_not_pruned() { assert_eq!(score, Ordf32(11_000.0)); } +// --- the bump lower bound used by `LowestFee`'s bound --- + +/// With nothing overpaying within reach, no descendant can owe less than this selection does, so the +/// lower bound is the full bump — the figure branch and bound gets to keep. +#[test] +fn bump_lower_bound_is_the_full_bump_when_nothing_overpays() { + let t = target(10.0, 10_000); + let problem = SelectionProblem::new( + t, + [ + input(50_000, "P"), + input(60_000, "Q"), + input(70_000, CONFIRMED), + ], + [ + ancestor("P", 1_000, 0, vec![]), + ancestor("Q", 2_000, 0, vec![]), + ], + ); + + let mut cs = problem.selector(); + cs.select(0); + assert_eq!(cs.ancestor_bump(), 2_500); + assert_eq!( + cs.ancestor_bump_lower_bound(), + 2_500, + "Q only ever adds to what is owed, so it cannot lower the floor" + ); + + // The reachable-but-unselected ancestors are exactly Q's. + let addable: Vec<_> = cs.addable_ancestors().iter().collect(); + assert_eq!(addable, vec![1]); +} + +/// A reachable ancestor that overpays is exactly what a descendant could use to owe less, so the +/// bound gives up precisely that surplus and no more. +#[test] +fn bump_lower_bound_gives_up_the_reachable_surplus() { + let t = target(1.0, 10_000); // 0.25 sat/wu + let problem = SelectionProblem::new( + t, + [input(50_000, "POOR"), input(50_000, "RICH")], + [ + ancestor("POOR", 4_000, 0, vec![]), // owes 1_000 + ancestor("RICH", 400, 10_000, vec![]), // overpays by 9_900 + ], + ); + + let mut cs = problem.selector(); + cs.select(0); + assert_eq!(cs.ancestor_bump(), 1_000); + assert_eq!( + cs.ancestor_bump_lower_bound(), + 0, + "RICH's 9_900 surplus swamps the 1_000 owed" + ); + + // Which is not pessimism: that descendant really does owe nothing. + let mut both = cs.clone(); + both.select(1); + assert_eq!(both.ancestor_bump(), 0); +} + +/// Only the surplus actually within reach is given up. +#[test] +fn bump_lower_bound_only_credits_reachable_surplus() { + let t = target(1.0, 10_000); // 0.25 sat/wu + let problem = SelectionProblem::new( + t, + [input(50_000, "POOR"), input(50_000, "RICH")], + [ + ancestor("POOR", 4_000, 0, vec![]), // owes 1_000 + ancestor("RICH", 400, 1_100, vec![]), // overpays by 1_000 + ], + ); + + let mut cs = problem.selector(); + cs.select(0); + assert_eq!(cs.ancestor_bump(), 1_000); + assert_eq!(cs.ancestor_bump_lower_bound(), 0); + + // Ban the coin that would bring RICH in and the surplus is out of reach again. + let mut banned = cs.clone(); + banned.ban(1); + assert!(banned.addable_ancestors().is_empty()); + assert_eq!(banned.ancestor_bump_lower_bound(), 1_000); + + // Likewise once there is nothing left to add. + let mut exhausted = cs.clone(); + exhausted.select(1); + assert!(exhausted.is_exhausted()); + assert_eq!( + exhausted.ancestor_bump_lower_bound(), + exhausted.ancestor_bump() + ); +} + +/// The whole point: the fee floor `LowestFee` bounds with actually charges for the ancestors. +#[test] +fn bound_credits_the_bump_when_nothing_overpays() { + let t = target(10.0, 90_000); + let problem = SelectionProblem::new( + t, + [input(100_000, "P"), input(100_000, CONFIRMED)], + [ancestor("P", 1_000, 0, vec![])], + ); + + let mut cs = problem.selector(); + cs.select(0); + + let child_fee = t + .fee + .rate + .implied_fee_wu(cs.weight(t.outputs, DrainWeights::NONE)); + let bound = metric().bound(&cs).expect("within max_weight"); + assert!( + bound >= Ordf32((child_fee + 2_500) as f32), + "bound {} must charge the child's own fee ({}) plus the 2_500 bump", + bound, + child_fee + ); +} + +/// An ancestor only one candidate can reach is folded into that candidate up front; the rest are +/// left to be de-duplicated per selection. +#[test] +fn ancestors_are_split_into_private_and_shared() { + let t = target(10.0, 10_000); + let problem = SelectionProblem::new( + t, + [ + vec![input(50_000, "MINE")], + vec![input(50_000, "OURS")], + vec![input(50_000, "OURS")], + ], + [ + ancestor("MINE", 1_000, 7, vec![]), + ancestor("OURS", 2_000, 9, vec![]), + ], + ); + + assert!(problem.has_shared_ancestors()); + + // Candidate 0 is the only one that can reach MINE, so it is charged for it directly. + assert_eq!(problem.private_ancestors(0), (1_000, 7)); + assert!(problem.shared_drags_in(0).is_empty()); + + // OURS is reachable two ways, so it stays in the shared set for both. + assert_eq!(problem.private_ancestors(1), (0, 0)); + assert_eq!(problem.private_ancestors(2), (0, 0)); + assert_eq!( + problem.shared_drags_in(1).iter().collect::>(), + vec![1] + ); + assert_eq!( + problem.shared_drags_in(2).iter().collect::>(), + vec![1] + ); + + // Either way `drags_in` still describes the full truth. + assert_eq!(problem.drags_in(0).iter().collect::>(), vec![0]); + assert_eq!(problem.drags_in(1).iter().collect::>(), vec![1]); + + // And a problem where nothing is shared says so, which is what lets the bump skip + // de-duplication entirely. + let unshared = SelectionProblem::new( + t, + [input(50_000, "MINE")], + [ancestor("MINE", 1_000, 0, vec![])], + ); + assert!(!unshared.has_shared_ancestors()); + assert!(unshared.has_ancestors()); +} + +/// Surplus cannot be cherry-picked: an ancestor arrives only by selecting a candidate, which drags +/// in that candidate's whole chain. So a coin whose parent overpays but whose grandparent does not +/// offers no way to owe less, and the bound must not pretend otherwise. +#[test] +fn bump_lower_bound_nets_ancestors_that_must_arrive_together() { + let t = target(1.0, 10_000); // 0.25 sat/wu + let problem = SelectionProblem::new( + t, + [input(50_000, "POOR"), input(50_000, "RICH")], + [ + ancestor("POOR", 4_000, 0, vec![]), // owes 1_000 + // Selecting the second coin brings RICH *and* its unpaid parent GRAN. + ancestor("GRAN", 8_000, 0, vec![]), // owes 2_000 + ancestor("RICH", 400, 10_000, vec!["GRAN"]), // overpays by 9_900 + ], + ); + + let mut cs = problem.selector(); + cs.select(0); + assert_eq!(cs.ancestor_bump(), 1_000); + + // RICH's 9_900 surplus is real, but only comes with GRAN's 2_000 deficit: still a net surplus. + assert_eq!(cs.ancestor_bump_lower_bound(), 0); + let mut both = cs.clone(); + both.select(1); + assert_eq!( + both.ancestor_bump(), + 0, + "that descendant really owes nothing" + ); + + // Now make the chain's deficit outweigh the surplus. Crediting RICH alone would wrongly drop the + // bound to 0; netting the chain keeps the full bump. + let deep = SelectionProblem::new( + t, + [input(50_000, "POOR"), input(50_000, "RICH")], + [ + ancestor("POOR", 4_000, 0, vec![]), + ancestor("GRAN", 80_000, 0, vec![]), // owes 20_000 + ancestor("RICH", 400, 10_000, vec!["GRAN"]), + ], + ); + let mut cs = deep.selector(); + cs.select(0); + assert_eq!(cs.ancestor_bump(), 1_000); + assert_eq!( + cs.ancestor_bump_lower_bound(), + 1_000, + "taking RICH means taking GRAN, which costs far more than RICH's surplus is worth" + ); + + let mut both = cs.clone(); + both.select(1); + assert!( + both.ancestor_bump() > 1_000, + "confirmed by the descendant, which owes more, not less" + ); +} + +/// An ancestor several candidates can reach cannot be tied to any one of them, so its surplus is +/// credited on its own rather than netted against a particular candidate's other ancestors. +#[test] +fn bump_lower_bound_credits_shared_surplus_on_its_own() { + let t = target(1.0, 10_000); // 0.25 sat/wu + let problem = SelectionProblem::new( + t, + [ + vec![input(50_000, "POOR")], + // Both of these reach RICH; the second also drags in its own expensive chain. + vec![input(50_000, "RICH")], + vec![input(50_000, "RICH"), input(50_000, "HEAVY")], + ], + [ + ancestor("POOR", 4_000, 0, vec![]), // owes 1_000 + ancestor("RICH", 400, 10_000, vec![]), // overpays by 9_900 + ancestor("HEAVY", 80_000, 0, vec![]), // owes 20_000 + ], + ); + + let mut cs = problem.selector(); + cs.select(0); + assert_eq!(cs.ancestor_bump(), 1_000); + assert_eq!( + cs.ancestor_bump_lower_bound(), + 0, + "RICH is reachable without HEAVY, so its surplus counts" + ); +} + // --- randomized cross-checks --- /// Spec for a randomly generated ancestor problem. Indices are taken modulo the relevant length so @@ -500,6 +763,40 @@ proptest! { } } + /// The bump lower bound must hold for the whole subtree, which is what lets the fee floor credit + /// it: no selection reachable from a node may owe less than the node's bound says. + #[test] + fn bump_lower_bound_holds_for_every_descendant(spec in spec_strategy()) { + let problem = spec.build(); + let root = problem.selector(); + + let nodes = std::iter::once(root.clone()).chain( + common::ExhaustiveIter::new(&root) + .into_iter() + .flatten() + .map(|(node, _)| node), + ); + + for node in nodes { + let lower_bound = node.ancestor_bump_lower_bound(); + prop_assert!( + lower_bound <= node.ancestor_bump(), + "node={} lb={} owes={}", node, lower_bound, node.ancestor_bump() + ); + + for (descendant, inclusion) in common::ExhaustiveIter::new(&node).into_iter().flatten() { + if !inclusion { + continue; + } + prop_assert!( + lower_bound <= descendant.ancestor_bump(), + "node={} lb={} descendant={} owes={}", + node, lower_bound, descendant, descendant.ancestor_bump() + ); + } + } + } + /// The bound must never exceed the score of any selection in its subtree (else branch and bound /// can prune the optimum), and `None` must really mean "nothing in this subtree is valid". #[test] From 8962079c19aacda3433cca0cd9a318ad668e66ba Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=BF=97=E5=AE=87?= Date: Thu, 13 Aug 2026 12:04:11 +0000 Subject: [PATCH 07/26] perf: tighten ancestor-aware LowestFee bound For funded nodes, subtract the ancestor surplus still reachable by a descendant. For unfunded nodes, derive a minimum added child weight from independent fractional relaxations of the target-rate, absolute-fee, and RBF constraints, then evaluate the fee floor at that weight. Candidate ancestry is deliberately represented only by the global bump lower bound: package surplus can absorb a later private deficit, so a per-candidate ancestor cost is not admissible. Keep infeasibility prunes off because ancestor funding is non-monotone. Add regressions for package subsidy, absolute/RBF double counting, and large-float cancellation, plus the existing exhaustive proptests. --- src/metrics/lowest_fee.rs | 146 ++++++++++++++++++++++++------ tests/ancestor.rs | 186 +++++++++++++++++++++++++++++++++++++- 2 files changed, 302 insertions(+), 30 deletions(-) diff --git a/src/metrics/lowest_fee.rs b/src/metrics/lowest_fee.rs index 49a9e2d..b84dd6d 100644 --- a/src/metrics/lowest_fee.rs +++ b/src/metrics/lowest_fee.rs @@ -23,9 +23,11 @@ use crate::{float::Ordf32, BnbMetric, CoinSelector, Drain, DrainWeights, FeeRate /// coins that drag in nothing (or that share an already-paid-for ancestor). The score itself is /// still the child transaction's fee — the bump is inside it, not added on top. /// -/// The bound is looser in that case (see [`bound`](BnbMetric::bound)): the tight bounds assume -/// funding is monotone and that a candidate costs its own weight, neither of which survives shared -/// or overpaying ancestors. Correctness is kept; the search just explores more. +/// The bound uses a child-weight relaxation when ancestors are present (see +/// [`bound`](BnbMetric::bound)): a funded node uses `score − reachable surplus`, while an unfunded +/// one estimates the least child weight needed to meet each fee constraint. The `None` prunes stay +/// off — funding is not monotone, so "select everything and it's still unfunded" does not mean the +/// subtree is empty. /// /// [`SelectionProblem`]: crate::SelectionProblem #[derive(Clone, Copy)] @@ -106,6 +108,114 @@ impl LowestFee { drain, )) } + + /// Whether a descendant of `cs` could still add both a change output and at least one more + /// input under `max_weight`. Same test as the no-ancestor funded path. + fn change_is_reachable(&self, cs: &CoinSelector<'_>) -> bool { + match cs.target().max_weight { + None => true, + Some(max_weight) => cs.min_input_weight().map_or(false, |min_input_weight| { + cs.weight(cs.target().outputs, self.drain_weights) + min_input_weight <= max_weight + }), + } + } + + /// Tighter than [`CoinSelector::fee_floor`] once the value shortfall proves that every funded + /// descendant must add some child input weight. + /// + /// Never returns `None`: a fat private deficit can un-fund a prefix that a subset would have + /// funded, so infeasibility is not something this path is allowed to claim. (The caller has + /// already hard-pruned on child `max_weight`, which is monotone.) + /// + /// The three fee constraints get independent fractional relaxations. Their maximum is still a + /// lower bound on the real added child weight. Candidate ancestry is ignored and the global bump + /// floor is used instead, avoiding package-surplus double counting. Flooring the fractional + /// weight keeps floating-point error in the safe direction. + fn bound_with_ancestors(&self, cs: &CoinSelector<'_>) -> Ordf32 { + if cs.is_funded() { + let (_, drain) = self.fee_score(cs).unwrap(); + let current_score = cs.fee(cs.target().value(), drain.value) as u64 + + drain.weights.spend_fee(self.long_term_feerate); + let surplus = cs + .ancestor_bump() + .saturating_sub(cs.ancestor_bump_lower_bound()); + let mut bound = current_score.saturating_sub(surplus); + if drain.is_none() { + let cost_of_adding_change = self.drain_weights.waste( + cs.target().fee.rate, + self.long_term_feerate, + cs.target().outputs.n_outputs, + ); + // Subtract the large integer terms before converting anything to float. Casting the + // non-negative waste to u64 floors it, keeping the bound conservative. + let with_change = current_score + .saturating_sub(surplus) + .saturating_sub(cs.excess(Drain::NONE) as u64) + .saturating_add(cost_of_adding_change as u64); + if self.change_is_reachable(cs) { + bound = bound.min(with_change); + } + } + return Ordf32(bound.max(cs.fee_floor()) as f32); + } + + let target = cs.target(); + let bump = cs.ancestor_bump_lower_bound(); + let current_weight = cs.weight(target.outputs, DrainWeights::NONE); + let selected_value = cs.selected_value() as f64; + let value_target = target.value() as f64; + let target_rate = target.fee.rate.spwu() as f64; + let rate_deficit = (value_target + target_rate * current_weight as f64 + bump as f64 + - selected_value) + .max(0.0); + let absolute_deficit = + (value_target + target.fee.absolute as f64 - selected_value).max(0.0); + let (replace_deficit, replace_rate) = target.fee.replace.map_or((0.0, 0.0), |replace| { + let rate = replace.incremental_relay_feerate.spwu() as f64; + ( + (value_target + replace.fee as f64 + rate * current_weight as f64 - selected_value) + .max(0.0), + rate, + ) + }); + + // Scan in f64 rather than trusting the f32 candidate ordering: two exact ratios can tie in + // f32, and choosing the lower one would overstate the required weight. + let mut best_value = 0.0_f64; + let mut weightless_value = false; + for (_, candidate) in cs.unselected() { + if candidate.weight == 0 { + weightless_value |= candidate.value > 0; + } else { + best_value = best_value.max(candidate.value as f64 / candidate.weight as f64); + } + } + let best_rate_gain = (best_value - target_rate).max(0.0); + let best_replace_gain = (best_value - replace_rate).max(0.0); + + // Treat the best candidate as unlimited fractional input. If no positive gain is available, + // or a positive-value zero-weight candidate exists, fall back to zero added weight rather + // than claiming infeasibility. + let weight_for = |deficit: f64, gain_pwu: f64| match (deficit, gain_pwu) { + (deficit, gain) if !weightless_value && deficit > 0.0 && gain > 0.0 => deficit / gain, + _ => 0.0, + }; + let added_weight = weight_for(rate_deficit, best_rate_gain) + .max(weight_for(absolute_deficit, best_value)) + .max(weight_for(replace_deficit, best_replace_gain)); + + // `added_weight` is non-negative, so conversion to u64 truncates (floors) it. + let added_weight = added_weight as u64; + let weight = match current_weight.checked_add(added_weight) { + Some(weight) if added_weight != u64::MAX => weight, + _ => return Ordf32(cs.fee_floor() as f32), + }; + let mut bound = (target.fee.rate.implied_fee_wu(weight) + bump).max(target.fee.absolute); + if let Some(replace) = target.fee.replace { + bound = bound.max(replace.min_fee_to_do_replacement_wu(weight)); + } + Ordf32(bound as f32) + } } impl BnbMetric for LowestFee { @@ -138,25 +248,10 @@ impl BnbMetric for LowestFee { return None; } - // Everything below assumes funding is monotone and that a candidate's cost is its own - // weight — both false once unconfirmed ancestors are in play, where a candidate's marginal - // cost depends on which ancestors the selection already drags in: - // - // - A funded node's score is not a lower bound for its descendants: a descendant can drag - // in an *overpaying* ancestor, which lowers the netted bump (see - // `CoinSelector::ancestor_bump`) and so lowers the fee it must pay. - // - The unfunded relaxation below resizes the best value-per-weight candidate. With - // ancestors, value-per-weight is not the true marginal funding efficiency (a candidate - // sharing an already-paid-for ancestor is cheaper than its weight suggests), and its - // `None` returns would claim infeasibility off the back of "select everything and it's - // still unfunded", which no longer implies anything about subsets. - // - // So fall back to the fee floor, which is monotone in weight and never claims - // infeasibility. It still credits what the ancestors owe, but only the least any descendant - // could owe (`CoinSelector::ancestor_bump_lower_bound`) rather than what this selection owes - // — which is the whole bump whenever no reachable ancestor overpays. + // With unconfirmed ancestors, funding is not monotone. Use the child-weight relaxation in + // `bound_with_ancestors`; never claim the subtree is empty. if cs.problem().has_ancestors() { - return Some(Ordf32(cs.fee_floor() as f32)); + return Some(self.bound_with_ancestors(cs)); } if cs.is_funded() { @@ -200,14 +295,7 @@ impl BnbMetric for LowestFee { // of which only make the tx heavier. If there's no room for both under the cap the // improvement is unreachable down this branch, so don't credit it — keep // `current_score` (a tighter, still-admissible bound). - let change_is_reachable = match cs.target().max_weight { - None => true, - Some(max_weight) => cs.min_input_weight().map_or(false, |min_input_weight| { - cs.weight(cs.target().outputs, self.drain_weights) + min_input_weight - <= max_weight - }), - }; - if change_is_reachable && best_score_with_change < current_score { + if self.change_is_reachable(cs) && best_score_with_change < current_score { return Some(best_score_with_change); } } diff --git a/tests/ancestor.rs b/tests/ancestor.rs index bf80b89..96dab52 100644 --- a/tests/ancestor.rs +++ b/tests/ancestor.rs @@ -12,7 +12,7 @@ use bdk_coin_select::{ float::Ordf32, metrics::{Changeless, LowestFee}, AncestorToBump, BnbMetric, Candidate, CoinSelector, Drain, DrainWeights, FeeRate, Input, - SelectionProblem, Target, TargetFee, TargetOutputs, TX_FIXED_FIELD_WEIGHT, + Replace, SelectionProblem, Target, TargetFee, TargetOutputs, TX_FIXED_FIELD_WEIGHT, }; use proptest::prelude::*; @@ -567,6 +567,190 @@ fn ancestors_are_split_into_private_and_shared() { assert!(unshared.has_ancestors()); } +/// A funded node's bound must give up the surplus a descendant could still pick up — otherwise it +/// sits above that descendant's score. +#[test] +fn funded_bound_gives_up_reachable_surplus() { + let t = target(1.0, 10_000); // 0.25 sat/wu + let problem = SelectionProblem::new( + t, + [input(50_000, "POOR"), input(50_000, "RICH")], + [ + ancestor("POOR", 4_000, 0, vec![]), // owes 1_000 + ancestor("RICH", 400, 10_000, vec![]), // overpays by 9_900 + ], + ); + + let mut cs = problem.selector(); + cs.select(0); + assert!(cs.is_funded()); + assert_eq!(cs.ancestor_bump(), 1_000); + assert_eq!(cs.ancestor_bump_lower_bound(), 0); + + let score = metric().score(&cs).unwrap(); + let bound = metric().bound(&cs).unwrap(); + assert!( + bound <= Ordf32(score.0 - 1_000.0), + "bound {} must sit at least the 1_000 surplus below score {}", + bound, + score + ); + + let mut both = cs.clone(); + both.select(1); + let both_score = metric().score(&both).unwrap(); + assert!( + bound <= both_score, + "bound {} above descendant score {}", + bound, + both_score + ); +} + +/// Subtracting two large `f32`s can round the bound upward. Surplus is therefore subtracted in +/// integer space before the result is converted to the metric's `f32` score. +#[test] +fn funded_bound_subtracts_surplus_before_float_conversion() { + let t = Target { + fee: TargetFee { + rate: FeeRate::from_sat_per_vb(20_000.0), // 5_000 sat/wu + absolute: 0, + replace: None, + }, + outputs: TargetOutputs { + value_sum: 0, + weight_sum: 100, + n_outputs: 1, + }, + max_weight: None, + }; + let problem = SelectionProblem::new( + t, + [ + Input { + value: 1_998_700_000, + weight: 0, + is_segwit: false, + residing_txid: "POOR", + }, + Input { + value: 0, + weight: 0, + is_segwit: false, + residing_txid: "RICH", + }, + ], + [ + ancestor("POOR", 400_000, 2_000_000, vec![]), // owes 1_998_000_000 + ancestor("RICH", 0, 1_998_000_000, vec![]), // cancels POOR exactly + ], + ); + let mut metric = LowestFee { + long_term_feerate: FeeRate::from_sat_per_vb(1.0), + dust_relay_feerate: FeeRate::from_sat_per_vb(1.0), + drain_weights: DrainWeights::NONE, + }; + + let mut node = problem.selector(); + node.select(0); + assert_eq!(node.ancestor_bump(), 1_998_000_000); + assert_eq!(node.ancestor_bump_lower_bound(), 0); + let bound = metric.bound(&node).unwrap(); + + let mut descendant = node.clone(); + descendant.select(1); + let score = metric.score(&descendant).unwrap(); + assert_eq!(score, Ordf32(700_000.0)); + assert!(bound <= score, "bound {} above descendant {}", bound, score); +} + +/// Selecting everything can un-fund, but that must not make the bound claim the subtree is empty. +#[test] +fn unfunded_bound_does_not_claim_infeasibility() { + let t = target(10.0, 90_000); + let problem = SelectionProblem::new( + t, + [input(100_000, CONFIRMED), input(100_000, "P")], + [ancestor("P", 100_000, 0, vec![])], + ); + + let cs = problem.selector(); + assert!(!cs.is_funded()); + assert!( + metric().bound(&cs).is_some(), + "an unfunded root with a live funded subset must not be pruned" + ); +} + +/// Existing package surplus can pay a later candidate's private deficit. Pricing that deficit as +/// the candidate's marginal cost would put the bound above the descendant's score. +#[test] +fn unfunded_bound_credits_selected_package_surplus() { + let t = target(1.0, 100_000); // 0.25 sat/wu + let problem = SelectionProblem::new( + t, + [input(60_000, "RICH"), input(50_000, "POOR")], + [ + ancestor("RICH", 400, 10_000, vec![]), // surplus 9_900 + ancestor("POOR", 4_000, 0, vec![]), // deficit 1_000 + ], + ); + + let mut node = problem.selector(); + node.select(0); + assert!(!node.is_funded()); + + let bound = metric().bound(&node).unwrap(); + let mut descendant = node.clone(); + descendant.select(1); + let score = metric().score(&descendant).unwrap(); + assert!( + bound <= score, + "bound {} above package-subsidized descendant {}", + bound, + score + ); +} + +/// The absolute fee is already the final child fee floor; the resize must not add target-rate +/// marginal cost on top of it. +#[test] +fn unfunded_bound_does_not_double_count_absolute_fee() { + let mut t = target(1.0, 100_000); + t.fee.absolute = 5_000; + let problem = + SelectionProblem::new(t, [input(105_000, "P")], [ancestor("P", 4_000, 0, vec![])]); + + let root = problem.selector(); + let bound = metric().bound(&root).unwrap(); + let mut descendant = root.clone(); + descendant.select(0); + let score = metric().score(&descendant).unwrap(); + assert_eq!(score, Ordf32(5_000.0)); + assert!(bound <= score, "bound {} above descendant {}", bound, score); +} + +/// RBF rule 4 prices only child weight. Ancestor weight must not enter its effective value, and the +/// replacement floor must not be charged twice. +#[test] +fn unfunded_bound_does_not_double_count_rbf_fee() { + let mut t = target(1.0, 100_000); + t.fee.replace = Some(Replace { + fee: 5_000, + incremental_relay_feerate: FeeRate::from_sat_per_vb(1.0), + }); + let problem = + SelectionProblem::new(t, [input(105_104, "P")], [ancestor("P", 4_000, 0, vec![])]); + + let root = problem.selector(); + let bound = metric().bound(&root).unwrap(); + let mut descendant = root.clone(); + descendant.select(0); + let score = metric().score(&descendant).unwrap(); + assert_eq!(score, Ordf32(5_104.0)); + assert!(bound <= score, "bound {} above descendant {}", bound, score); +} + /// Surplus cannot be cherry-picked: an ancestor arrives only by selecting a candidate, which drags /// in that candidate's whole chain. So a coin whose parent overpays but whose grandparent does not /// offers no way to owe less, and the bound must not pretend otherwise. From c4938f67010c3a150b573f07e1b360837718d479 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=BF=97=E5=AE=87?= Date: Fri, 14 Aug 2026 04:52:28 +0000 Subject: [PATCH 08/26] perf!: make BnB metrics delta-aware Maintain aggregate selection state per branch and expose it through SelectionView so metric evaluation avoids repeatedly walking selected candidates. Track each branch's candidate cursor to skip repeated scans, and extend benchmarks across wallet- and exchange-scale pools. --- CHANGELOG.md | 4 +- Cargo.toml | 5 + README.md | 7 +- benches/coin_selector.rs | 62 +++- src/bnb.rs | 104 ++++--- src/coin_selector.rs | 26 +- src/drain.rs | 6 +- src/lib.rs | 5 +- src/metrics/changeless.rs | 12 +- src/metrics/lowest_fee.rs | 37 ++- src/selection_view.rs | 608 ++++++++++++++++++++++++++++++++++++++ tests/ancestor.rs | 40 +-- tests/bnb.rs | 45 ++- tests/common.rs | 29 +- tests/lowest_fee.rs | 9 +- tests/weight.rs | 19 +- 16 files changed, 885 insertions(+), 133 deletions(-) create mode 100644 src/selection_view.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index 80a38b1..25211cd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,7 +1,8 @@ # Unreleased - **Breaking:** Replace `Candidate`'s `input_count` and `is_segwit` fields with `segwit_count` and `legacy_count`, fixing `CoinSelector::input_weight` undercounting candidates that group multiple inputs: in a segwit transaction every legacy input still serializes an empty witness (1 WU), which was previously paid once per candidate instead of once per legacy input, so a group of N legacy inputs came out N-1 WU short. Splitting the count by script type also means a single candidate may now mix legacy and segwit inputs and still be priced exactly. Replaces `Candidate::new` with `Candidate::new_segwit` and `Candidate::new_legacy`. -- **Breaking:** `BnbMetric`'s `score`, `bound`, and `drain` take the `target: Target` as a parameter, and `CoinSelector::run_bnb`/`bnb_solutions` gain a leading `target` argument. Consequently `LowestFee` and `Changeless` no longer store a `target` field. This removes the target that `Changeless` previously had to keep in sync with its inner metric, and aligns the metric API with the rest of `CoinSelector`, where `target` is always passed in. +- Add `SelectionView`, a cached read-only view obtained with `CoinSelector::compute_view`. `BnbMetric::{score, bound, drain}` now consume `&SelectionView`; branch and bound maintains its aggregates incrementally while the selector continues to own its `SelectionProblem` and target. +- Add a per-branch cursor to avoid repeatedly scanning already-decided candidates during branch-and-bound search. - **Breaking:** `BnbMetric` metrics now decide the change output themselves. The trait gains a `drain(&mut self, cs) -> Drain` method; call it on a branch-and-bound solution (or the `LowestFee` metric directly) to get the change output the metric optimized against, instead of computing a separate `ChangePolicy`. - **Breaking:** `CoinSelector::run_bnb` now returns `(Ordf32, Drain)` instead of just `Ordf32`, handing back the change output the metric decided on for the winning selection. - **Breaking:** `LowestFee` no longer takes a `change_policy`. It now takes `dust_relay_feerate: FeeRate` and `drain_weights: DrainWeights`, and adds change only when doing so lowers the long-term fee and the change would not be dust. @@ -28,4 +29,3 @@ - No more `base_weight` in `CoinSelector`. Weight of the outputs is tracked in `target`. - You now account for the number of outputs in both drain and target and their weight. - Removed waste metric because it was pretty broken and took a lot to maintain - diff --git a/Cargo.toml b/Cargo.toml index 14cabad..dba3e7f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -29,3 +29,8 @@ criterion = "0.5" [[bench]] name = "coin_selector" harness = false + +# Enable debug symbols so profilers (perf, samply, flamegraph) can resolve +# function names. No runtime cost. +[profile.bench] +debug = true diff --git a/README.md b/README.md index fcc5843..b42370c 100644 --- a/README.md +++ b/README.md @@ -136,6 +136,10 @@ let target = Target { let problem = SelectionProblem::new_no_ancestors(target, candidates); let mut coin_selector = CoinSelector::new(&problem); +// For repeated read-only calculations, compute a cached view of the current selection. +let empty_view = coin_selector.compute_view(); +assert_eq!(empty_view.selected_value(), 0); + // The feerate used to work out whether a change output would be dust (and so shouldn't be added). // The standard dust relay feerate is 3 sat/vb. let dust_relay_feerate = FeeRate::from_sat_per_vb(3.0); @@ -157,7 +161,7 @@ let change = match coin_selector.run_bnb(metric, 100_000) { // fall back to naive selection coin_selector.select_until_target_met().expect("a selection was impossible!"); // the metric still decides the change output for whatever we end up selecting - metric.drain(&coin_selector) + metric.drain(&coin_selector.compute_view()) } Ok((score, change)) => { println!("we found a solution with score {}", score); @@ -179,4 +183,3 @@ println!("We are including a change output of {} value (0 means not change)", ch # Minimum Supported Rust Version (MSRV) This library is compiles on rust v1.54 and above - diff --git a/benches/coin_selector.rs b/benches/coin_selector.rs index 7a13dc0..a1b72ec 100644 --- a/benches/coin_selector.rs +++ b/benches/coin_selector.rs @@ -1,6 +1,9 @@ //! Benchmarks for `CoinSelector`. //! -//! Three groups: +//! Groups include selector construction and cloning, cached-view construction, and end-to-end BnB +//! with and without ancestors. Linear operations cover wallet (~1k) through exchange (~10M) pools; +//! BnB sizes remain moderate because its search space is exponential. +//! //! - `clone`: direct cost of `CoinSelector::clone()`, the operation `Bitset` //! was introduced to make cheap. //! - `run_bnb_lowest_fee`: end-to-end Branch-and-Bound throughput on a @@ -24,6 +27,9 @@ use bdk_coin_select::{ use criterion::{criterion_group, criterion_main, BatchSize, BenchmarkId, Criterion}; use std::hint::black_box; +const LARGE_N: &[usize] = &[64, 1_024, 16_384, 262_144, 1_048_576, 10_000_000]; +const SPARSE_SELECTED: usize = 100; + /// Deterministic synthetic pool of P2WPKH-shaped UTXOs. /// /// Values grow super-linearly so the pool resembles a real wallet's mix of @@ -33,7 +39,7 @@ fn make_candidates(n: usize) -> Vec { (0..n) .map(|i| { let i = i as u64; - let value = 1_000 + i * 137 + i * i; + let value = 1_000 + i.wrapping_mul(137).wrapping_add(i.wrapping_mul(i)); Candidate { value, weight: TXIN_BASE_WEIGHT + P2WPKH_SAT_W, @@ -44,10 +50,34 @@ fn make_candidates(n: usize) -> Vec { .collect() } +fn select_sparse(selector: &mut CoinSelector<'_>, n: usize) { + let count = SPARSE_SELECTED.min(n); + let stride = (n / count.max(1)).max(1); + for index in (0..n).step_by(stride).take(count) { + selector.select(index); + } +} + +fn bench_coin_selector_new(c: &mut Criterion) { + let mut group = c.benchmark_group("new"); + group.sample_size(20); + for &n in LARGE_N { + let candidates = make_candidates(n); + let (target, _) = make_bnb_inputs(&candidates); + let problem = SelectionProblem::new_no_ancestors(target, candidates.iter().copied()); + group.bench_with_input(BenchmarkId::from_parameter(n), &n, |b, _| { + b.iter(|| black_box(CoinSelector::new(&problem))); + }); + } + group.finish(); +} + fn make_bnb_inputs(candidates: &[Candidate]) -> (Target, FeeRate) { let target_fr = FeeRate::from_sat_per_vb(2.0); let long_term_fr = FeeRate::from_sat_per_vb(10.0); - let total: u64 = candidates.iter().map(|c| c.value).sum(); + let total = candidates + .iter() + .fold(0_u64, |sum, candidate| sum.wrapping_add(candidate.value)); let target = Target { fee: TargetFee::from_feerate(target_fr), outputs: TargetOutputs::fund_outputs([(TXOUT_BASE_WEIGHT + TR_SPK_WEIGHT, total / 2)]), @@ -58,15 +88,13 @@ fn make_bnb_inputs(candidates: &[Candidate]) -> (Target, FeeRate) { fn bench_coin_selector_clone(c: &mut Criterion) { let mut group = c.benchmark_group("clone"); - for &n in &[64usize, 256, 1024, 4096] { + group.sample_size(20); + for &n in LARGE_N { let candidates = make_candidates(n); let (target, _) = make_bnb_inputs(&candidates); let problem = SelectionProblem::new_no_ancestors(target, candidates.iter().copied()); let mut selector = CoinSelector::new(&problem); - // Select ~10% of candidates so `selected` is non-trivial to copy. - for i in (0..n).step_by(10) { - selector.select(i); - } + select_sparse(&mut selector, n); group.bench_with_input(BenchmarkId::from_parameter(n), &n, |b, _| { b.iter(|| black_box(selector.clone())); }); @@ -74,6 +102,22 @@ fn bench_coin_selector_clone(c: &mut Criterion) { group.finish(); } +fn bench_compute_view(c: &mut Criterion) { + let mut group = c.benchmark_group("compute_view"); + group.sample_size(20); + for &n in LARGE_N { + let candidates = make_candidates(n); + let (target, _) = make_bnb_inputs(&candidates); + let problem = SelectionProblem::new_no_ancestors(target, candidates.iter().copied()); + let mut selector = CoinSelector::new(&problem); + select_sparse(&mut selector, n); + group.bench_with_input(BenchmarkId::from_parameter(n), &n, |b, _| { + b.iter(|| black_box(selector.compute_view().selected_value())); + }); + } + group.finish(); +} + fn bench_run_bnb_lowest_fee(c: &mut Criterion) { let mut group = c.benchmark_group("run_bnb_lowest_fee"); // Cap iterations so the largest case fits in a benchmark sample. @@ -197,7 +241,9 @@ fn bench_run_bnb_lowest_fee_ancestors(c: &mut Criterion) { criterion_group!( benches, + bench_coin_selector_new, bench_coin_selector_clone, + bench_compute_view, bench_run_bnb_lowest_fee, bench_run_bnb_lowest_fee_ancestors ); diff --git a/src/bnb.rs b/src/bnb.rs index 245384a..c76d389 100644 --- a/src/bnb.rs +++ b/src/bnb.rs @@ -1,6 +1,6 @@ use core::cmp::Reverse; -use crate::{float::Ordf32, Drain}; +use crate::{float::Ordf32, Drain, SelectionCache, SelectionView}; use super::CoinSelector; use alloc::collections::BinaryHeap; @@ -49,11 +49,20 @@ impl<'a, M: BnbMetric> Iterator for BnbIter<'a, M> { // self.metric.score(&branch.selector), // ); - let selector = branch.selector; + let Branch { + selector, + cache, + is_exclusion, + cursor, + .. + } = branch; let mut return_val = None; - if !branch.is_exclusion { - if let Some(score) = self.metric.score(&selector) { + if !is_exclusion { + if let Some(score) = self + .metric + .score(&SelectionView::with_cache(&selector, &cache)) + { let better = match self.best { Some(best_score) => score < best_score, None => true, @@ -65,7 +74,7 @@ impl<'a, M: BnbMetric> Iterator for BnbIter<'a, M> { }; } - self.insert_new_branches(&selector); + self.insert_new_branches(&selector, &cache, cursor); Some(return_val.map(|score| (selector, score))) } } @@ -82,13 +91,20 @@ impl<'a, M: BnbMetric> BnbIter<'a, M> { selector.sort_candidates_by_descending_value_pwu(); } - iter.consider_adding_to_queue(&selector, false); + let cache = SelectionCache::from_selector(&selector); + iter.consider_adding_to_queue(&selector, &cache, false, 0); iter } - fn consider_adding_to_queue(&mut self, cs: &CoinSelector<'a>, is_exclusion: bool) { - let bound = self.metric.bound(cs); + fn consider_adding_to_queue( + &mut self, + cs: &CoinSelector<'a>, + cache: &SelectionCache, + is_exclusion: bool, + cursor: usize, + ) { + let bound = self.metric.bound(&SelectionView::with_cache(cs, cache)); if let Some(bound) = bound { let is_good_enough = match self.best { Some(best) => best > bound, @@ -98,7 +114,9 @@ impl<'a, M: BnbMetric> BnbIter<'a, M> { let branch = Branch { lower_bound: bound, selector: cs.clone(), + cache: cache.clone(), is_exclusion, + cursor, }; /*println!( "\t\t(PUSH) branch={} inclusion={} lb={:?} score={:?}", @@ -127,43 +145,61 @@ impl<'a, M: BnbMetric> BnbIter<'a, M> { }*/ } - fn insert_new_branches(&mut self, cs: &CoinSelector<'a>) { - let (next_index, next) = match cs.unselected().next() { - Some(c) => c, - None => return, // exhausted + fn insert_new_branches(&mut self, cs: &CoinSelector<'a>, cache: &SelectionCache, start: usize) { + let mut iter = cs.candidates().skip(start); + let mut cursor = start; + let (next_index, next) = loop { + match iter.next() { + None => return, + Some((index, candidate)) => { + if !cs.is_selected(index) && !cs.banned().contains(index) { + break (index, candidate); + } + cursor += 1; + } + } }; let mut inclusion_cs = cs.clone(); + let mut inclusion_cache = cache.clone(); inclusion_cs.select(next_index); - self.consider_adding_to_queue(&inclusion_cs, false); + inclusion_cache.add(cs.problem(), next_index, next); + self.consider_adding_to_queue(&inclusion_cs, &inclusion_cache, false, cursor + 1); // For the exclusion branch, we keep banning candidates that are interchangeable with the one // we just excluded: same value and weight, and dragging in exactly the same unconfirmed // ancestors (two coins of equal value and weight are *not* interchangeable if one of them // drags in an ancestor that needs bumping). Candidates are only compared until the first // mismatch, since this exploits them being adjacent in the sorted order. - let mut is_first_ban = true; let mut exclusion_cs = cs.clone(); - let to_ban = (next.value, next.weight); + let to_ban = ( + next.value, + next.weight, + next.segwit_count, + next.legacy_count, + ); let to_ban_drags_in = cs.problem().drags_in(next_index); - for (next_index, next) in cs.unselected() { - if (next.value, next.weight) != to_ban + exclusion_cs.ban(next_index); + let mut exclusion_cursor = cursor + 1; + for (next_index, next) in iter { + if cs.is_selected(next_index) || cs.banned().contains(next_index) { + exclusion_cursor += 1; + continue; + } + if ( + next.value, + next.weight, + next.segwit_count, + next.legacy_count, + ) != to_ban || cs.problem().drags_in(next_index) != to_ban_drags_in { break; } - let (_index, _candidate) = exclusion_cs - .candidates() - .find(|(i, _)| *i == next_index) - .expect("must have index since we are planning to ban it"); - if is_first_ban { - is_first_ban = false; - } /*else { - println!("banning: [{}] {:?}", _index, _candidate); - }*/ exclusion_cs.ban(next_index); + exclusion_cursor += 1; } - self.consider_adding_to_queue(&exclusion_cs, true); + self.consider_adding_to_queue(&exclusion_cs, cache, true, exclusion_cursor); } } @@ -171,7 +207,9 @@ impl<'a, M: BnbMetric> BnbIter<'a, M> { struct Branch<'a> { lower_bound: Ordf32, selector: CoinSelector<'a>, + cache: SelectionCache, is_exclusion: bool, + cursor: usize, } impl Ord for Branch<'_> { @@ -206,25 +244,25 @@ impl Eq for Branch<'_> {} /// /// This is to be used as input for [`CoinSelector::run_bnb`] or [`CoinSelector::bnb_solutions`]. pub trait BnbMetric { - /// Get the score of a given selection for `target`. + /// Get the score of a given selection. /// /// If this returns `None`, the selection is invalid. - fn score(&mut self, cs: &CoinSelector<'_>) -> Option; + fn score(&mut self, view: &SelectionView<'_>) -> Option; - /// Get the lower bound score using a heuristic for `target`. + /// Get the lower bound score using a heuristic. /// /// This represents the best possible score of all descendant branches (according to the /// heuristic). /// /// If this returns `None`, the current branch and all descendant branches will not have valid /// solutions. - fn bound(&mut self, cs: &CoinSelector<'_>) -> Option; + fn bound(&mut self, view: &SelectionView<'_>) -> Option; - /// The change output (a.k.a. drain) this metric decides on for the given selection and `target`, + /// The change output (a.k.a. drain) this metric decides on for the given selection, /// or [`Drain::NONE`] if it decides there should be no change. /// /// Call this on a branch-and-bound solution to get the change output the metric optimized against. - fn drain(&mut self, cs: &CoinSelector<'_>) -> Drain; + fn drain(&mut self, view: &SelectionView<'_>) -> Drain; /// Returns whether the metric requies we order candidates by descending value per weight unit. fn requires_ordering_by_descending_value_pwu(&self) -> bool { diff --git a/src/coin_selector.rs b/src/coin_selector.rs index 795f147..b5073c2 100644 --- a/src/coin_selector.rs +++ b/src/coin_selector.rs @@ -55,6 +55,11 @@ impl<'a> CoinSelector<'a> { self.problem } + /// Build a cached read-only view of the current selection. + pub fn compute_view(&'a self) -> SelectionView<'a> { + SelectionView::from_selector(self) + } + /// Iterate over all the candidates in their currently sorted order. Each item has the original /// index with the candidate. pub fn candidates( @@ -521,25 +526,6 @@ impl<'a> CoinSelector<'a> { + self.ancestor_bump() } - /// A lower bound on the fee that this selection — and every selection extending it — must pay. - /// - /// Every term is monotone in the tx weight and so can only grow as more inputs (or a drain) are - /// added. What the ancestors owe is *not* monotone, so this credits only - /// [`ancestor_bump_lower_bound`](Self::ancestor_bump_lower_bound) — the least any descendant - /// could owe — rather than this selection's actual [`ancestor_bump`](Self::ancestor_bump). - /// - /// Weight-unit (un-rounded) fees are used throughout, which can only make the floor smaller. - pub(crate) fn fee_floor(&self) -> u64 { - let weight = self.weight(self.target().outputs, DrainWeights::NONE); - let mut floor = (self.target().fee.rate.implied_fee_wu(weight) - + self.ancestor_bump_lower_bound()) - .max(self.target().fee.absolute); - if let Some(replace) = self.target().fee.replace { - floor = floor.max(replace.min_fee_to_do_replacement_wu(weight)); - } - floor - } - /// The actual fee the selection would pay if it was used in a transaction that had /// `target_value` value for outputs and change output of `drain_value`. /// @@ -946,7 +932,7 @@ impl<'a> CoinSelector<'a> { .flatten() .last(); if let Some((selector, score)) = best { - let drain = iter.metric.drain(&selector); + let drain = iter.metric.drain(&selector.compute_view()); *self = selector; return Ok((score, drain)); } diff --git a/src/drain.rs b/src/drain.rs index 98067ef..9c58347 100644 --- a/src/drain.rs +++ b/src/drain.rs @@ -70,10 +70,12 @@ impl DrainWeights { /// A drain (A.K.A. change) output. /// Technically it could represent multiple outputs. /// -/// This is returned from [`CoinSelector::drain`]. Note if `drain` returns a drain where `is_none()` -/// returns true then **no change should be added** to the transaction. +/// This is returned from [`CoinSelector::drain`] and [`SelectionView::drain`]. Note if `drain` +/// returns a drain where `is_none()` returns true then **no change should be added** to the +/// transaction. /// /// [`CoinSelector::drain`]: crate::CoinSelector::drain +/// [`SelectionView::drain`]: crate::SelectionView::drain #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Default)] pub struct Drain { /// Weight of adding drain output and spending the drain output. diff --git a/src/lib.rs b/src/lib.rs index 77bb5dd..5a4d9fe 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -16,6 +16,9 @@ pub use bitset::*; mod coin_selector; pub mod float; pub use coin_selector::*; +mod selection_view; +use selection_view::SelectionCache; +pub use selection_view::SelectionView; mod bnb; pub use bnb::*; @@ -61,7 +64,7 @@ pub const TR_KEYSPEND_TXIN_WEIGHT: u64 = TXIN_BASE_WEIGHT + TR_KEYSPEND_SATISFAC pub const TR_DUST_RELAY_MIN_VALUE: u64 = 330; /// Helper to calculate varint size. `v` is the value the varint represents. -const fn varint_size(v: usize) -> u64 { +pub(crate) const fn varint_size(v: usize) -> u64 { if v <= 0xfc { return 1; } diff --git a/src/metrics/changeless.rs b/src/metrics/changeless.rs index dce049c..b0268ab 100644 --- a/src/metrics/changeless.rs +++ b/src/metrics/changeless.rs @@ -1,4 +1,4 @@ -use crate::{bnb::BnbMetric, float::Ordf32, CoinSelector, Drain}; +use crate::{bnb::BnbMetric, float::Ordf32, Drain, SelectionView}; /// Constrains an `inner` metric to only changeless solutions. /// @@ -31,7 +31,7 @@ impl Changeless { /// rather than risk discarding a branch that does contain a changeless solution. /// /// [`requires_ordering_by_descending_value_pwu`]: BnbMetric::requires_ordering_by_descending_value_pwu - fn change_unavoidable(&mut self, cs: &CoinSelector<'_>) -> bool { + fn change_unavoidable(&mut self, cs: &SelectionView<'_>) -> bool { if cs.problem().has_ancestors() { return false; } @@ -45,7 +45,7 @@ impl Changeless { .rev() .take_while(|(_, wv)| wv.effective_value(cs.target().fee.rate) < 0.0) .for_each(|(index, _)| { - least_excess.select(index); + least_excess.add(index); }); self.0.drain(&least_excess).is_some() @@ -53,12 +53,12 @@ impl Changeless { } impl BnbMetric for Changeless { - fn drain(&mut self, _cs: &CoinSelector<'_>) -> Drain { + fn drain(&mut self, _cs: &SelectionView<'_>) -> Drain { // by definition a changeless selection never has a change output Drain::NONE } - fn score(&mut self, cs: &CoinSelector<'_>) -> Option { + fn score(&mut self, cs: &SelectionView<'_>) -> Option { // Reject selections that have change. We don't need an explicit target-met check: `inner` // returns `None` for invalid (e.g. not-target-met) selections. // @@ -71,7 +71,7 @@ impl BnbMetric for Changeless { self.0.score(cs) } - fn bound(&mut self, cs: &CoinSelector<'_>) -> Option { + fn bound(&mut self, cs: &SelectionView<'_>) -> Option { if self.change_unavoidable(cs) { // every descendant has change, so no changeless solution is reachable None diff --git a/src/metrics/lowest_fee.rs b/src/metrics/lowest_fee.rs index b84dd6d..1ecc2d4 100644 --- a/src/metrics/lowest_fee.rs +++ b/src/metrics/lowest_fee.rs @@ -1,4 +1,4 @@ -use crate::{float::Ordf32, BnbMetric, CoinSelector, Drain, DrainWeights, FeeRate}; +use crate::{float::Ordf32, BnbMetric, Drain, DrainWeights, FeeRate, SelectionView}; /// Metric that aims to minimize transaction fees. The future fee for spending the change output is /// included in this calculation. @@ -19,7 +19,8 @@ use crate::{float::Ordf32, BnbMetric, CoinSelector, Drain, DrainWeights, FeeRate /// # Unconfirmed ancestors /// /// When the [`SelectionProblem`] has unconfirmed ancestors, the fee a selection must pay includes -/// the [`CoinSelector::ancestor_bump`] of the ancestors it drags in, so the search naturally prefers +/// the [`CoinSelector::ancestor_bump`](crate::CoinSelector::ancestor_bump) of the ancestors it drags +/// in, so the search naturally prefers /// coins that drag in nothing (or that share an already-paid-for ancestor). The score itself is /// still the child transaction's fee — the bump is inside it, not added on top. /// @@ -42,7 +43,7 @@ pub struct LowestFee { impl LowestFee { /// The value the change output should have, or `None` if this selection should be changeless. - fn drain_value(&self, cs: &CoinSelector<'_>) -> Option { + fn drain_value(&self, cs: &SelectionView<'_>) -> Option { // The change output pays for its own weight, so the value we'd actually recover is the // excess remaining after accounting for that weight. let excess_with_drain_weight = cs.excess(Drain { @@ -88,7 +89,7 @@ impl LowestFee { /// Any [`CoinSelector::ancestor_bump`] is not added on top: it is already inside the child's fee, /// because covering it is what [`CoinSelector::is_funded`] demands and what the change /// calculation gives up. - fn fee_score(&self, cs: &CoinSelector<'_>) -> Option<(Ordf32, Drain)> { + fn fee_score(&self, cs: &SelectionView<'_>) -> Option<(Ordf32, Drain)> { if !cs.is_funded() { return None; } @@ -111,7 +112,7 @@ impl LowestFee { /// Whether a descendant of `cs` could still add both a change output and at least one more /// input under `max_weight`. Same test as the no-ancestor funded path. - fn change_is_reachable(&self, cs: &CoinSelector<'_>) -> bool { + fn change_is_reachable(&self, cs: &SelectionView<'_>) -> bool { match cs.target().max_weight { None => true, Some(max_weight) => cs.min_input_weight().map_or(false, |min_input_weight| { @@ -131,7 +132,7 @@ impl LowestFee { /// lower bound on the real added child weight. Candidate ancestry is ignored and the global bump /// floor is used instead, avoiding package-surplus double counting. Flooring the fractional /// weight keeps floating-point error in the safe direction. - fn bound_with_ancestors(&self, cs: &CoinSelector<'_>) -> Ordf32 { + fn bound_with_ancestors(&self, cs: &SelectionView<'_>) -> Ordf32 { if cs.is_funded() { let (_, drain) = self.fee_score(cs).unwrap(); let current_score = cs.fee(cs.target().value(), drain.value) as u64 @@ -219,14 +220,14 @@ impl LowestFee { } impl BnbMetric for LowestFee { - fn drain(&mut self, cs: &CoinSelector<'_>) -> Drain { + fn drain(&mut self, cs: &SelectionView<'_>) -> Drain { self.drain_value(cs).map_or(Drain::NONE, |value| Drain { weights: self.drain_weights, value, }) } - fn score(&mut self, cs: &CoinSelector<'_>) -> Option { + fn score(&mut self, cs: &SelectionView<'_>) -> Option { let (score, drain) = self.fee_score(cs)?; // A final selection must fit the weight cap. `drain_value` already refuses an over-cap // change, but a changeless selection can still be too heavy on its own. Reuse the drain @@ -237,7 +238,7 @@ impl BnbMetric for LowestFee { Some(score) } - fn bound(&mut self, cs: &CoinSelector<'_>) -> Option { + fn bound(&mut self, cs: &SelectionView<'_>) -> Option { // Weight hard-prune: input weight only grows as this branch is extended, so the lightest // solution in the subtree is this selection with no drain. If even that busts `max_weight`, // the whole subtree is infeasible -> prune. (Also keeps `fee_score(cs).unwrap()` below @@ -303,14 +304,22 @@ impl BnbMetric for LowestFee { Some(current_score) } else { // Step 1: select everything up until the input that hits the cs.target(). - let (mut cs, resize_index, to_resize) = - cs.clone().select_iter().find(|(cs, _, _)| cs.is_funded())?; + let mut local = cs.clone(); + let mut unselected = cs.unselected(); + let (resize_index, to_resize) = loop { + let (index, candidate) = unselected.next()?; + local.add(index); + if local.is_funded() { + break (index, candidate); + } + }; // If this selection is already perfect, return its score directly. - if cs.excess(Drain::NONE) == 0 { - return Some(self.fee_score(&cs).unwrap().0); + if local.excess(Drain::NONE) == 0 { + return Some(self.fee_score(&local).unwrap().0); }; - cs.deselect(resize_index); + local.sub(resize_index); + let cs = &local; // We need to find the minimum fee we'd pay if we satisfy the feerate constraint. We do // this by imagining we had a perfect input that perfectly hit the cs.target(). The sats per diff --git a/src/selection_view.rs b/src/selection_view.rs new file mode 100644 index 0000000..aa45b46 --- /dev/null +++ b/src/selection_view.rs @@ -0,0 +1,608 @@ +//! Cached, read-only selection queries. + +use alloc::{borrow::Cow, vec::Vec}; +use core::ops::Deref; + +#[allow(unused)] +use crate::float::FloatExt; +use crate::{ + varint_size, Bitset, Candidate, ChangePolicy, CoinSelector, Drain, DrainWeights, FeeRate, + SelectionProblem, TargetOutputs, TX_FIXED_FIELD_WEIGHT, +}; + +/// Running aggregates used by branch and bound. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct SelectionCache { + value_sum: u64, + weight_sum: u64, + segwit_count: usize, + legacy_count: usize, + private_weight: u64, + private_fee: u64, + shared_refcounts: Vec, + shared_weight: u64, + shared_fee: u64, + selected: Bitset, +} + +impl SelectionCache { + pub(crate) fn from_selector(selector: &CoinSelector<'_>) -> Self { + let mut cache = Self { + value_sum: 0, + weight_sum: 0, + segwit_count: 0, + legacy_count: 0, + private_weight: 0, + private_fee: 0, + shared_refcounts: alloc::vec![0; selector.problem().ancestors().len()], + shared_weight: 0, + shared_fee: 0, + selected: Bitset::with_capacity(if selector.problem().has_ancestors() { + selector.problem().len() + } else { + 0 + }), + }; + for (index, candidate) in selector.selected() { + cache.add(selector.problem(), index, candidate); + } + cache + } + + fn input_weight(&self) -> u64 { + let is_segwit_tx = self.segwit_count > 0; + let witness_header_extra_weight = is_segwit_tx as u64 * 2; + let input_count = self.segwit_count + self.legacy_count; + let input_varint_weight = varint_size(input_count) * 4; + let legacy_witness_lengths = is_segwit_tx as u64 * self.legacy_count as u64; + input_varint_weight + self.weight_sum + legacy_witness_lengths + witness_header_extra_weight + } + + pub(crate) fn add(&mut self, problem: &SelectionProblem, index: usize, candidate: Candidate) { + if problem.has_ancestors() && !self.selected.insert(index) { + return; + } + self.value_sum += candidate.value; + self.weight_sum += candidate.weight; + self.segwit_count += candidate.segwit_count; + self.legacy_count += candidate.legacy_count; + + if problem.has_private_ancestors() { + let (weight, fee) = problem.private_ancestors(index); + self.private_weight += weight; + self.private_fee += fee; + } + if problem.has_shared_ancestors() { + for ancestor in problem.shared_drags_in(index).iter() { + if self.shared_refcounts[ancestor] == 0 { + let (weight, fee) = problem.ancestors()[ancestor]; + self.shared_weight += weight; + self.shared_fee += fee; + } + self.shared_refcounts[ancestor] += 1; + } + } + } + + pub(crate) fn sub(&mut self, problem: &SelectionProblem, index: usize, candidate: Candidate) { + if problem.has_ancestors() && !self.selected.remove(index) { + return; + } + self.value_sum -= candidate.value; + self.weight_sum -= candidate.weight; + self.segwit_count -= candidate.segwit_count; + self.legacy_count -= candidate.legacy_count; + + if problem.has_private_ancestors() { + let (weight, fee) = problem.private_ancestors(index); + self.private_weight -= weight; + self.private_fee -= fee; + } + if problem.has_shared_ancestors() { + for ancestor in problem.shared_drags_in(index).iter() { + self.shared_refcounts[ancestor] -= 1; + if self.shared_refcounts[ancestor] == 0 { + let (weight, fee) = problem.ancestors()[ancestor]; + self.shared_weight -= weight; + self.shared_fee -= fee; + } + } + } + } +} + +/// A read-only view over a [`CoinSelector`] with cached aggregate queries. +/// +/// Branch and bound maintains the cache incrementally. For ad-hoc use, +/// [`CoinSelector::compute_view`] builds it from the current selection. +#[derive(Clone, Debug)] +pub struct SelectionView<'a> { + selector: &'a CoinSelector<'a>, + cache: Cow<'a, SelectionCache>, +} + +impl<'a> Deref for SelectionView<'a> { + type Target = CoinSelector<'a>; + + fn deref(&self) -> &Self::Target { + self.selector + } +} + +impl<'a> SelectionView<'a> { + pub(crate) fn with_cache(selector: &'a CoinSelector<'a>, cache: &'a SelectionCache) -> Self { + Self { + selector, + cache: Cow::Borrowed(cache), + } + } + + pub(crate) fn from_selector(selector: &'a CoinSelector<'a>) -> Self { + Self { + selector, + cache: Cow::Owned(SelectionCache::from_selector(selector)), + } + } + + /// The selector represented by this view. + pub fn selector(&self) -> &'a CoinSelector<'a> { + self.selector + } + + /// Apply a hypothetical selection to this view's cached aggregates. + pub fn add(&mut self, index: usize) { + let candidate = self.selector.candidate(index); + self.cache + .to_mut() + .add(self.selector.problem(), index, candidate); + } + + /// Apply a hypothetical deselection to this view's cached aggregates. + pub fn sub(&mut self, index: usize) { + let candidate = self.selector.candidate(index); + self.cache + .to_mut() + .sub(self.selector.problem(), index, candidate); + } + + /// Absolute value sum of selected inputs. + pub fn selected_value(&self) -> u64 { + self.cache.value_sum + } + + /// Input weight including the input-count varint and witness serialization overhead. + pub fn input_weight(&self) -> u64 { + self.cache.input_weight() + } + + /// Current child transaction weight. + pub fn weight(&self, target_outputs: TargetOutputs, drain_weights: DrainWeights) -> u64 { + TX_FIXED_FIELD_WEIGHT + + self.input_weight() + + target_outputs.output_weight_with_drain(drain_weights) + } + + /// Ancestor fee bump owed by the current selection, with shared ancestors charged once. + pub fn ancestor_bump(&self) -> u64 { + let weight = self.cache.private_weight + self.cache.shared_weight; + let fee = self.cache.private_fee + self.cache.shared_fee; + self.target() + .fee + .rate + .implied_fee_wu(weight) + .saturating_sub(fee) + } + + /// Lower bound on the ancestor bump owed by this branch or any descendant. + pub fn ancestor_bump_lower_bound(&self) -> u64 { + let problem = self.selector.problem(); + if !problem.has_ancestors() { + return 0; + } + + let spwu = self.target().fee.rate.spwu() as f64; + let owes = |(weight, fee): (u64, u64)| weight as f64 * spwu - fee as f64; + let mut owed = 0.0; + let mut shed = 0.0; + + if problem.has_private_ancestors() { + for index in self.cache.selected.iter() { + owed += owes(problem.private_ancestors(index)); + } + for (index, _) in self.selector.candidates() { + if !self.cache.selected.contains(index) && !self.selector.banned().contains(index) { + shed += (-owes(problem.private_ancestors(index))).max(0.0); + } + } + } + + if problem.has_shared_ancestors() { + for (index, count) in self.cache.shared_refcounts.iter().enumerate() { + if *count > 0 { + owed += owes(problem.ancestors()[index]); + } + } + + let mut addable = Bitset::with_capacity(problem.ancestors().len()); + for (index, _) in self.selector.candidates() { + if self.cache.selected.contains(index) || self.selector.banned().contains(index) { + continue; + } + for ancestor in problem.shared_drags_in(index).iter() { + if self.cache.shared_refcounts[ancestor] == 0 { + addable.insert(ancestor); + } + } + } + for ancestor in addable.iter() { + shed += (-owes(problem.ancestors()[ancestor])).max(0.0); + } + } + + let bound = owed - shed; + if bound <= 0.0 { + 0 + } else { + bound as u64 + } + } + + fn implied_fee_from_feerate(&self, drain_weights: DrainWeights) -> u64 { + self.target() + .fee + .rate + .implied_fee(self.weight(self.target().outputs, drain_weights)) + + self.ancestor_bump() + } + + fn implied_fee_from_feerate_wu(&self, drain_weights: DrainWeights) -> u64 { + self.target() + .fee + .rate + .implied_fee_wu(self.weight(self.target().outputs, drain_weights)) + + self.ancestor_bump() + } + + /// Excess against all target fee constraints. + pub fn excess(&self, drain: Drain) -> i64 { + self.rate_excess(drain) + .min(self.absolute_excess(drain)) + .min(self.replacement_excess(drain)) + } + + /// Excess against the target feerate, including ancestor bumping. + pub fn rate_excess(&self, drain: Drain) -> i64 { + self.selected_value() as i64 + - self.target().value() as i64 + - drain.value as i64 + - self.implied_fee_from_feerate(drain.weights) as i64 + } + + /// Weight-unit version of [`rate_excess`](Self::rate_excess). + pub fn rate_excess_wu(&self, drain: Drain) -> i64 { + self.selected_value() as i64 + - self.target().value() as i64 + - drain.value as i64 + - self.implied_fee_from_feerate_wu(drain.weights) as i64 + } + + /// Excess against the absolute fee target. + pub fn absolute_excess(&self, drain: Drain) -> i64 { + self.selected_value() as i64 + - self.target().value() as i64 + - drain.value as i64 + - self.target().fee.absolute as i64 + } + + /// Excess against replacement rule 4. + pub fn replacement_excess(&self, drain: Drain) -> i64 { + let fee = self.target().fee.replace.map_or(0, |replace| { + replace.min_fee_to_do_replacement(self.weight(self.target().outputs, drain.weights)) + }); + self.selected_value() as i64 + - self.target().value() as i64 + - drain.value as i64 + - fee as i64 + } + + /// Weight-unit version of [`replacement_excess`](Self::replacement_excess). + pub fn replacement_excess_wu(&self, drain: Drain) -> i64 { + let fee = self.target().fee.replace.map_or(0, |replace| { + replace.min_fee_to_do_replacement_wu(self.weight(self.target().outputs, drain.weights)) + }); + self.selected_value() as i64 + - self.target().value() as i64 + - drain.value as i64 + - fee as i64 + } + + /// Whether the value and fee target is met with `drain`. + pub fn is_funded_with_drain(&self, drain: Drain) -> bool { + self.excess(drain) >= 0 + } + + /// Whether the value and fee target is met without change. + pub fn is_funded(&self) -> bool { + self.is_funded_with_drain(Drain::NONE) + } + + /// Whether selecting every remaining effective candidate can meet the target. + /// + /// As on [`CoinSelector::is_fundable`], this is a heuristic when ancestors are present. + pub fn is_fundable(&self) -> bool { + let mut local = self.clone(); + for (index, candidate) in self.selector.unselected() { + if candidate.effective_value(self.target().fee.rate) > 0.0 { + local.add(index); + } + } + local.is_funded() + } + + /// Additional value needed to meet the target. + pub fn missing(&self) -> u64 { + let excess = self.excess(Drain::NONE); + if excess < 0 { + excess.unsigned_abs() + } else { + 0 + } + } + + /// Whether this child transaction fits its target weight cap. + pub fn is_within_max_weight(&self, drain_weights: DrainWeights) -> bool { + self.target().max_weight.map_or(true, |max_weight| { + self.weight(self.target().outputs, drain_weights) <= max_weight + }) + } + + /// Actual child fee for the supplied output values. + pub fn fee(&self, target_value: u64, drain_value: u64) -> i64 { + self.selected_value() as i64 - target_value as i64 - drain_value as i64 + } + + /// Fee required by all target fee constraints for this selection. + pub fn implied_fee(&self, drain_weights: DrainWeights) -> u64 { + let mut fee = self + .implied_fee_from_feerate(drain_weights) + .max(self.target().fee.absolute); + if let Some(replace) = self.target().fee.replace { + fee = fee.max( + replace + .min_fee_to_do_replacement(self.weight(self.target().outputs, drain_weights)), + ); + } + fee + } + + /// Child transaction feerate implied by the selection and outputs. + pub fn implied_feerate(&self, target_outputs: TargetOutputs, drain: Drain) -> Option { + let fee = + self.selected_value() as i64 - target_outputs.value_sum as i64 - drain.value as i64; + let weight = self.weight(target_outputs, drain.weights); + if fee < 0 || weight == 0 { + return None; + } + Some(FeeRate::from_sat_per_wu(fee as f32 / weight as f32)) + } + + /// A lower bound on the child fee for this branch and every descendant. + pub(crate) fn fee_floor(&self) -> u64 { + let weight = self.weight(self.target().outputs, DrainWeights::NONE); + let mut floor = (self.target().fee.rate.implied_fee_wu(weight) + + self.ancestor_bump_lower_bound()) + .max(self.target().fee.absolute); + if let Some(replace) = self.target().fee.replace { + floor = floor.max(replace.min_fee_to_do_replacement_wu(weight)); + } + floor + } + + /// The value of a policy-controlled change output, if one should be added. + pub fn drain_value(&self, change_policy: ChangePolicy) -> Option { + let excess = self.excess(Drain { + weights: change_policy.drain_weights, + value: 0, + }); + if excess > change_policy.min_value as i64 { + Some(excess as u64) + } else { + None + } + } + + /// A policy-controlled change output. + pub fn drain(&self, change_policy: ChangePolicy) -> Drain { + self.drain_value(change_policy) + .map_or(Drain::NONE, |value| Drain { + weights: change_policy.drain_weights, + value, + }) + } + + /// The current selection's effective value at `feerate`. + pub fn effective_value(&self, feerate: FeeRate) -> i64 { + self.selected_value() as i64 - (self.input_weight() as f32 * feerate.spwu()).ceil() as i64 + } + + /// Waste created by this selection. + pub fn waste(&self, long_term_feerate: FeeRate, drain: Drain, excess_discount: f32) -> f32 { + debug_assert!((0.0..=1.0).contains(&excess_discount)); + let mut waste = + self.input_weight() as f32 * (self.target().fee.rate.spwu() - long_term_feerate.spwu()); + if drain.is_none() { + waste += self.excess(drain).max(0) as f32 * excess_discount.clamp(0.0, 1.0); + } else { + waste += drain.weights.waste( + self.target().fee.rate, + long_term_feerate, + self.target().outputs.n_outputs, + ); + } + waste + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::{AncestorToBump, Input, Target, TargetFee, TargetOutputs}; + + fn target() -> Target { + Target { + fee: TargetFee::ZERO, + outputs: TargetOutputs { + value_sum: 0, + weight_sum: 0, + n_outputs: 0, + }, + max_weight: None, + } + } + + #[test] + fn mixed_candidate_counts_match_selector() { + let candidates = [ + Candidate { + value: 1, + weight: 200, + segwit_count: 1, + legacy_count: 2, + }, + Candidate::new_legacy(2, 100), + ]; + let problem = SelectionProblem::new_no_ancestors(target(), candidates); + let mut selector = problem.selector(); + selector.select_all(); + let view = selector.compute_view(); + assert_eq!(view.input_weight(), selector.input_weight()); + assert_eq!(view.selected_value(), selector.selected_value()); + } + + #[test] + fn shared_ancestor_is_cached_once_and_removed_on_last_sub() { + let ancestors = [AncestorToBump { + txid: 1, + weight: 400, + fee: 0, + parents: alloc::vec![], + }]; + let inputs = [ + Input { + value: 1_000, + weight: 100, + is_segwit: true, + residing_txid: 1, + }, + Input { + value: 2_000, + weight: 100, + is_segwit: false, + residing_txid: 1, + }, + ]; + let problem = SelectionProblem::new(target(), inputs, ancestors); + let selector = problem.selector(); + let mut view = selector.compute_view(); + view.add(0); + let once = view.ancestor_bump(); + view.add(1); + assert_eq!(view.ancestor_bump(), once); + view.sub(0); + assert_eq!(view.ancestor_bump(), once); + view.sub(1); + assert_eq!(view.ancestor_bump(), 0); + } + + #[test] + fn cache_matches_selector_for_every_mixed_selection() { + let candidates = [ + Candidate::new_segwit(1_000, 100), + Candidate::new_legacy(2_000, 200), + Candidate { + value: 3_000, + weight: 350, + segwit_count: 2, + legacy_count: 3, + }, + ]; + let problem = SelectionProblem::new_no_ancestors(target(), candidates); + for mask in 0..1 << candidates.len() { + let mut selector = problem.selector(); + for index in 0..candidates.len() { + if mask & (1 << index) != 0 { + selector.select(index); + } + } + let view = selector.compute_view(); + assert_eq!(view.selected_value(), selector.selected_value()); + assert_eq!(view.input_weight(), selector.input_weight()); + assert_eq!(view.excess(Drain::NONE), selector.excess(Drain::NONE)); + } + } + + #[test] + fn hypothetical_ancestor_queries_match_selector_mutations() { + let mut target = target(); + target.fee = TargetFee::from_feerate(FeeRate::from_sat_per_vb(4.0)); + let ancestors = [ + AncestorToBump { + txid: 1, + weight: 400, + fee: 0, + parents: alloc::vec![], + }, + AncestorToBump { + txid: 2, + weight: 400, + fee: 800, + parents: alloc::vec![], + }, + ]; + let groups = [ + alloc::vec![ + Input { + value: 1_000, + weight: 100, + is_segwit: true, + residing_txid: 1, + }, + Input { + value: 1_000, + weight: 100, + is_segwit: false, + residing_txid: 2, + }, + ], + alloc::vec![Input { + value: 2_000, + weight: 100, + is_segwit: true, + residing_txid: 1, + }], + ]; + let problem = SelectionProblem::new(target, groups, ancestors); + let base = problem.selector(); + let mut actual = base.clone(); + let mut hypothetical = base.compute_view(); + + for index in 0..2 { + actual.select(index); + hypothetical.add(index); + assert_eq!(hypothetical.ancestor_bump(), actual.ancestor_bump()); + assert_eq!( + hypothetical.ancestor_bump_lower_bound(), + actual.ancestor_bump_lower_bound() + ); + assert_eq!(hypothetical.excess(Drain::NONE), actual.excess(Drain::NONE)); + } + + actual.deselect(0); + hypothetical.sub(0); + assert_eq!(hypothetical.ancestor_bump(), actual.ancestor_bump()); + assert_eq!( + hypothetical.ancestor_bump_lower_bound(), + actual.ancestor_bump_lower_bound() + ); + } +} diff --git a/tests/ancestor.rs b/tests/ancestor.rs index 96dab52..d2ce031 100644 --- a/tests/ancestor.rs +++ b/tests/ancestor.rs @@ -312,8 +312,8 @@ fn score_is_the_childs_fee_which_already_covers_the_bump() { cs.select(0); let mut m = metric(); - let score = m.score(&cs).expect("funded"); - let drain = m.drain(&cs); + let score = m.score(&cs.compute_view()).expect("funded"); + let drain = m.drain(&cs.compute_view()); assert_eq!( score, Ordf32( @@ -368,7 +368,7 @@ fn changeless_solution_reachable_only_via_an_ancestor_is_not_pruned() { clean_only.select(0); assert!(clean_only.is_funded()); assert!( - m.drain(&clean_only).is_some(), + m.drain(&clean_only.compute_view()).is_some(), "the clean coin on its own overshoots enough to warrant change" ); @@ -378,7 +378,7 @@ fn changeless_solution_reachable_only_via_an_ancestor_is_not_pruned() { assert_eq!(both.ancestor_bump(), 10_800); assert!(both.is_funded(), "still funded after paying the bump"); assert!( - m.drain(&both).is_none(), + m.drain(&both.compute_view()).is_none(), "the bump leaves too little excess to be worth a change output" ); @@ -507,7 +507,9 @@ fn bound_credits_the_bump_when_nothing_overpays() { .fee .rate .implied_fee_wu(cs.weight(t.outputs, DrainWeights::NONE)); - let bound = metric().bound(&cs).expect("within max_weight"); + let bound = metric() + .bound(&cs.compute_view()) + .expect("within max_weight"); assert!( bound >= Ordf32((child_fee + 2_500) as f32), "bound {} must charge the child's own fee ({}) plus the 2_500 bump", @@ -587,8 +589,8 @@ fn funded_bound_gives_up_reachable_surplus() { assert_eq!(cs.ancestor_bump(), 1_000); assert_eq!(cs.ancestor_bump_lower_bound(), 0); - let score = metric().score(&cs).unwrap(); - let bound = metric().bound(&cs).unwrap(); + let score = metric().score(&cs.compute_view()).unwrap(); + let bound = metric().bound(&cs.compute_view()).unwrap(); assert!( bound <= Ordf32(score.0 - 1_000.0), "bound {} must sit at least the 1_000 surplus below score {}", @@ -598,7 +600,7 @@ fn funded_bound_gives_up_reachable_surplus() { let mut both = cs.clone(); both.select(1); - let both_score = metric().score(&both).unwrap(); + let both_score = metric().score(&both.compute_view()).unwrap(); assert!( bound <= both_score, "bound {} above descendant score {}", @@ -655,11 +657,11 @@ fn funded_bound_subtracts_surplus_before_float_conversion() { node.select(0); assert_eq!(node.ancestor_bump(), 1_998_000_000); assert_eq!(node.ancestor_bump_lower_bound(), 0); - let bound = metric.bound(&node).unwrap(); + let bound = metric.bound(&node.compute_view()).unwrap(); let mut descendant = node.clone(); descendant.select(1); - let score = metric.score(&descendant).unwrap(); + let score = metric.score(&descendant.compute_view()).unwrap(); assert_eq!(score, Ordf32(700_000.0)); assert!(bound <= score, "bound {} above descendant {}", bound, score); } @@ -677,7 +679,7 @@ fn unfunded_bound_does_not_claim_infeasibility() { let cs = problem.selector(); assert!(!cs.is_funded()); assert!( - metric().bound(&cs).is_some(), + metric().bound(&cs.compute_view()).is_some(), "an unfunded root with a live funded subset must not be pruned" ); } @@ -700,10 +702,10 @@ fn unfunded_bound_credits_selected_package_surplus() { node.select(0); assert!(!node.is_funded()); - let bound = metric().bound(&node).unwrap(); + let bound = metric().bound(&node.compute_view()).unwrap(); let mut descendant = node.clone(); descendant.select(1); - let score = metric().score(&descendant).unwrap(); + let score = metric().score(&descendant.compute_view()).unwrap(); assert!( bound <= score, "bound {} above package-subsidized descendant {}", @@ -722,10 +724,10 @@ fn unfunded_bound_does_not_double_count_absolute_fee() { SelectionProblem::new(t, [input(105_000, "P")], [ancestor("P", 4_000, 0, vec![])]); let root = problem.selector(); - let bound = metric().bound(&root).unwrap(); + let bound = metric().bound(&root.compute_view()).unwrap(); let mut descendant = root.clone(); descendant.select(0); - let score = metric().score(&descendant).unwrap(); + let score = metric().score(&descendant.compute_view()).unwrap(); assert_eq!(score, Ordf32(5_000.0)); assert!(bound <= score, "bound {} above descendant {}", bound, score); } @@ -743,10 +745,10 @@ fn unfunded_bound_does_not_double_count_rbf_fee() { SelectionProblem::new(t, [input(105_104, "P")], [ancestor("P", 4_000, 0, vec![])]); let root = problem.selector(); - let bound = metric().bound(&root).unwrap(); + let bound = metric().bound(&root.compute_view()).unwrap(); let mut descendant = root.clone(); descendant.select(0); - let score = metric().score(&descendant).unwrap(); + let score = metric().score(&descendant.compute_view()).unwrap(); assert_eq!(score, Ordf32(5_104.0)); assert!(bound <= score, "bound {} above descendant {}", bound, score); } @@ -1001,7 +1003,7 @@ proptest! { ); for node in nodes { - let bound = metric.bound(&node); + let bound = metric.bound(&node.compute_view()); let subtree = std::iter::once(node.clone()).chain( common::ExhaustiveIter::new(&node) .into_iter() @@ -1011,7 +1013,7 @@ proptest! { ); for descendant in subtree { - let score = metric.score(&descendant); + let score = metric.score(&descendant.compute_view()); match bound { Some(lb) => if let Some(score) = score { prop_assert!( diff --git a/tests/bnb.rs b/tests/bnb.rs index 2604dbb..b4dcced 100644 --- a/tests/bnb.rs +++ b/tests/bnb.rs @@ -1,7 +1,7 @@ mod common; use bdk_coin_select::{ - float::Ordf32, BnbMetric, Candidate, CoinSelector, Drain, SelectionProblem, Target, TargetFee, - TargetOutputs, + float::Ordf32, BnbMetric, Candidate, CoinSelector, Drain, SelectionProblem, SelectionView, + Target, TargetFee, TargetOutputs, }; #[macro_use] extern crate alloc; @@ -33,7 +33,7 @@ struct MinExcessThenWeight; const EXCESS_RATIO: f32 = 1_000_000_f32; impl BnbMetric for MinExcessThenWeight { - fn score(&mut self, cs: &CoinSelector<'_>) -> Option { + fn score(&mut self, cs: &SelectionView<'_>) -> Option { let excess = cs.excess(Drain::NONE); if excess < 0 { None @@ -44,13 +44,13 @@ impl BnbMetric for MinExcessThenWeight { } } - fn bound(&mut self, cs: &CoinSelector<'_>) -> Option { - let mut cs = cs.clone(); + fn bound(&mut self, cs: &SelectionView<'_>) -> Option { + let mut cs = cs.selector().clone(); cs.select_until_target_met().ok()?; Some(Ordf32(cs.input_weight() as f32)) } - fn drain(&mut self, _cs: &CoinSelector<'_>) -> Drain { + fn drain(&mut self, _cs: &SelectionView<'_>) -> Drain { Drain::NONE } } @@ -142,6 +142,39 @@ fn bnb_finds_solution_if_possible_in_n_iter() { assert_eq!(excess, 0); } +#[test] +fn exclusion_cursor_skips_preselected_equivalent_candidate() { + let candidates = [ + Candidate::new_legacy(500, 100), + Candidate::new_legacy(500, 100), + Candidate::new_legacy(400, 100), + ]; + let target = Target { + outputs: TargetOutputs { + value_sum: 900, + weight_sum: 0, + n_outputs: 1, + }, + fee: TargetFee::ZERO, + max_weight: None, + }; + let problem = SelectionProblem::new_no_ancestors(target, candidates); + let mut selector = problem.selector(); + selector.select(1); + + selector + .run_bnb(MinExcessThenWeight, 1_000) + .expect("must find a solution"); + + assert_eq!( + selector.selected_indices().iter().collect::>(), + vec![1, 2] + ); + for (index, _) in selector.selected() { + assert!(!selector.banned().contains(index)); + } +} + proptest! { #[test] #[cfg(not(debug_assertions))] // too slow if compiling for debug diff --git a/tests/common.rs b/tests/common.rs index 3977851..544b740 100644 --- a/tests/common.rs +++ b/tests/common.rs @@ -63,7 +63,7 @@ where println!("\texhaustive search:"); let now = std::time::Instant::now(); let exp_result = exhaustive_search(&mut exp_selection, &mut metric); - let exp_change = metric.drain(&exp_selection); + let exp_change = metric.drain(&exp_selection.compute_view()); let exp_result_str = result_string(&exp_result.ok_or("no possible solution"), exp_change); println!( "\t\telapsed={:8}s result={}", @@ -73,7 +73,7 @@ where // bonus check: ensure replacement fee is respected if exp_result.is_some() { let selected_value = exp_selection.selected_value(); - let drain = metric.drain(&exp_selection); + let drain = metric.drain(&exp_selection.compute_view()); let target_value = target.value(); let replace_fee = params .replace @@ -89,7 +89,7 @@ where let now = std::time::Instant::now(); let mut bnb_metric = metric.clone(); let result = bnb_search(&mut selection, metric, usize::MAX); - let change = bnb_metric.drain(&selection); + let change = bnb_metric.drain(&selection.compute_view()); let result_str = result_string(&result, change); println!( "\t\telapsed={:8}s result={}", @@ -113,7 +113,7 @@ where // bonus check: ensure replacement fee is respected let selected_value = selection.selected_value(); - let drain = bnb_metric.drain(&selection); + let drain = bnb_metric.drain(&selection.compute_view()); let target_value = target.value(); let replace_fee = params .replace @@ -159,12 +159,12 @@ where print_candidates(¶ms, &init_cs); for (cs, _) in ExhaustiveIter::new(&init_cs).into_iter().flatten() { - if let Some(lb_score) = metric.bound(&cs) { + if let Some(lb_score) = metric.bound(&cs.compute_view()) { // This is the branch's lower bound. In other words, this is the BEST selection // possible (can overshoot) traversing down this branch. Let's check that! - if let Some(score) = metric.score(&cs) { - let has_change = metric.drain(&cs).is_some(); + if let Some(score) = metric.score(&cs.compute_view()) { + let has_change = metric.drain(&cs.compute_view()).is_some(); prop_assert!( score >= lb_score, "checking branch: selection={} score={} change={} lb={}", @@ -180,9 +180,10 @@ where .flatten() .filter(|(_, inc)| *inc) { - if let Some(descendant_score) = metric.score(&descendant_cs) { - let parent_has_change = metric.drain(&cs).is_some(); - let descendant_has_change = metric.drain(&descendant_cs).is_some(); + if let Some(descendant_score) = metric.score(&descendant_cs.compute_view()) { + let parent_has_change = metric.drain(&cs.compute_view()).is_some(); + let descendant_has_change = + metric.drain(&descendant_cs.compute_view()).is_some(); prop_assert!( descendant_score >= lb_score, " @@ -364,7 +365,7 @@ where .enumerate() .inspect(|(i, _)| rounds = *i) .filter(|(_, (_, inclusion))| *inclusion) - .filter_map(|(_, (cs, _))| metric.score(&cs).map(|score| (cs, score))); + .filter_map(|(_, (cs, _))| metric.score(&cs.compute_view()).map(|score| (cs, score))); for (child_cs, score) in iter { match &mut best { @@ -504,11 +505,11 @@ pub fn compare_against_benchmarks( let cmp_benchmarks = cmp_benchmarks .into_iter() .filter_map(|cs| { - let score = metric.clone().score(&cs)?; + let score = metric.clone().score(&cs.compute_view())?; Some((cs, score)) }) .collect::>(); - let sol_score = metric.score(&sol); + let sol_score = metric.score(&sol.compute_view()); for (_bench_id, (mut bench, bench_score)) in cmp_benchmarks.into_iter().enumerate() { prop_assert!( @@ -550,7 +551,7 @@ fn randomly_satisfy_target<'a, R: rand::Rng>( while let Some(next) = cs.unselected_indices().choose(rng) { cs.select(next); if cs.is_funded() { - let curr_score = metric.score(&cs); + let curr_score = metric.score(&cs.compute_view()); if let Some(last_score) = last_score { if curr_score.is_none() || curr_score.unwrap() > last_score { break; diff --git a/tests/lowest_fee.rs b/tests/lowest_fee.rs index b2e509c..b433969 100644 --- a/tests/lowest_fee.rs +++ b/tests/lowest_fee.rs @@ -279,7 +279,7 @@ fn does_not_create_change_below_spend_cost() { expected.select(0); assert_eq!(cs.selected_indices(), expected.selected_indices()); assert!( - metric.drain(&cs).is_none(), + metric.drain(&cs.compute_view()).is_none(), "optimal selection must be changeless" ); @@ -289,7 +289,12 @@ fn does_not_create_change_below_spend_cost() { with_extra_input.select(2); with_extra_input }; - assert!(score <= metric.score(&with_extra_input).expect("target is met")); + assert!( + score + <= metric + .score(&with_extra_input.compute_view()) + .expect("target is met") + ); } #[test] diff --git a/tests/weight.rs b/tests/weight.rs index 65548e7..4abfbb1 100644 --- a/tests/weight.rs +++ b/tests/weight.rs @@ -79,11 +79,14 @@ fn segwit_one_input_one_output() { coin_selector.select_all(); assert_eq!( - coin_selector.weight(target_ouputs, DrainWeights::NONE), + coin_selector + .compute_view() + .weight(target_ouputs, DrainWeights::NONE), tx.weight().to_wu() ); assert_eq!( (coin_selector + .compute_view() .implied_feerate(target_ouputs, Drain::NONE) .unwrap() .as_sat_vb() @@ -128,11 +131,14 @@ fn segwit_two_inputs_one_output() { coin_selector.select_all(); assert_eq!( - coin_selector.weight(target_ouputs, DrainWeights::NONE), + coin_selector + .compute_view() + .weight(target_ouputs, DrainWeights::NONE), tx.weight().to_wu() ); assert_eq!( (coin_selector + .compute_view() .implied_feerate(target_ouputs, Drain::NONE) .unwrap() .as_sat_vb() @@ -177,11 +183,14 @@ fn legacy_three_inputs() { coin_selector.select_all(); assert_eq!( - coin_selector.weight(target_ouputs, DrainWeights::NONE), + coin_selector + .compute_view() + .weight(target_ouputs, DrainWeights::NONE), orig_weight.to_wu() ); assert_eq!( (coin_selector + .compute_view() .implied_feerate(target_ouputs, Drain::NONE) .unwrap() .as_sat_vb() @@ -363,7 +372,9 @@ fn mixed_group_all_inputs_one_candidate() { coin_selector.select_all(); assert_eq!( - coin_selector.weight(target_ouputs, DrainWeights::NONE), + coin_selector + .compute_view() + .weight(target_ouputs, DrainWeights::NONE), tx.weight().to_wu() ); } From 58b26c631dadc3702cd17a024b1704b89801da79 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=BF=97=E5=AE=87?= Date: Thu, 13 Aug 2026 14:05:13 +0000 Subject: [PATCH 09/26] fix: harden delta-aware ancestor selection Keep SelectionView's hypothetical updates set-like and synchronize ancestor reachability when branches exclude candidates. Remove unsound funding and changeless assumptions exposed by non-monotone ancestor debt, and preserve conservative fee rounding in the bound. Add regressions for public view updates, exclusion transitions, weight caps, mixed serialization overhead, and floating-point edge cases. --- src/bnb.rs | 5 +- src/coin_selector.rs | 34 +---- src/feerate.rs | 19 ++- src/metrics/changeless.rs | 56 +------ src/metrics/lowest_fee.rs | 4 +- src/selection_view.rs | 301 +++++++++++++++++++++++++++++++------- tests/bnb.rs | 2 +- tests/changeless.rs | 48 +++++- tests/lowest_fee.rs | 2 +- 9 files changed, 335 insertions(+), 136 deletions(-) diff --git a/src/bnb.rs b/src/bnb.rs index c76d389..18890ea 100644 --- a/src/bnb.rs +++ b/src/bnb.rs @@ -172,6 +172,7 @@ impl<'a, M: BnbMetric> BnbIter<'a, M> { // drags in an ancestor that needs bumping). Candidates are only compared until the first // mismatch, since this exploits them being adjacent in the sorted order. let mut exclusion_cs = cs.clone(); + let mut exclusion_cache = cache.clone(); let to_ban = ( next.value, next.weight, @@ -180,6 +181,7 @@ impl<'a, M: BnbMetric> BnbIter<'a, M> { ); let to_ban_drags_in = cs.problem().drags_in(next_index); exclusion_cs.ban(next_index); + exclusion_cache.ban(cs.problem(), next_index); let mut exclusion_cursor = cursor + 1; for (next_index, next) in iter { if cs.is_selected(next_index) || cs.banned().contains(next_index) { @@ -197,9 +199,10 @@ impl<'a, M: BnbMetric> BnbIter<'a, M> { break; } exclusion_cs.ban(next_index); + exclusion_cache.ban(cs.problem(), next_index); exclusion_cursor += 1; } - self.consider_adding_to_queue(&exclusion_cs, cache, true, exclusion_cursor); + self.consider_adding_to_queue(&exclusion_cs, &exclusion_cache, true, exclusion_cursor); } } diff --git a/src/coin_selector.rs b/src/coin_selector.rs index b5073c2..0db7882 100644 --- a/src/coin_selector.rs +++ b/src/coin_selector.rs @@ -130,31 +130,6 @@ impl<'a> CoinSelector<'a> { self.selected.contains(index) } - /// Whether the candidates can cover this `target`'s **value** (net of input fees) — i.e. whether - /// enough value is reachable for [`is_funded`] to hold. Respects [`ban`]ned candidates. - /// - /// Selecting *all* effective inputs maximizes the value available, so if that can't meet the - /// target value, nothing can. - /// - /// NOTE: this does **not** account for [`Target::max_weight`] — a `true` result can still be - /// infeasible under the weight cap. Use [`select_until_target_met`] or branch and bound (both of - /// which enforce the cap) to actually build a selection. - /// - /// NOTE: this is exact only when [`SelectionProblem::has_ancestors`] is `false`. With - /// unconfirmed ancestors, funding is not monotone (an input can drag in an ancestor that costs - /// more than the input is worth, and inputs sharing an ancestor pay for it once between them), - /// so the all-effective selection is no longer guaranteed to be the best case: this becomes a - /// heuristic and can answer either way. Use branch and bound to decide feasibility exactly. - /// - /// [`ban`]: Self::ban - /// [`is_funded`]: Self::is_funded - /// [`select_until_target_met`]: Self::select_until_target_met - pub fn is_fundable(&self) -> bool { - let mut test = self.clone(); - test.select_all_effective(self.target().fee.rate); - test.is_funded() - } - /// Returns true if no candidates have been selected. pub fn is_empty(&self) -> bool { self.selected.is_empty() @@ -946,7 +921,7 @@ impl<'a> CoinSelector<'a> { assert_eq!(rounds, max_rounds); // still-yielding ⟹ we truncated at the cap return Err(NoBnbSolution::RoundLimit { max_rounds, rounds }); } - if !self.is_fundable() { + if !self.compute_view().is_fundable() { return Err(NoBnbSolution::InsufficientFunds); } Err(NoBnbSolution::MaxWeightExceeded) @@ -1057,9 +1032,10 @@ impl std::error::Error for SelectError {} pub enum NoBnbSolution { /// The candidates can't cover the target value, so no selection is possible. /// - /// With unconfirmed ancestors this is decided by the heuristic [`CoinSelector::is_fundable`], so - /// it may be reported where [`MaxWeightExceeded`](Self::MaxWeightExceeded) fits better, and vice - /// versa. Either way the search was exhaustive: there is no solution. + /// With unconfirmed ancestors this is decided by the heuristic + /// [`SelectionView::is_fundable`](crate::SelectionView::is_fundable), so it may be reported where + /// [`MaxWeightExceeded`](Self::MaxWeightExceeded) fits better, and vice versa. Either way the + /// search was exhaustive: there is no solution. InsufficientFunds, /// Some selection covers the target value, but every one of them exceeds /// [`Target::max_weight`]. diff --git a/src/feerate.rs b/src/feerate.rs index b4f0490..7aa2761 100644 --- a/src/feerate.rs +++ b/src/feerate.rs @@ -89,7 +89,13 @@ impl FeeRate { /// Same as [implied_fee](Self::implied_fee) except the fee rate given by `self` is applied to `tx_weight` directly. pub fn implied_fee_wu(&self, tx_weight: u64) -> u64 { - (tx_weight as f32 * self.spwu()).ceil() as u64 + let fee = tx_weight as f64 * self.spwu() as f64; + let truncated = fee as u64; + if truncated as f64 == fee { + truncated + } else { + truncated.saturating_add(1) + } } } @@ -108,3 +114,14 @@ impl Sub for FeeRate { Self(Ordf32(self.0 .0 - rhs.0 .0)) } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn implied_fee_wu_retains_integer_precision_above_f32_range() { + let rate = FeeRate::from_sat_per_wu(43.0); + assert_eq!(rate.implied_fee_wu(399_999), 17_199_957); + } +} diff --git a/src/metrics/changeless.rs b/src/metrics/changeless.rs index b0268ab..9732e4d 100644 --- a/src/metrics/changeless.rs +++ b/src/metrics/changeless.rs @@ -11,47 +11,6 @@ pub struct Changeless( pub M, ); -impl Changeless { - /// Whether every selection reachable down this branch (the current one and any superset of it) - /// would have a change output according to the inner metric — so no changeless solution exists - /// here and the branch can be pruned. - /// - /// The inner metric only adds change once the excess is large enough (we assume its change - /// decision is monotone in the excess). So the reachable selection least likely to have change - /// is the one with the smallest excess — the current selection plus every remaining - /// negative-effective-value candidate, since each of those lowers the excess. If even that - /// selection still has change, then so does every reachable selection. - /// - /// NOTE: this relies on candidates being sorted so that all negative effective value candidates - /// are next to each other, which [`requires_ordering_by_descending_value_pwu`] guarantees. - /// - /// NOTE: with unconfirmed ancestors this reasoning breaks down — a candidate's marginal cost is - /// not its own value and weight (it also drags in ancestors, possibly ones already paid for), so - /// the selection built here need not be the one with the smallest excess. We give up the prune - /// rather than risk discarding a branch that does contain a changeless solution. - /// - /// [`requires_ordering_by_descending_value_pwu`]: BnbMetric::requires_ordering_by_descending_value_pwu - fn change_unavoidable(&mut self, cs: &SelectionView<'_>) -> bool { - if cs.problem().has_ancestors() { - return false; - } - - if self.0.drain(cs).is_none() { - return false; - } - - let mut least_excess = cs.clone(); - cs.unselected() - .rev() - .take_while(|(_, wv)| wv.effective_value(cs.target().fee.rate) < 0.0) - .for_each(|(index, _)| { - least_excess.add(index); - }); - - self.0.drain(&least_excess).is_some() - } -} - impl BnbMetric for Changeless { fn drain(&mut self, _cs: &SelectionView<'_>) -> Drain { // by definition a changeless selection never has a change output @@ -72,17 +31,14 @@ impl BnbMetric for Changeless { } fn bound(&mut self, cs: &SelectionView<'_>) -> Option { - if self.change_unavoidable(cs) { - // every descendant has change, so no changeless solution is reachable - None - } else { - // the changeless-constrained optimum is no better than the inner metric's unconstrained - // optimum, so the inner bound is a valid lower bound - self.0.bound(cs) - } + // The changeless-constrained optimum is no better than the inner metric's unconstrained + // optimum, so the inner bound is a valid lower bound. Change-unavoidability pruning is not + // generally sound because candidate marginal fees depend on vbyte rounding, RBF, framing, + // and ancestry. + self.0.bound(cs) } fn requires_ordering_by_descending_value_pwu(&self) -> bool { - true + self.0.requires_ordering_by_descending_value_pwu() } } diff --git a/src/metrics/lowest_fee.rs b/src/metrics/lowest_fee.rs index 1ecc2d4..61fe386 100644 --- a/src/metrics/lowest_fee.rs +++ b/src/metrics/lowest_fee.rs @@ -308,7 +308,7 @@ impl BnbMetric for LowestFee { let mut unselected = cs.unselected(); let (resize_index, to_resize) = loop { let (index, candidate) = unselected.next()?; - local.add(index); + local.add_unchecked(index); if local.is_funded() { break (index, candidate); } @@ -318,7 +318,7 @@ impl BnbMetric for LowestFee { if local.excess(Drain::NONE) == 0 { return Some(self.fee_score(&local).unwrap().0); }; - local.sub(resize_index); + local.sub_unchecked(resize_index); let cs = &local; // We need to find the minimum fee we'd pay if we satisfy the feerate constraint. We do diff --git a/src/selection_view.rs b/src/selection_view.rs index aa45b46..bac944e 100644 --- a/src/selection_view.rs +++ b/src/selection_view.rs @@ -11,7 +11,7 @@ use crate::{ }; /// Running aggregates used by branch and bound. -#[derive(Debug, Clone, PartialEq, Eq)] +#[derive(Debug, Clone, PartialEq)] pub(crate) struct SelectionCache { value_sum: u64, weight_sum: u64, @@ -22,7 +22,11 @@ pub(crate) struct SelectionCache { shared_refcounts: Vec, shared_weight: u64, shared_fee: u64, + private_reachable_surplus: f64, + shared_reachable_refcounts: Vec, + shared_reachable_surplus: f64, selected: Bitset, + available: Bitset, } impl SelectionCache { @@ -37,18 +41,85 @@ impl SelectionCache { shared_refcounts: alloc::vec![0; selector.problem().ancestors().len()], shared_weight: 0, shared_fee: 0, + private_reachable_surplus: 0.0, + shared_reachable_refcounts: alloc::vec![0; selector.problem().ancestors().len()], + shared_reachable_surplus: 0.0, selected: Bitset::with_capacity(if selector.problem().has_ancestors() { selector.problem().len() } else { 0 }), + available: Bitset::with_capacity(if selector.problem().has_ancestors() { + selector.problem().len() + } else { + 0 + }), }; for (index, candidate) in selector.selected() { cache.add(selector.problem(), index, candidate); } + if selector.problem().has_ancestors() { + for (index, _) in selector.candidates() { + if !selector.is_selected(index) && !selector.banned().contains(index) { + cache.add_reachable(selector.problem(), index); + } + } + } cache } + fn ancestor_surplus(problem: &SelectionProblem, (weight, fee): (u64, u64)) -> f64 { + (fee as f64 - weight as f64 * problem.target().fee.rate.spwu() as f64).max(0.0) + } + + fn add_reachable(&mut self, problem: &SelectionProblem, index: usize) { + if !problem.has_ancestors() { + return; + } + if !self.available.insert(index) { + return; + } + if problem.has_private_ancestors() { + self.private_reachable_surplus += + Self::ancestor_surplus(problem, problem.private_ancestors(index)); + } + if problem.has_shared_ancestors() { + for ancestor in problem.shared_drags_in(index).iter() { + if self.shared_reachable_refcounts[ancestor] == 0 + && self.shared_refcounts[ancestor] == 0 + { + self.shared_reachable_surplus += + Self::ancestor_surplus(problem, problem.ancestors()[ancestor]); + } + self.shared_reachable_refcounts[ancestor] += 1; + } + } + } + + fn remove_reachable(&mut self, problem: &SelectionProblem, index: usize) { + if !problem.has_ancestors() { + return; + } + if !self.available.remove(index) { + return; + } + if problem.has_private_ancestors() { + self.private_reachable_surplus -= + Self::ancestor_surplus(problem, problem.private_ancestors(index)); + } + if problem.has_shared_ancestors() { + for ancestor in problem.shared_drags_in(index).iter() { + self.shared_reachable_refcounts[ancestor] -= 1; + if self.shared_reachable_refcounts[ancestor] == 0 + && self.shared_refcounts[ancestor] == 0 + { + self.shared_reachable_surplus -= + Self::ancestor_surplus(problem, problem.ancestors()[ancestor]); + } + } + } + } + fn input_weight(&self) -> u64 { let is_segwit_tx = self.segwit_count > 0; let witness_header_extra_weight = is_segwit_tx as u64 * 2; @@ -59,13 +130,14 @@ impl SelectionCache { } pub(crate) fn add(&mut self, problem: &SelectionProblem, index: usize, candidate: Candidate) { - if problem.has_ancestors() && !self.selected.insert(index) { + if self.selected.capacity() > 0 && !self.selected.insert(index) { return; } self.value_sum += candidate.value; self.weight_sum += candidate.weight; self.segwit_count += candidate.segwit_count; self.legacy_count += candidate.legacy_count; + self.remove_reachable(problem, index); if problem.has_private_ancestors() { let (weight, fee) = problem.private_ancestors(index); @@ -78,14 +150,24 @@ impl SelectionCache { let (weight, fee) = problem.ancestors()[ancestor]; self.shared_weight += weight; self.shared_fee += fee; + if self.shared_reachable_refcounts[ancestor] > 0 { + self.shared_reachable_surplus -= + Self::ancestor_surplus(problem, (weight, fee)); + } } self.shared_refcounts[ancestor] += 1; } } } - pub(crate) fn sub(&mut self, problem: &SelectionProblem, index: usize, candidate: Candidate) { - if problem.has_ancestors() && !self.selected.remove(index) { + pub(crate) fn sub( + &mut self, + problem: &SelectionProblem, + index: usize, + candidate: Candidate, + is_addable: bool, + ) { + if self.selected.capacity() > 0 && !self.selected.remove(index) { return; } self.value_sum -= candidate.value; @@ -105,9 +187,20 @@ impl SelectionCache { let (weight, fee) = problem.ancestors()[ancestor]; self.shared_weight -= weight; self.shared_fee -= fee; + if self.shared_reachable_refcounts[ancestor] > 0 { + self.shared_reachable_surplus += + Self::ancestor_surplus(problem, (weight, fee)); + } } } } + if is_addable { + self.add_reachable(problem, index); + } + } + + pub(crate) fn ban(&mut self, problem: &SelectionProblem, index: usize) { + self.remove_reachable(problem, index); } } @@ -149,8 +242,22 @@ impl<'a> SelectionView<'a> { self.selector } + fn track_selected(&mut self) { + if self.cache.selected.capacity() > 0 || self.selector.problem().is_empty() { + return; + } + let mut selected = Bitset::with_capacity(self.selector.problem().len()); + for (index, _) in self.selector.selected() { + selected.insert(index); + } + self.cache.to_mut().selected = selected; + } + /// Apply a hypothetical selection to this view's cached aggregates. + /// + /// Does nothing if the candidate was already selected in the view. pub fn add(&mut self, index: usize) { + self.track_selected(); let candidate = self.selector.candidate(index); self.cache .to_mut() @@ -158,11 +265,34 @@ impl<'a> SelectionView<'a> { } /// Apply a hypothetical deselection to this view's cached aggregates. + /// + /// Does nothing if the candidate was not selected in the view. pub fn sub(&mut self, index: usize) { + self.track_selected(); + let candidate = self.selector.candidate(index); + self.cache.to_mut().sub( + self.selector.problem(), + index, + candidate, + !self.selector.banned().contains(index), + ); + } + + pub(crate) fn add_unchecked(&mut self, index: usize) { let candidate = self.selector.candidate(index); self.cache .to_mut() - .sub(self.selector.problem(), index, candidate); + .add(self.selector.problem(), index, candidate); + } + + pub(crate) fn sub_unchecked(&mut self, index: usize) { + let candidate = self.selector.candidate(index); + self.cache.to_mut().sub( + self.selector.problem(), + index, + candidate, + !self.selector.banned().contains(index), + ); } /// Absolute value sum of selected inputs. @@ -194,52 +324,19 @@ impl<'a> SelectionView<'a> { } /// Lower bound on the ancestor bump owed by this branch or any descendant. + /// + /// Branch and bound maintains both selected obligations and still-reachable surplus in the + /// cache, so this query is constant-time. pub fn ancestor_bump_lower_bound(&self) -> u64 { - let problem = self.selector.problem(); - if !problem.has_ancestors() { + if !self.selector.problem().has_ancestors() { return 0; } let spwu = self.target().fee.rate.spwu() as f64; - let owes = |(weight, fee): (u64, u64)| weight as f64 * spwu - fee as f64; - let mut owed = 0.0; - let mut shed = 0.0; - - if problem.has_private_ancestors() { - for index in self.cache.selected.iter() { - owed += owes(problem.private_ancestors(index)); - } - for (index, _) in self.selector.candidates() { - if !self.cache.selected.contains(index) && !self.selector.banned().contains(index) { - shed += (-owes(problem.private_ancestors(index))).max(0.0); - } - } - } - - if problem.has_shared_ancestors() { - for (index, count) in self.cache.shared_refcounts.iter().enumerate() { - if *count > 0 { - owed += owes(problem.ancestors()[index]); - } - } - - let mut addable = Bitset::with_capacity(problem.ancestors().len()); - for (index, _) in self.selector.candidates() { - if self.cache.selected.contains(index) || self.selector.banned().contains(index) { - continue; - } - for ancestor in problem.shared_drags_in(index).iter() { - if self.cache.shared_refcounts[ancestor] == 0 { - addable.insert(ancestor); - } - } - } - for ancestor in addable.iter() { - shed += (-owes(problem.ancestors()[ancestor])).max(0.0); - } - } - - let bound = owed - shed; + let owed = (self.cache.private_weight + self.cache.shared_weight) as f64 * spwu + - (self.cache.private_fee + self.cache.shared_fee) as f64; + let bound = + owed - self.cache.private_reachable_surplus - self.cache.shared_reachable_surplus; if bound <= 0.0 { 0 } else { @@ -326,13 +423,23 @@ impl<'a> SelectionView<'a> { self.is_funded_with_drain(Drain::NONE) } - /// Whether selecting every remaining effective candidate can meet the target. + /// Whether the target appears reachable after adding every remaining candidate with positive + /// standalone effective value. /// - /// As on [`CoinSelector::is_fundable`], this is a heuristic when ancestors are present. + /// The current selection is checked first because transaction framing can make adding a + /// standalone-positive candidate reduce actual excess. This remains a heuristic: framing and + /// ancestors make marginal effective values selection-dependent. pub fn is_fundable(&self) -> bool { + if self.is_funded() { + return true; + } let mut local = self.clone(); - for (index, candidate) in self.selector.unselected() { - if candidate.effective_value(self.target().fee.rate) > 0.0 { + local.track_selected(); + for (index, candidate) in self.selector.candidates() { + if !local.cache.selected.contains(index) + && !self.selector.banned().contains(index) + && candidate.effective_value(self.target().fee.rate) > 0.0 + { local.add(index); } } @@ -541,6 +648,90 @@ mod tests { } } + #[test] + fn hypothetical_updates_have_set_semantics_without_ancestors() { + let candidates = [ + Candidate::new_segwit(1_000, 100), + Candidate::new_legacy(2_000, 200), + ]; + let problem = SelectionProblem::new_no_ancestors(target(), candidates); + let mut selector = problem.selector(); + selector.select(0); + let mut view = selector.compute_view(); + + let initial_value = view.selected_value(); + let initial_weight = view.input_weight(); + view.add(0); + assert_eq!(view.selected_value(), initial_value); + assert_eq!(view.input_weight(), initial_weight); + + view.sub(1); + assert_eq!(view.selected_value(), initial_value); + assert_eq!(view.input_weight(), initial_weight); + + view.sub(0); + view.sub(0); + assert_eq!(view.selected_value(), 0); + + view.add(1); + view.add(1); + assert_eq!(view.selected_value(), candidates[1].value); + assert_eq!(view.input_weight(), { + let mut expected = problem.selector(); + expected.select(1); + expected.input_weight() + }); + } + + #[test] + fn is_fundable_uses_hypothetical_selection_state() { + let mut target = target(); + target.outputs.value_sum = 2_000; + let candidates = [ + Candidate::new_segwit(1_000, 100), + Candidate::new_segwit(1_000, 100), + ]; + let problem = SelectionProblem::new_no_ancestors(target, candidates); + let mut selector = problem.selector(); + selector.select(0); + let mut view = selector.compute_view(); + + view.sub(0); + assert!(view.is_fundable()); + view.add(1); + assert!(view.is_fundable()); + } + + #[test] + fn is_fundable_never_rejects_an_already_funded_mixed_selection() { + let mut target = target(); + target.outputs.value_sum = 1_000; + target.fee = TargetFee::from_feerate(FeeRate::from_sat_per_vb(4.0)); + let candidates = [ + Candidate { + value: 1_201, + weight: 158, + segwit_count: 1, + legacy_count: 0, + }, + Candidate { + value: 165, + weight: 164, + segwit_count: 0, + legacy_count: 3, + }, + ]; + let problem = SelectionProblem::new_no_ancestors(target, candidates); + let mut selector = problem.selector(); + selector.select(0); + assert!(selector.is_funded()); + + let mut all = selector.clone(); + all.select(1); + assert!(!all.is_funded()); + assert!(selector.compute_view().is_fundable()); + } + #[test] fn hypothetical_ancestor_queries_match_selector_mutations() { let mut target = target(); @@ -604,5 +795,15 @@ mod tests { hypothetical.ancestor_bump_lower_bound(), actual.ancestor_bump_lower_bound() ); + + actual.ban(0); + hypothetical + .cache + .to_mut() + .ban(hypothetical.selector.problem(), 0); + assert_eq!( + hypothetical.ancestor_bump_lower_bound(), + actual.ancestor_bump_lower_bound() + ); } } diff --git a/tests/bnb.rs b/tests/bnb.rs index b4dcced..7562f66 100644 --- a/tests/bnb.rs +++ b/tests/bnb.rs @@ -194,7 +194,7 @@ proptest! { match solutions.enumerate().filter_map(|(i, sol)| Some((i, sol?))).last() { Some((_i, (sol, _score))) => assert!(sol.selected_value() >= target_value), - _ => prop_assert!(!cs.is_fundable()), + _ => prop_assert!(!cs.compute_view().is_fundable()), } } diff --git a/tests/changeless.rs b/tests/changeless.rs index d25f10f..e742cda 100644 --- a/tests/changeless.rs +++ b/tests/changeless.rs @@ -3,12 +3,58 @@ mod common; use bdk_coin_select::{ float::Ordf32, metrics::{Changeless, LowestFee}, - Candidate, CoinSelector, DrainWeights, FeeRate, SelectionProblem, Target, TargetFee, + BnbMetric, Candidate, CoinSelector, DrainWeights, FeeRate, SelectionProblem, Target, TargetFee, TargetOutputs, }; use proptest::{prelude::*, proptest, test_runner::*}; use rand::{prelude::IteratorRandom, Rng, RngCore}; +#[test] +fn mixed_serialization_overhead_does_not_prune_exact_solution() { + let target = Target { + outputs: TargetOutputs { + n_outputs: 0, + value_sum: 1_000, + weight_sum: 0, + }, + fee: TargetFee::from_feerate(FeeRate::from_sat_per_vb(4.0)), + max_weight: None, + }; + let candidates = [ + Candidate { + value: 1_201, + weight: 158, + segwit_count: 1, + legacy_count: 0, + }, + Candidate { + value: 167, + weight: 164, + segwit_count: 0, + legacy_count: 3, + }, + ]; + let problem = SelectionProblem::new_no_ancestors(target, candidates); + let mut selector = problem.selector(); + let metric = Changeless(LowestFee { + long_term_feerate: FeeRate::ZERO, + dust_relay_feerate: FeeRate::ZERO, + drain_weights: DrainWeights::NONE, + }); + + let mut expected = problem.selector(); + expected.select_all(); + assert_eq!(expected.excess(bdk_coin_select::Drain::NONE), 0); + assert!(metric.clone().score(&expected.compute_view()).is_some()); + + selector.run_bnb(metric, 100).expect("exact solution"); + assert_eq!( + selector.selected_indices().iter().collect::>(), + [0, 1] + ); + assert_eq!(selector.excess(bdk_coin_select::Drain::NONE), 0); +} + fn test_wv(mut rng: impl RngCore) -> impl Iterator { core::iter::repeat_with(move || { let value = rng.random_range(0..1_000); diff --git a/tests/lowest_fee.rs b/tests/lowest_fee.rs index b433969..c70efc7 100644 --- a/tests/lowest_fee.rs +++ b/tests/lowest_fee.rs @@ -92,7 +92,7 @@ proptest! { let mut cs = CoinSelector::new(&problem); let metric = params.lowest_fee_metric(); - let is_impossible = !cs.is_fundable(); + let is_impossible = !cs.compute_view().is_fundable(); match common::bnb_search(&mut cs, metric, params.n_candidates * 10) { Ok((score, rounds)) => { // the +1 is because the iterator will always try selecting nothing as a solution so we have From 28c5455823597945a6461edd6b83dae87ba2b595 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=BF=97=E5=AE=87?= Date: Thu, 13 Aug 2026 14:05:20 +0000 Subject: [PATCH 10/26] bench: restore capped BnB frontier coverage Separate deterministic solution-finding cases from larger pools expected to exhaust the fixed round cap. Assert each fixture's expected search outcome before measuring it so benchmark comparisons cannot silently time different paths. --- benches/coin_selector.rs | 59 ++++++++++++++++++++++++++++++---------- 1 file changed, 44 insertions(+), 15 deletions(-) diff --git a/benches/coin_selector.rs b/benches/coin_selector.rs index a1b72ec..22ccc07 100644 --- a/benches/coin_selector.rs +++ b/benches/coin_selector.rs @@ -6,8 +6,10 @@ //! //! - `clone`: direct cost of `CoinSelector::clone()`, the operation `Bitset` //! was introduced to make cheap. -//! - `run_bnb_lowest_fee`: end-to-end Branch-and-Bound throughput on a -//! deterministic synthetic pool using the `LowestFee` metric. +//! - `run_bnb_lowest_fee`: end-to-end Branch-and-Bound solution finding on a deterministic +//! synthetic pool using the `LowestFee` metric. +//! - `run_bnb_lowest_fee_exhaust_cap`: frontier expansion at sizes that exhaust the fixed round +//! cap, isolating the cache and cursor hot path. //! - `run_bnb_lowest_fee_ancestors`: the same, but where the coins sit on unconfirmed ancestors that //! need bumping — covering both the private and shared ancestor paths, which cost different //! amounts per fee calculation. @@ -118,25 +120,38 @@ fn bench_compute_view(c: &mut Criterion) { group.finish(); } -fn bench_run_bnb_lowest_fee(c: &mut Criterion) { - let mut group = c.benchmark_group("run_bnb_lowest_fee"); - // Cap iterations so the largest case fits in a benchmark sample. - group.sample_size(20); - for &n in &[20usize, 50, 100, 200] { +const MAX_ROUNDS: usize = 100_000; + +fn bench_run_bnb_lowest_fee_sizes( + c: &mut Criterion, + group_name: &str, + sizes: &[usize], + expect_solution: bool, +) { + let mut group = c.benchmark_group(group_name); + group.sample_size(10); + for &n in sizes { let candidates = make_candidates(n); let (target, long_term_feerate) = make_bnb_inputs(&candidates); - let problem_2 = SelectionProblem::new_no_ancestors(target, candidates.iter().copied()); - let selector = CoinSelector::new(&problem_2); + let problem = SelectionProblem::new_no_ancestors(target, candidates.iter().copied()); + let selector = CoinSelector::new(&problem); + let metric = || LowestFee { + long_term_feerate, + dust_relay_feerate: FeeRate::from_sat_per_vb(1.0), + drain_weights: DrainWeights::TR_KEYSPEND, + }; + assert_eq!( + selector.clone().run_bnb(metric(), MAX_ROUNDS).is_ok(), + expect_solution, + "{}/{} changed search path", + group_name, + n, + ); group.bench_with_input(BenchmarkId::from_parameter(n), &n, |b, _| { b.iter_batched( || selector.clone(), |mut sel| { - let metric = LowestFee { - long_term_feerate, - dust_relay_feerate: FeeRate::from_sat_per_vb(1.0), - drain_weights: DrainWeights::TR_KEYSPEND, - }; - let _ = sel.run_bnb(metric, black_box(100_000)); + let _ = sel.run_bnb(metric(), black_box(MAX_ROUNDS)); sel }, BatchSize::SmallInput, @@ -146,6 +161,19 @@ fn bench_run_bnb_lowest_fee(c: &mut Criterion) { group.finish(); } +fn bench_run_bnb_lowest_fee(c: &mut Criterion) { + bench_run_bnb_lowest_fee_sizes(c, "run_bnb_lowest_fee", &[20, 50, 100], true); +} + +fn bench_run_bnb_lowest_fee_exhaust_cap(c: &mut Criterion) { + bench_run_bnb_lowest_fee_sizes( + c, + "run_bnb_lowest_fee_exhaust_cap", + &[200, 500, 1_000], + false, + ); +} + /// Deterministic synthetic pool where every third coin sits on an unconfirmed chain that still owes /// fees, so every fee calculation has to work out the bump. /// @@ -245,6 +273,7 @@ criterion_group!( bench_coin_selector_clone, bench_compute_view, bench_run_bnb_lowest_fee, + bench_run_bnb_lowest_fee_exhaust_cap, bench_run_bnb_lowest_fee_ancestors ); criterion_main!(benches); From 3a3005e96a0e19896865f32338fa4d591a81b474 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=BF=97=E5=AE=87?= Date: Thu, 13 Aug 2026 14:50:53 +0000 Subject: [PATCH 11/26] perf: compact ancestor-aware BnB cache Store private ancestor totals directly and allocate shared reference tracking only when the problem actually has shared ancestry. Preserve an explicit precision allowance for large floating-point ancestor fees so the smaller cache does not tighten the admissible bound. --- src/bnb.rs | 2 +- src/coin_selector.rs | 4 +- src/feerate.rs | 19 +----- src/selection_problem.rs | 18 ++++++ src/selection_view.rs | 129 ++++++++++++++++++++++++++++----------- tests/ancestor.rs | 19 ++++++ 6 files changed, 137 insertions(+), 54 deletions(-) diff --git a/src/bnb.rs b/src/bnb.rs index 18890ea..1d771bf 100644 --- a/src/bnb.rs +++ b/src/bnb.rs @@ -163,7 +163,7 @@ impl<'a, M: BnbMetric> BnbIter<'a, M> { let mut inclusion_cs = cs.clone(); let mut inclusion_cache = cache.clone(); inclusion_cs.select(next_index); - inclusion_cache.add(cs.problem(), next_index, next); + inclusion_cache.add(cs.problem(), next_index, next, true); self.consider_adding_to_queue(&inclusion_cs, &inclusion_cache, false, cursor + 1); // For the exclusion branch, we keep banning candidates that are interchangeable with the one diff --git a/src/coin_selector.rs b/src/coin_selector.rs index 0db7882..a0cdf73 100644 --- a/src/coin_selector.rs +++ b/src/coin_selector.rs @@ -336,7 +336,9 @@ impl<'a> CoinSelector<'a> { if bound <= 0.0 { 0 } else { - bound as u64 // truncating a positive float is the floor, i.e. rounds down + // Truncating a positive float rounds down. Account for the lower precision used by the + // actual f32 fee calculation so this cannot sit above a descendant's real bump. + (bound as u64).saturating_sub(self.problem.ancestor_fee_precision_slack()) } } diff --git a/src/feerate.rs b/src/feerate.rs index 7aa2761..b4f0490 100644 --- a/src/feerate.rs +++ b/src/feerate.rs @@ -89,13 +89,7 @@ impl FeeRate { /// Same as [implied_fee](Self::implied_fee) except the fee rate given by `self` is applied to `tx_weight` directly. pub fn implied_fee_wu(&self, tx_weight: u64) -> u64 { - let fee = tx_weight as f64 * self.spwu() as f64; - let truncated = fee as u64; - if truncated as f64 == fee { - truncated - } else { - truncated.saturating_add(1) - } + (tx_weight as f32 * self.spwu()).ceil() as u64 } } @@ -114,14 +108,3 @@ impl Sub for FeeRate { Self(Ordf32(self.0 .0 - rhs.0 .0)) } } - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn implied_fee_wu_retains_integer_precision_above_f32_range() { - let rate = FeeRate::from_sat_per_wu(43.0); - assert_eq!(rate.implied_fee_wu(399_999), 17_199_957); - } -} diff --git a/src/selection_problem.rs b/src/selection_problem.rs index 1a3d403..d15a6fd 100644 --- a/src/selection_problem.rs +++ b/src/selection_problem.rs @@ -299,6 +299,24 @@ impl SelectionProblem { self.has_shared_ancestors } + pub(crate) fn ancestor_fee_precision_slack(&self) -> u64 { + let rate = self.target.fee.rate.spwu() as f64; + if rate == 0.0 { + return 0; + } + let total_weight = self + .ancestors + .iter() + .fold(0_u64, |sum, (weight, _)| sum.saturating_add(*weight)); + let max_fee = total_weight as f64 * rate; + if max_fee <= (1_u64 << f32::MANTISSA_DIGITS) as f64 { + return 0; + } + // Conversion and multiplication each round in `implied_fee_wu`. Two f32 epsilons plus one + // satoshi conservatively cover their combined error for every ancestor subset. + ((max_fee * 2.0 * f32::EPSILON as f64).min(u64::MAX as f64) as u64).saturating_add(1) + } + /// The fee still owed so the ancestors only this candidate would drag in meet /// [`Target::fee`](crate::TargetFee)'s rate, as if it were the only selected candidate. /// diff --git a/src/selection_view.rs b/src/selection_view.rs index bac944e..d18b2d6 100644 --- a/src/selection_view.rs +++ b/src/selection_view.rs @@ -25,8 +25,8 @@ pub(crate) struct SelectionCache { private_reachable_surplus: f64, shared_reachable_refcounts: Vec, shared_reachable_surplus: f64, + ancestor_fee_precision_slack: u64, selected: Bitset, - available: Bitset, } impl SelectionCache { @@ -38,30 +38,43 @@ impl SelectionCache { legacy_count: 0, private_weight: 0, private_fee: 0, - shared_refcounts: alloc::vec![0; selector.problem().ancestors().len()], + shared_refcounts: alloc::vec![ + 0; + if selector.problem().has_shared_ancestors() { + selector.problem().ancestors().len() + } else { + 0 + } + ], shared_weight: 0, shared_fee: 0, private_reachable_surplus: 0.0, - shared_reachable_refcounts: alloc::vec![0; selector.problem().ancestors().len()], + shared_reachable_refcounts: alloc::vec![ + 0; + if selector.problem().has_shared_ancestors() { + selector.problem().ancestors().len() + } else { + 0 + } + ], shared_reachable_surplus: 0.0, - selected: Bitset::with_capacity(if selector.problem().has_ancestors() { - selector.problem().len() - } else { - 0 - }), - available: Bitset::with_capacity(if selector.problem().has_ancestors() { - selector.problem().len() - } else { - 0 - }), + ancestor_fee_precision_slack: selector.problem().ancestor_fee_precision_slack(), + // BnB transitions each candidate exactly once, so it needs no duplicate-tracking + // bitset. Public hypothetical updates allocate this lazily in `track_selected`. + selected: Bitset::default(), }; + if selector.problem().has_ancestors() { + for (index, _) in selector.candidates() { + cache.add_reachable(selector.problem(), index); + } + } for (index, candidate) in selector.selected() { - cache.add(selector.problem(), index, candidate); + cache.add(selector.problem(), index, candidate, true); } if selector.problem().has_ancestors() { - for (index, _) in selector.candidates() { - if !selector.is_selected(index) && !selector.banned().contains(index) { - cache.add_reachable(selector.problem(), index); + for index in selector.banned().iter() { + if !selector.is_selected(index) { + cache.ban(selector.problem(), index); } } } @@ -76,9 +89,6 @@ impl SelectionCache { if !problem.has_ancestors() { return; } - if !self.available.insert(index) { - return; - } if problem.has_private_ancestors() { self.private_reachable_surplus += Self::ancestor_surplus(problem, problem.private_ancestors(index)); @@ -100,9 +110,6 @@ impl SelectionCache { if !problem.has_ancestors() { return; } - if !self.available.remove(index) { - return; - } if problem.has_private_ancestors() { self.private_reachable_surplus -= Self::ancestor_surplus(problem, problem.private_ancestors(index)); @@ -129,7 +136,13 @@ impl SelectionCache { input_varint_weight + self.weight_sum + legacy_witness_lengths + witness_header_extra_weight } - pub(crate) fn add(&mut self, problem: &SelectionProblem, index: usize, candidate: Candidate) { + pub(crate) fn add( + &mut self, + problem: &SelectionProblem, + index: usize, + candidate: Candidate, + was_reachable: bool, + ) { if self.selected.capacity() > 0 && !self.selected.insert(index) { return; } @@ -137,7 +150,9 @@ impl SelectionCache { self.weight_sum += candidate.weight; self.segwit_count += candidate.segwit_count; self.legacy_count += candidate.legacy_count; - self.remove_reachable(problem, index); + if was_reachable { + self.remove_reachable(problem, index); + } if problem.has_private_ancestors() { let (weight, fee) = problem.private_ancestors(index); @@ -259,9 +274,12 @@ impl<'a> SelectionView<'a> { pub fn add(&mut self, index: usize) { self.track_selected(); let candidate = self.selector.candidate(index); - self.cache - .to_mut() - .add(self.selector.problem(), index, candidate); + self.cache.to_mut().add( + self.selector.problem(), + index, + candidate, + !self.selector.banned().contains(index), + ); } /// Apply a hypothetical deselection to this view's cached aggregates. @@ -282,7 +300,7 @@ impl<'a> SelectionView<'a> { let candidate = self.selector.candidate(index); self.cache .to_mut() - .add(self.selector.problem(), index, candidate); + .add(self.selector.problem(), index, candidate, true); } pub(crate) fn sub_unchecked(&mut self, index: usize) { @@ -340,7 +358,7 @@ impl<'a> SelectionView<'a> { if bound <= 0.0 { 0 } else { - bound as u64 + (bound as u64).saturating_sub(self.cache.ancestor_fee_precision_slack) } } @@ -496,11 +514,20 @@ impl<'a> SelectionView<'a> { /// A lower bound on the child fee for this branch and every descendant. pub(crate) fn fee_floor(&self) -> u64 { let weight = self.weight(self.target().outputs, DrainWeights::NONE); - let mut floor = (self.target().fee.rate.implied_fee_wu(weight) - + self.ancestor_bump_lower_bound()) - .max(self.target().fee.absolute); + let rate_floor = self + .target() + .fee + .rate + .implied_fee_wu(weight) + .min(self.target().fee.rate.implied_fee(weight)); + let mut floor = + (rate_floor + self.ancestor_bump_lower_bound()).max(self.target().fee.absolute); if let Some(replace) = self.target().fee.replace { - floor = floor.max(replace.min_fee_to_do_replacement_wu(weight)); + floor = floor.max( + replace + .min_fee_to_do_replacement_wu(weight) + .min(replace.min_fee_to_do_replacement(weight)), + ); } floor } @@ -621,6 +648,40 @@ mod tests { assert_eq!(view.ancestor_bump(), 0); } + #[test] + fn hypothetical_banned_candidate_does_not_change_reachability() { + let ancestors = [AncestorToBump { + txid: 1, + weight: 400, + fee: 1_000, + parents: alloc::vec![], + }]; + let inputs = [ + Input { + value: 1_000, + weight: 100, + is_segwit: true, + residing_txid: 1, + }, + Input { + value: 2_000, + weight: 100, + is_segwit: true, + residing_txid: 1, + }, + ]; + let problem = SelectionProblem::new(target(), inputs, ancestors); + let mut selector = problem.selector(); + selector.ban(0); + selector.ban(1); + let mut view = selector.compute_view(); + let bound = view.ancestor_bump_lower_bound(); + + view.add(0); + view.sub(0); + assert_eq!(view.ancestor_bump_lower_bound(), bound); + } + #[test] fn cache_matches_selector_for_every_mixed_selection() { let candidates = [ diff --git a/tests/ancestor.rs b/tests/ancestor.rs index d2ce031..ee1341c 100644 --- a/tests/ancestor.rs +++ b/tests/ancestor.rs @@ -518,6 +518,25 @@ fn bound_credits_the_bump_when_nothing_overpays() { ); } +#[test] +fn bump_lower_bound_accounts_for_large_f32_fee_rounding() { + let t = target(172.0, 1_000); // exactly 43 sat/wu + let problem = SelectionProblem::new( + t, + [input(20_000_000, "P")], + [ancestor("P", 399_999, 0, vec![])], + ); + let mut cs = problem.selector(); + cs.select(0); + + assert!( + cs.ancestor_bump_lower_bound() <= cs.ancestor_bump(), + "the f64 relaxation must not exceed the f32 fee obligation" + ); + let view = cs.compute_view(); + assert!(view.ancestor_bump_lower_bound() <= view.ancestor_bump()); +} + /// An ancestor only one candidate can reach is folded into that candidate up front; the rest are /// left to be de-duplicated per selection. #[test] From 2cd779cf58228fa5ccbf112883120288472dc660 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=BF=97=E5=AE=87?= Date: Thu, 13 Aug 2026 15:26:44 +0000 Subject: [PATCH 12/26] feat: add dedicated LowestFeeChangeless metric Replace generic metric composition with a changeless metric that reuses LowestFee's funding, weight-cap, dust, and change decisions. Add a monotone selected-value bound for pools up to 24 candidates while retaining LowestFee's ordering for larger pools to avoid finite-round starvation. Cover the constrained objective with exhaustive and serialization-edge regressions, and document the migration from Changeless and tuple metrics. --- CHANGELOG.md | 4 +- src/metrics.rs | 4 +- src/metrics/changeless.rs | 44 --------- src/metrics/lowest_fee.rs | 2 +- src/metrics/lowest_fee_changeless.rs | 78 +++++++++++++++ tests/ancestor.rs | 29 ++---- tests/lowest_fee.rs | 13 +-- ...changeless.rs => lowest_fee_changeless.rs} | 98 ++++++++++++------- 8 files changed, 163 insertions(+), 109 deletions(-) delete mode 100644 src/metrics/changeless.rs create mode 100644 src/metrics/lowest_fee_changeless.rs rename tests/{changeless.rs => lowest_fee_changeless.rs} (62%) diff --git a/CHANGELOG.md b/CHANGELOG.md index 25211cd..0e5add0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,8 +8,8 @@ - **Breaking:** `LowestFee` no longer takes a `change_policy`. It now takes `dust_relay_feerate: FeeRate` and `drain_weights: DrainWeights`, and adds change only when doing so lowers the long-term fee and the change would not be dust. - Add `DrainWeights::dust_threshold(dust_relay_feerate)`, the minimum value a change output with these weights must have to not be dust. - Add `CoinSelector::select_srd`, a Single Random Draw selector (port of Bitcoin Core's `SelectCoinsSRD`) that adds candidates in random order until the change reaches `change_lower`, producing a healthy-sized (privacy-friendly) change output instead of minimizing fees. Adds the `CHANGE_LOWER` constant for Core's value. -- **Breaking:** `Changeless` is now `Changeless`, wrapping an inner metric it constrains to changeless solutions (e.g. `Changeless`), replacing the previous tuple-composition approach. -- **Breaking:** Removed the `BnbMetric` tuple implementations (`impl BnbMetric for ((A, f32), ...)`). Weighted composition of independent metrics is no longer supported; the only composition still provided is the changeless constraint, now expressed as `Changeless`. If you relied on tuples to blend multiple objectives, there is no drop-in replacement. +- Add `LowestFeeChangeless`, a dedicated lowest-fee changeless metric with bounds for the constrained objective. +- **Breaking:** Remove `Changeless` and the `BnbMetric` tuple implementations (`impl BnbMetric for ((A, f32), ...)`). Generic metric composition is no longer supported. Use `LowestFeeChangeless` when a changeless transaction is required; otherwise use `LowestFee`. - **Breaking:** `CoinSelector::selected_indices` and `CoinSelector::banned` now return `&Bitset` instead of `&BTreeSet`. `Bitset` exposes `contains`/`len`/`is_empty`/`iter` (#46) - Replace the internal `Cow`/`Cow<[usize]>` selection state with a `Bitset` and an `Arc`-shared candidate order, making the per-branch clones in branch-and-bound substantially cheaper (#46) - Fix compilation error when building with `--no-default-features` (#36) diff --git a/src/metrics.rs b/src/metrics.rs index 1da1163..36192aa 100644 --- a/src/metrics.rs +++ b/src/metrics.rs @@ -5,5 +5,5 @@ //! [`CoinSelector::run_bnb`]: crate::CoinSelector::run_bnb mod lowest_fee; pub use lowest_fee::*; -mod changeless; -pub use changeless::*; +mod lowest_fee_changeless; +pub use lowest_fee_changeless::*; diff --git a/src/metrics/changeless.rs b/src/metrics/changeless.rs deleted file mode 100644 index 9732e4d..0000000 --- a/src/metrics/changeless.rs +++ /dev/null @@ -1,44 +0,0 @@ -use crate::{bnb::BnbMetric, float::Ordf32, Drain, SelectionView}; - -/// Constrains an `inner` metric to only changeless solutions. -/// -/// A selection is scored by `inner` only if the inner metric decides it should *not* have a change -/// output (see [`BnbMetric::drain`]); otherwise it is treated as invalid. This lets you find, for -/// example, the lowest-fee changeless solution via `Changeless`. -#[derive(Clone, Copy, Debug)] -pub struct Changeless( - /// The inner metric that scores changeless solutions and owns the change decision. - pub M, -); - -impl BnbMetric for Changeless { - fn drain(&mut self, _cs: &SelectionView<'_>) -> Drain { - // by definition a changeless selection never has a change output - Drain::NONE - } - - fn score(&mut self, cs: &SelectionView<'_>) -> Option { - // Reject selections that have change. We don't need an explicit target-met check: `inner` - // returns `None` for invalid (e.g. not-target-met) selections. - // - // NOTE: for metrics whose `score` recomputes the drain (e.g. `LowestFee`), this evaluates - // the drain decision twice per node. Sharing it would mean threading the drain into - // `score`, which we avoid to keep metrics composable. - if self.0.drain(cs).is_some() { - return None; - } - self.0.score(cs) - } - - fn bound(&mut self, cs: &SelectionView<'_>) -> Option { - // The changeless-constrained optimum is no better than the inner metric's unconstrained - // optimum, so the inner bound is a valid lower bound. Change-unavoidability pruning is not - // generally sound because candidate marginal fees depend on vbyte rounding, RBF, framing, - // and ancestry. - self.0.bound(cs) - } - - fn requires_ordering_by_descending_value_pwu(&self) -> bool { - self.0.requires_ordering_by_descending_value_pwu() - } -} diff --git a/src/metrics/lowest_fee.rs b/src/metrics/lowest_fee.rs index 61fe386..6af8b50 100644 --- a/src/metrics/lowest_fee.rs +++ b/src/metrics/lowest_fee.rs @@ -43,7 +43,7 @@ pub struct LowestFee { impl LowestFee { /// The value the change output should have, or `None` if this selection should be changeless. - fn drain_value(&self, cs: &SelectionView<'_>) -> Option { + pub(super) fn drain_value(&self, cs: &SelectionView<'_>) -> Option { // The change output pays for its own weight, so the value we'd actually recover is the // excess remaining after accounting for that weight. let excess_with_drain_weight = cs.excess(Drain { diff --git a/src/metrics/lowest_fee_changeless.rs b/src/metrics/lowest_fee_changeless.rs new file mode 100644 index 0000000..334a949 --- /dev/null +++ b/src/metrics/lowest_fee_changeless.rs @@ -0,0 +1,78 @@ +use crate::{float::Ordf32, BnbMetric, Drain, DrainWeights, FeeRate, SelectionView}; + +use super::LowestFee; + +/// Metric that minimizes fees while only accepting selections for which [`LowestFee`] chooses no +/// change output. +/// +/// This reuses [`LowestFee`]'s change decision, including the future cost of spending change, its +/// dust threshold, and the transaction weight cap. A selection is only valid here when that +/// decision returns no change output. +/// +/// Unlike constraining an arbitrary metric after the fact, this metric has a changeless-specific +/// lower bound. A changeless selection's score is its selected value minus the target value. Since +/// selected value can only increase down a branch, the current no-change fee is a lower bound for +/// every descendant, including when unconfirmed ancestry makes funding non-monotone. The bound +/// combines that fact with [`LowestFee`]'s funding relaxation. +#[derive(Clone, Copy, Debug)] +pub struct LowestFeeChangeless { + /// The estimated feerate needed to spend a potential change output later. + pub long_term_feerate: FeeRate, + /// The feerate used to determine the dust threshold of a potential change output. + pub dust_relay_feerate: FeeRate, + /// The weights of the potential change output. + pub drain_weights: DrainWeights, +} + +impl LowestFeeChangeless { + fn lowest_fee(self) -> LowestFee { + LowestFee { + long_term_feerate: self.long_term_feerate, + dust_relay_feerate: self.dust_relay_feerate, + drain_weights: self.drain_weights, + } + } +} + +impl From for LowestFeeChangeless { + fn from(metric: LowestFee) -> Self { + Self { + long_term_feerate: metric.long_term_feerate, + dust_relay_feerate: metric.dust_relay_feerate, + drain_weights: metric.drain_weights, + } + } +} + +impl BnbMetric for LowestFeeChangeless { + fn drain(&mut self, _cs: &SelectionView<'_>) -> Drain { + Drain::NONE + } + + fn score(&mut self, cs: &SelectionView<'_>) -> Option { + if !cs.is_funded() + || !cs.is_within_max_weight(DrainWeights::NONE) + || self.lowest_fee().drain_value(cs).is_some() + { + return None; + } + + Some(Ordf32( + cs.selected_value().saturating_sub(cs.target().value()) as f32, + )) + } + + fn bound(&mut self, cs: &SelectionView<'_>) -> Option { + let mut lowest_fee = self.lowest_fee(); + let funding_bound = lowest_fee.bound(cs)?; + if cs.problem().len() > 24 { + return Some(funding_bound); + } + let no_change_fee = Ordf32(cs.selected_value().saturating_sub(cs.target().value()) as f32); + Some(funding_bound.max(no_change_fee)) + } + + fn requires_ordering_by_descending_value_pwu(&self) -> bool { + true + } +} diff --git a/tests/ancestor.rs b/tests/ancestor.rs index ee1341c..72ecb6b 100644 --- a/tests/ancestor.rs +++ b/tests/ancestor.rs @@ -10,7 +10,7 @@ mod common; use bdk_coin_select::{ float::Ordf32, - metrics::{Changeless, LowestFee}, + metrics::{LowestFee, LowestFeeChangeless}, AncestorToBump, BnbMetric, Candidate, CoinSelector, Drain, DrainWeights, FeeRate, Input, Replace, SelectionProblem, Target, TargetFee, TargetOutputs, TX_FIXED_FIELD_WEIGHT, }; @@ -328,13 +328,11 @@ fn score_is_the_childs_fee_which_already_covers_the_bump() { } /// A changeless solution can be reachable *only* by adding a coin whose ancestor eats the excess, -/// which the `Changeless` wrapper's prune cannot see. +/// which a changeless bound must account for. /// -/// That prune asks "does the reachable selection with the least excess still have change?", and -/// builds it by adding the remaining coins with negative effective value. Here the coin that kills -/// the change looks profitable on its own (1000 sats for 200 wu) — it only shrinks the excess -/// because it drags in an ancestor owing 10_800 sats. So the prune concludes change is unavoidable -/// and would discard the one changeless solution there is. +/// Here the coin that kills the change looks profitable on its own (1000 sats for 200 wu). It only +/// shrinks the excess because it drags in an ancestor owing 10_800 sats, so a bound cannot assume +/// excess is monotone or infer that change is unavoidable from standalone effective values. #[test] fn changeless_solution_reachable_only_via_an_ancestor_is_not_pruned() { let t = target(1.0, 100_000); @@ -386,7 +384,7 @@ fn changeless_solution_reachable_only_via_an_ancestor_is_not_pruned() { // that has change. let mut cs = problem.selector(); let (score, drain) = cs - .run_bnb(Changeless(metric()), 100_000) + .run_bnb(LowestFeeChangeless::from(metric()), 100_000) .expect("the changeless solution must not be pruned"); assert!(drain.is_none()); assert!(cs.is_selected(0) && cs.is_selected(1)); @@ -1078,26 +1076,19 @@ proptest! { } } - /// Same for the changeless-constrained metric, whose extra prune ("every reachable selection - /// would have change") also leans on a candidate costing only its own weight. - /// - /// NOTE: `max_weight` is forced off here. `Changeless` disagrees with brute force on - /// capped problems *without* any ancestors too (`LowestFee` reports a selection as changeless - /// when change would bust the cap, a route to changelessness that `Changeless`'s - /// excess-monotone prune doesn't consider), so that is a separate, pre-existing issue rather - /// than something ancestors introduce. + /// Same for the dedicated changeless metric, including capped problems. #[test] fn changeless_bnb_finds_the_brute_force_optimum( - spec in spec_strategy().prop_map(|spec| AncestorProblemSpec { max_weight: None, ..spec }), + spec in spec_strategy(), ) { let problem = spec.build(); let mut exhaustive_cs = problem.selector(); - let mut exhaustive_metric = Changeless(metric()); + let mut exhaustive_metric = LowestFeeChangeless::from(metric()); let expected = common::exhaustive_search(&mut exhaustive_cs, &mut exhaustive_metric); let mut bnb_cs = problem.selector(); - let found = common::bnb_search(&mut bnb_cs, Changeless(metric()), usize::MAX); + let found = common::bnb_search(&mut bnb_cs, LowestFeeChangeless::from(metric()), usize::MAX); match (expected, found) { (Some((expected_score, _)), Ok((score, _))) => { diff --git a/tests/lowest_fee.rs b/tests/lowest_fee.rs index c70efc7..d6606d2 100644 --- a/tests/lowest_fee.rs +++ b/tests/lowest_fee.rs @@ -1,6 +1,6 @@ #![allow(unused_imports)] mod common; -use bdk_coin_select::metrics::{Changeless, LowestFee}; +use bdk_coin_select::metrics::{LowestFee, LowestFeeChangeless}; use bdk_coin_select::{ BnbMetric, Candidate, ChangePolicy, CoinSelector, Drain, DrainWeights, FeeRate, NoBnbSolution, Replace, SelectionProblem, Target, TargetFee, TargetOutputs, TX_FIXED_FIELD_WEIGHT, @@ -174,9 +174,8 @@ proptest! { } } -/// We wrap `LowestFee` in `Changeless` to derive a metric that finds the lowest-fee changeless -/// solution. Constraining to changeless should never take fewer rounds than the unconstrained -/// `LowestFee`. +/// The dedicated changeless metric shares `LowestFee`'s eligibility rules while bounding its +/// narrower objective directly. #[test] fn combined_changeless_metric() { let params = common::StrategyParams { @@ -202,7 +201,7 @@ fn combined_changeless_metric() { let mut cs_b = CoinSelector::new(&problem_5); let metric_lowest_fee = params.lowest_fee_metric(); - let metric_changeless = Changeless(params.lowest_fee_metric()); + let metric_changeless = LowestFeeChangeless::from(params.lowest_fee_metric()); // cs_a uses the unconstrained metric let (score, rounds) = @@ -214,7 +213,9 @@ fn combined_changeless_metric() { common::bnb_search(&mut cs_b, metric_changeless, usize::MAX).expect("must find solution"); println!("score={:?} rounds={}", combined_score, combined_rounds); - assert!(combined_rounds >= rounds); + assert!(combined_score >= score); + let mut change_decision = params.lowest_fee_metric(); + assert!(change_decision.drain(&cs_b.compute_view()).is_none()); } /// Because this metric decides change optimally, it never creates a change output whose value diff --git a/tests/changeless.rs b/tests/lowest_fee_changeless.rs similarity index 62% rename from tests/changeless.rs rename to tests/lowest_fee_changeless.rs index e742cda..8bec18d 100644 --- a/tests/changeless.rs +++ b/tests/lowest_fee_changeless.rs @@ -1,13 +1,49 @@ -#![allow(unused)] mod common; use bdk_coin_select::{ - float::Ordf32, - metrics::{Changeless, LowestFee}, - BnbMetric, Candidate, CoinSelector, DrainWeights, FeeRate, SelectionProblem, Target, TargetFee, - TargetOutputs, + float::Ordf32, metrics::LowestFeeChangeless, BnbMetric, Candidate, DrainWeights, FeeRate, + SelectionProblem, Target, TargetFee, TargetOutputs, }; +#[cfg(not(debug_assertions))] use proptest::{prelude::*, proptest, test_runner::*}; -use rand::{prelude::IteratorRandom, Rng, RngCore}; +#[cfg(not(debug_assertions))] +use rand::{Rng, RngCore}; + +#[test] +fn funded_changeful_branch_is_bounded_by_its_no_change_fee() { + let target = Target { + outputs: TargetOutputs { + n_outputs: 1, + value_sum: 100_000, + weight_sum: 100, + }, + fee: TargetFee::from_feerate(FeeRate::from_sat_per_vb(1.0)), + max_weight: None, + }; + let problem = SelectionProblem::new_no_ancestors( + target, + [Candidate { + value: 110_000, + weight: 200, + segwit_count: 1, + legacy_count: 0, + }], + ); + let mut selector = problem.selector(); + selector.select(0); + let mut metric = LowestFeeChangeless { + long_term_feerate: FeeRate::ZERO, + dust_relay_feerate: FeeRate::ZERO, + drain_weights: DrainWeights { + output_weight: 100, + spend_weight: 100, + n_outputs: 1, + }, + }; + let view = selector.compute_view(); + + assert!(metric.score(&view).is_none(), "this selection wants change"); + assert_eq!(metric.bound(&view), Some(Ordf32(10_000.0))); +} #[test] fn mixed_serialization_overhead_does_not_prune_exact_solution() { @@ -36,11 +72,11 @@ fn mixed_serialization_overhead_does_not_prune_exact_solution() { ]; let problem = SelectionProblem::new_no_ancestors(target, candidates); let mut selector = problem.selector(); - let metric = Changeless(LowestFee { + let metric = LowestFeeChangeless { long_term_feerate: FeeRate::ZERO, dust_relay_feerate: FeeRate::ZERO, drain_weights: DrainWeights::NONE, - }); + }; let mut expected = problem.selector(); expected.select_all(); @@ -55,6 +91,7 @@ fn mixed_serialization_overhead_does_not_prune_exact_solution() { assert_eq!(selector.excess(bdk_coin_select::Drain::NONE), 0); } +#[cfg(not(debug_assertions))] fn test_wv(mut rng: impl RngCore) -> impl Iterator { core::iter::repeat_with(move || { let value = rng.random_range(0..1_000); @@ -67,11 +104,11 @@ fn test_wv(mut rng: impl RngCore) -> impl Iterator { }) } +#[cfg(not(debug_assertions))] proptest! { #![proptest_config(ProptestConfig::default())] #[test] - #[cfg(not(debug_assertions))] // too slow if compiling for debug fn compare_against_benchmarks( n_candidates in 0..15_usize, // candidates (n) target_value in 500..1_000_000_u64, // target value (sats) @@ -79,14 +116,11 @@ proptest! { target_weight in 0..10_000_u32, // the sum of the weight of the outputs (wu) replace in common::maybe_replace(0..10_000u64), // The weight of the transaction we're replacing feerate in 1.0..100.0_f32, // feerate (sats/vb) - feerate_lt_diff in -5.0..50.0_f32, // longterm feerate diff (sats/vb) drain_weight in 100..=500_u32, // drain weight (wu) drain_spend_weight in 1..=2000_u32, // drain spend weight (wu) - drain_dust in 100..=1000_u64, // drain dust (sats) n_drain_outputs in 1..150usize, // the number of drain outputs ) { println!("======================================="); - let start = std::time::Instant::now(); let mut rng = TestRng::deterministic_rng(RngAlgorithm::ChaCha); let feerate = FeeRate::from_sat_per_vb(feerate); let drain_weights = DrainWeights { @@ -113,38 +147,32 @@ proptest! { max_weight: None, }; let problem = SelectionProblem::new_no_ancestors(target, candidates.iter().copied()); - let cs = CoinSelector::new(&problem); - let make_metric = || { - Changeless(LowestFee { + LowestFeeChangeless { long_term_feerate: feerate, dust_relay_feerate: FeeRate::from_sat_per_vb(1.0), drain_weights, - }) + } }; - let solutions = cs.bnb_solutions(make_metric()); - - println!("candidates: {:#?}", cs.candidates().collect::>()); + let mut exhaustive_cs = problem.selector(); + let mut exhaustive_metric = make_metric(); + let expected = common::exhaustive_search(&mut exhaustive_cs, &mut exhaustive_metric); - let best = solutions - .enumerate() - .filter_map(|(i, sol)| Some((i, sol?))) - .last(); + let mut bnb_cs = problem.selector(); + let found = common::bnb_search(&mut bnb_cs, make_metric(), usize::MAX); - - match best { - Some((_i, (_sol, _score))) => { - /* there is nothing to check about a changeless solution */ - } - None => { - let mut cs = cs.clone(); - let mut metric = make_metric(); - let has_solution = common::exhaustive_search(&mut cs, &mut metric).is_some(); - dbg!(format!("{}", cs)); - assert!(!has_solution); + match (expected, found) { + (Some((expected_score, _)), Ok((score, _))) => { + prop_assert_eq!(score, expected_score, "bnb={} exhaustive={}", bnb_cs, exhaustive_cs); } + (None, Err(_)) => {} + (expected, found) => prop_assert!( + false, + "disagreement: exhaustive={:?} bnb={:?}", + expected.map(|(score, _)| score), + found.map(|(score, _)| score), + ), } - dbg!(start.elapsed()); } } From 7e965cfa6830a272449e63a462ace6dcbcec3a9d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=BF=97=E5=AE=87?= Date: Fri, 14 Aug 2026 07:01:25 +0000 Subject: [PATCH 13/26] docs: polish ancestor-aware API guidance --- CHANGELOG.md | 8 ++- README.md | 59 +++++++++++++++++--- src/bnb.rs | 2 +- src/coin_selector.rs | 82 ++++++++++++++-------------- src/metrics/lowest_fee.rs | 20 ++++--- src/metrics/lowest_fee_changeless.rs | 9 ++- src/selection_problem.rs | 21 +++++-- src/selection_view.rs | 17 ++++-- 8 files changed, 141 insertions(+), 77 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0e5add0..143e142 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,14 +1,16 @@ # Unreleased - **Breaking:** Replace `Candidate`'s `input_count` and `is_segwit` fields with `segwit_count` and `legacy_count`, fixing `CoinSelector::input_weight` undercounting candidates that group multiple inputs: in a segwit transaction every legacy input still serializes an empty witness (1 WU), which was previously paid once per candidate instead of once per legacy input, so a group of N legacy inputs came out N-1 WU short. Splitting the count by script type also means a single candidate may now mix legacy and segwit inputs and still be priced exactly. Replaces `Candidate::new` with `Candidate::new_segwit` and `Candidate::new_legacy`. -- Add `SelectionView`, a cached read-only view obtained with `CoinSelector::compute_view`. `BnbMetric::{score, bound, drain}` now consume `&SelectionView`; branch and bound maintains its aggregates incrementally while the selector continues to own its `SelectionProblem` and target. +- **Breaking:** Add `SelectionProblem`, which owns the target, candidates, and optional unconfirmed-ancestor data for a selection run. `CoinSelector::new` now takes `&SelectionProblem`, and the selector borrows it for its lifetime. +- Charge selections for the fee needed to bring the union of their unconfirmed ancestors up to the target feerate. Build ancestor-aware problems from `Input`/`InputGroup` and `AncestorToBump` with `SelectionProblem::new`; use `SelectionProblem::new_no_ancestors` for prebuilt candidates that need no CPFP bump. +- Add `SelectionView`, a cached view obtained with `CoinSelector::compute_view`. `BnbMetric::{score, bound, drain}` now consume `&SelectionView`; branch and bound maintains its aggregates incrementally while the underlying selector remains unchanged. - Add a per-branch cursor to avoid repeatedly scanning already-decided candidates during branch-and-bound search. - **Breaking:** `BnbMetric` metrics now decide the change output themselves. The trait gains a `drain(&mut self, cs) -> Drain` method; call it on a branch-and-bound solution (or the `LowestFee` metric directly) to get the change output the metric optimized against, instead of computing a separate `ChangePolicy`. - **Breaking:** `CoinSelector::run_bnb` now returns `(Ordf32, Drain)` instead of just `Ordf32`, handing back the change output the metric decided on for the winning selection. -- **Breaking:** `LowestFee` no longer takes a `change_policy`. It now takes `dust_relay_feerate: FeeRate` and `drain_weights: DrainWeights`, and adds change only when doing so lowers the long-term fee and the change would not be dust. +- **Breaking:** `LowestFee` no longer takes a `change_policy`. It now takes `dust_relay_feerate: FeeRate` and `drain_weights: DrainWeights`, and adds change only when doing so lowers the long-term fee, the value is at least the dust threshold, and the transaction with change fits `Target::max_weight`. - Add `DrainWeights::dust_threshold(dust_relay_feerate)`, the minimum value a change output with these weights must have to not be dust. - Add `CoinSelector::select_srd`, a Single Random Draw selector (port of Bitcoin Core's `SelectCoinsSRD`) that adds candidates in random order until the change reaches `change_lower`, producing a healthy-sized (privacy-friendly) change output instead of minimizing fees. Adds the `CHANGE_LOWER` constant for Core's value. -- Add `LowestFeeChangeless`, a dedicated lowest-fee changeless metric with bounds for the constrained objective. +- Add `LowestFeeChangeless`, a dedicated lowest-fee changeless metric. It applies its changeless-specific bound to pools of at most 24 candidates and retains `LowestFee`'s search bound and ordering for larger pools to avoid starving useful branches under finite round limits. - **Breaking:** Remove `Changeless` and the `BnbMetric` tuple implementations (`impl BnbMetric for ((A, f32), ...)`). Generic metric composition is no longer supported. Use `LowestFeeChangeless` when a changeless transaction is required; otherwise use `LowestFee`. - **Breaking:** `CoinSelector::selected_indices` and `CoinSelector::banned` now return `&Bitset` instead of `&BTreeSet`. `Bitset` exposes `contains`/`len`/`is_empty`/`iter` (#46) - Replace the internal `Cow`/`Cow<[usize]>` selection state with a `Bitset` and an `Arc`-shared candidate order, making the per-branch clones in branch-and-bound substantially cheaper (#46) diff --git a/README.md b/README.md index b42370c..e107a18 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,7 @@ > ⚠ This work is only ready to use by those who expect (potentially catastrophic) bugs and will have > the time to investigate them and contribute back to this crate. -## Synopis +## Synopsis ```rust use std::str::FromStr; @@ -123,8 +123,8 @@ let candidates = [ weight: TR_KEYSPEND_TXIN_WEIGHT, } ]; -let drain_weights = bdk_coin_select::DrainWeights::default(); -// You could determine this by looking at the user's transaction history and taking an average of the feerate. +let drain_weights = bdk_coin_select::DrainWeights::TR_KEYSPEND; +// A wallet-policy or fee-estimator assumption for the future spend of change. let long_term_feerate = FeeRate::from_sat_per_vb(10.0); let target = Target { @@ -146,7 +146,8 @@ let dust_relay_feerate = FeeRate::from_sat_per_vb(3.0); // The LowestFee metric tries to make selections that minimize your total fees paid over time. It // decides for itself whether to add a change output: change is added whenever doing so reduces the -// long-term fee (factoring in the cost to spend the output later on) and the change wouldn't be dust. +// long-term fee (factoring in the cost to spend the output later on), the value is at least the +// dust threshold, and the transaction with change fits its weight cap. let mut metric = LowestFee { long_term_feerate, // used to calculate the cost of spending the change output in the future dust_relay_feerate, @@ -175,11 +176,55 @@ let selection = coin_selector .collect::>(); println!("we selected {} inputs", selection.len()); -println!("We are including a change output of {} value (0 means not change)", change.value); +println!("We are including a change output of {} value (0 means no change)", change.value); ``` -# Minimum Supported Rust Version (MSRV) +## Unconfirmed ancestors -This library is compiles on rust v1.54 and above +Use `SelectionProblem::new` when spending unconfirmed UTXOs. Supply every unconfirmed transaction +that created an input and all of its transitive unconfirmed ancestors; missing transaction ids are +treated as confirmed and can make the required CPFP fee too low. Parent lists contain direct parents +only. Ancestors shared by several selected inputs are charged once over their union. + +```rust +use bdk_coin_select::{ + AncestorToBump, FeeRate, Input, SelectionProblem, Target, TargetFee, TargetOutputs, +}; + +let target = Target { + fee: TargetFee::from_feerate(FeeRate::from_sat_per_vb(5.0)), + outputs: TargetOutputs::fund_outputs([(136, 50_000)]), + max_weight: None, +}; +let inputs = [Input { + value: 100_000, + weight: 272, + is_segwit: true, + residing_txid: "child", +}]; +let ancestors = [ + AncestorToBump { + txid: "parent", + weight: 400, + fee: 100, + parents: vec![], + }, + AncestorToBump { + txid: "child", + weight: 600, + fee: 200, + parents: vec!["parent"], + }, +]; +let problem = SelectionProblem::new(target, inputs, ancestors); +let mut coin_selector = problem.selector(); +``` + +Adding an input may drag in more fee debt than value, so funding is not necessarily monotone for +ancestor-aware problems. `run_bnb` accounts for this and de-duplicates shared ancestors. + +## Minimum Supported Rust Version (MSRV) + +This library compiles on Rust 1.54 and above. diff --git a/src/bnb.rs b/src/bnb.rs index 1d771bf..fae92d0 100644 --- a/src/bnb.rs +++ b/src/bnb.rs @@ -217,7 +217,7 @@ struct Branch<'a> { impl Ord for Branch<'_> { fn cmp(&self, other: &Self) -> core::cmp::Ordering { - // NOTE: Reverse comparision `lower_bound` because we want a min-heap (by default BinaryHeap + // NOTE: Reverse comparison `lower_bound` because we want a min-heap (by default BinaryHeap // is a max-heap). // NOTE: We tiebreak equal scores based on whether it's exlusion or not (preferring // inclusion). We do this because we want to try and get to evaluating complete selection diff --git a/src/coin_selector.rs b/src/coin_selector.rs index a0cdf73..e9ed12b 100644 --- a/src/coin_selector.rs +++ b/src/coin_selector.rs @@ -10,7 +10,7 @@ use alloc::{sync::Arc, vec::Vec}; /// `change_lower` argument of [`CoinSelector::select_srd`]. pub const CHANGE_LOWER: u64 = 50_000; -/// [`CoinSelector`] selects/deselects coins from a set of canididate coins. +/// [`CoinSelector`] selects or deselects coins from a set of candidate coins. /// /// You can manually select coins using methods like [`select`], or automatically with methods such /// as [`bnb_solutions`]. @@ -32,9 +32,8 @@ impl<'a> CoinSelector<'a> { /// ancestor-bump data. Methods refer to candidates by index into /// [`SelectionProblem::candidates`]. /// - /// The `CoinSelector` does not keep track of the final transaction's output count. The caller - /// is responsible for including the potential output-count varint weight change in the - /// corresponding [`DrainWeights`]. + /// Record the number of potential change outputs in [`DrainWeights::n_outputs`]. The selector + /// then accounts for the resulting output-count varint weight change automatically. pub fn new(problem: &'a SelectionProblem) -> Self { let n = problem.len(); Self { @@ -55,7 +54,7 @@ impl<'a> CoinSelector<'a> { self.problem } - /// Build a cached read-only view of the current selection. + /// Build a cached view of the current selection for aggregate queries and hypothetical updates. pub fn compute_view(&'a self) -> SelectionView<'a> { SelectionView::from_selector(self) } @@ -75,27 +74,26 @@ impl<'a> CoinSelector<'a> { self.problem.candidate(index) } - /// Deselect a candidate at `index`. `index` refers to its position in the original `candidates` - /// slice of [`SelectionProblem::candidates`]. + /// Deselect a candidate at `index`, its position in [`SelectionProblem::candidates`]. pub fn deselect(&mut self, index: usize) -> bool { self.selected.remove(index) } - /// Convienince method to pick elements of a slice by the indexes that are currently selected. - /// Obviously the slice must represent the inputs ordered in the same way as when they were - /// passed to `Candidates::new`. + /// Convenience method to pick elements of a slice by the indices that are currently selected. + /// + /// The slice must contain one element per [`SelectionProblem::candidates`] entry in construction + /// order. pub fn apply_selection(&self, candidates: &'a [T]) -> impl Iterator + '_ { self.selected.iter().map(move |i| &candidates[i]) } - /// Select the input at `index`. `index` refers to its position in the original `candidates` - /// slice of [`SelectionProblem::candidates`]. + /// Select the candidate at `index`, its position in [`SelectionProblem::candidates`]. pub fn select(&mut self, index: usize) -> bool { assert!(index < self.problem.len()); self.selected.insert(index) } - /// Select the next unselected candidate in the sorted order fo the candidates. + /// Select the next unselected candidate in the current candidate order. pub fn select_next(&mut self) -> bool { let next = self.unselected_indices().next(); if let Some(next) = next { @@ -109,7 +107,7 @@ impl<'a> CoinSelector<'a> { /// Ban an input from being selected. Banning the input means it won't show up in [`unselected`] /// or [`unselected_indices`]. Note it can still be manually selected. /// - /// `index` refers to its position in the original `candidates` slice of [`SelectionProblem::candidates`]. + /// `index` is its position in [`SelectionProblem::candidates`]. /// /// [`unselected`]: Self::unselected /// [`unselected_indices`]: Self::unselected_indices @@ -124,8 +122,7 @@ impl<'a> CoinSelector<'a> { &self.banned } - /// Is the input at `index` selected. `index` refers to its position in the original - /// `candidates` slice of [`SelectionProblem::candidates`]. + /// Whether the candidate at `index` in [`SelectionProblem::candidates`] is selected. pub fn is_selected(&self, index: usize) -> bool { self.selected.contains(index) } @@ -524,12 +521,12 @@ impl<'a> CoinSelector<'a> { self.input_weight() as f32 * (feerate.spwu() - long_term_feerate.spwu()) } - /// Sorts the candidates by the comparision function. + /// Sorts the candidates by the comparison function. /// - /// The comparision function takes the candidates's index and the [`Candidate`]. + /// The comparison function takes the candidate's index and the [`Candidate`]. /// /// Note this function does not change the index of the candidates after sorting, just the order - /// in which they will be returned when interating over them in [`candidates`] and [`unselected`]. + /// in which they will be returned when iterating over them in [`candidates`] and [`unselected`]. /// /// [`candidates`]: CoinSelector::candidates /// [`unselected`]: CoinSelector::unselected @@ -544,10 +541,10 @@ impl<'a> CoinSelector<'a> { /// Sorts the candidates by the key function. /// - /// The key function takes the candidates's index and the [`Candidate`]. + /// The key function takes the candidate's index and the [`Candidate`]. /// /// Note this function does not change the index of the candidates after sorting, just the order - /// in which they will be returned when interating over them in [`candidates`] and [`unselected`]. + /// in which they will be returned when iterating over them in [`candidates`] and [`unselected`]. /// /// [`candidates`]: CoinSelector::candidates /// [`unselected`]: CoinSelector::unselected @@ -634,7 +631,7 @@ impl<'a> CoinSelector<'a> { .min() } - /// The indices of the selelcted candidates. + /// The indices of the selected candidates. pub fn selected_indices(&self) -> &Bitset { &self.selected } @@ -659,9 +656,8 @@ impl<'a> CoinSelector<'a> { /// Whether the tx implied by the current selection plus a drain of `drain_weights` is within /// [`Target::max_weight`]. Pass [`DrainWeights::NONE`] for a changeless tx. /// - /// Always `true` when `max_weight` is `None`. Note this is the *anti-monotone* half of - /// feasibility (adding inputs adds weight), so it is kept separate from the monotone - /// value-only [`is_funded`](Self::is_funded). + /// Always `true` when `max_weight` is `None`. Adding inputs cannot reduce child transaction + /// weight, so this constraint is kept separate from value funding. pub fn is_within_max_weight(&self, drain_weights: DrainWeights) -> bool { match self.target().max_weight { Some(max_weight) => self.weight(self.target().outputs, drain_weights) <= max_weight, @@ -672,19 +668,18 @@ impl<'a> CoinSelector<'a> { /// Whether the selection covers the target value (i.e. [`excess`](Self::excess) is /// non-negative), ignoring [`Target::max_weight`]. /// - /// This is **monotone** — selecting more never un-meets it — *unless* the problem has - /// unconfirmed ancestors, in which case adding an input can drag in an ancestor whose bump - /// exceeds the input's value (see [`ancestor_bump`](Self::ancestor_bump)). It deliberately does - /// not include the weight cap — see [`is_within_max_weight`](Self::is_within_max_weight). + /// Adding an input normally helps, but can increase serialization overhead, and unconfirmed + /// ancestors add stronger non-monotonicity when their bump exceeds the input's value (see + /// [`ancestor_bump`](Self::ancestor_bump)). This deliberately excludes the weight cap; see + /// [`is_within_max_weight`](Self::is_within_max_weight). pub fn is_funded_with_drain(&self, drain: Drain) -> bool { self.excess(drain) >= 0 } /// Whether the selection covers the target **value** (net of input fees), i.e. [`excess`] is - /// non-negative. Monotone unless the problem has unconfirmed ancestors (see - /// [`is_funded_with_drain`] and [`ancestor_bump`]), and it deliberately does *not* check - /// [`Target::max_weight`] — that is the separate, anti-monotone [`is_within_max_weight`]. See - /// [`is_funded_with_drain`] for the version that accounts for a specific `drain`. + /// non-negative. It deliberately does *not* check [`Target::max_weight`]; use + /// [`is_within_max_weight`] for that constraint. See [`is_funded_with_drain`] for the version + /// that accounts for a specific `drain`. /// /// [`excess`]: Self::excess /// [`ancestor_bump`]: Self::ancestor_bump @@ -706,7 +701,7 @@ impl<'a> CoinSelector<'a> { /// The value of the change output should have to drain the excess value while maintaining the /// constraints of `target` and respecting `change_policy`. /// - /// If not change output should be added according to policy then it will return `None`. + /// If no change output should be added according to policy, this returns `None`. pub fn drain_value(&self, change_policy: ChangePolicy) -> Option { let excess = self.excess(Drain { weights: change_policy.drain_weights, @@ -750,11 +745,12 @@ impl<'a> CoinSelector<'a> { /// Select all candidates with an *effective value* greater than 0 at the provided `feerate`. /// - /// A candidate if effective if it provides more value than it takes to pay for at `feerate`. + /// A candidate is effective if it provides more value than it costs at `feerate`. /// /// This looks at each candidate's own value and weight only: a candidate that pays for itself /// but drags in an unconfirmed ancestor still counts as effective, even if the resulting - /// [`ancestor_bump`](Self::ancestor_bump) outweighs it. + /// [`ancestor_bump`](Self::ancestor_bump) outweighs it. Selection-dependent input-count and + /// witness serialization overhead are also excluded from this standalone calculation. pub fn select_all_effective(&mut self, feerate: FeeRate) { for i in 0..self.candidate_order.len() { let cand_index = self.candidate_order[i]; @@ -772,14 +768,15 @@ impl<'a> CoinSelector<'a> { /// /// # Errors /// - /// - [`SelectError::InsufficientFunds`] if the candidates can't cover the target value. + /// - [`SelectError::InsufficientFunds`] if this in-order greedy selection exhausts the candidates + /// without covering the target value. Another subset may still work; use branch and bound to + /// search for one. /// - [`SelectError::MaxWeightExceeded`] if the value is met but the resulting selection exceeds /// [`Target::max_weight`]. Note this only reflects *this* in-order greedy selection; a /// different selection might still fit the cap (use branch and bound to search for one). /// - /// With unconfirmed ancestors the same caveat applies to - /// [`SelectError::InsufficientFunds`]: selecting everything can fail to meet the target while - /// some subset (one that drags in fewer ancestors) would meet it. + /// This is especially relevant with unconfirmed ancestors: selecting everything can fail while + /// a subset that drags in less ancestor fee debt would meet the target. pub fn select_until_target_met(&mut self) -> Result<(), SelectError> { self.select_until(|cs| cs.is_funded()).ok_or_else(|| { SelectError::InsufficientFunds(InsufficientFunds { @@ -878,9 +875,10 @@ impl<'a> CoinSelector<'a> { /// [`BnbMetric`]. /// /// Not every iteration will return a solution. If a solution is found, we return the selection - /// and score. Each subsequent solution of the iterator guarantees a higher score than the last. + /// and score. Each subsequent solution guarantees a lower (better) score than the last. /// - /// Most of the time, you would want to use [`CoinSelector::run_bnb`] instead. + /// Most callers should use [`CoinSelector::run_bnb`] instead, especially when they need the + /// change output selected by the metric. pub fn bnb_solutions( &self, metric: M, diff --git a/src/metrics/lowest_fee.rs b/src/metrics/lowest_fee.rs index 6af8b50..e923738 100644 --- a/src/metrics/lowest_fee.rs +++ b/src/metrics/lowest_fee.rs @@ -13,22 +13,24 @@ use crate::{float::Ordf32, BnbMetric, Drain, DrainWeights, FeeRate, SelectionVie /// /// Unlike other metrics, `LowestFee` decides for itself whether a selection should have a change /// output: change is added whenever doing so lowers the long-term fee (i.e. the recovered excess -/// outweighs the future cost of spending the change) and the resulting change value is above the -/// dust threshold implied by `dust_relay_feerate`. +/// outweighs the future cost of spending the change), the resulting value is at least the dust +/// threshold implied by `dust_relay_feerate`, and the transaction with change fits +/// [`Target::max_weight`](crate::Target::max_weight). /// /// # Unconfirmed ancestors /// /// When the [`SelectionProblem`] has unconfirmed ancestors, the fee a selection must pay includes /// the [`CoinSelector::ancestor_bump`](crate::CoinSelector::ancestor_bump) of the ancestors it drags -/// in, so the search naturally prefers -/// coins that drag in nothing (or that share an already-paid-for ancestor). The score itself is -/// still the child transaction's fee — the bump is inside it, not added on top. +/// in, so the search naturally prefers coins that drag in nothing or share an already-paid-for +/// ancestor. Ancestor fees are netted over the union, allowing an overpaying ancestor to offset an +/// underpaying one without subsidizing the child itself. The score remains the child transaction's +/// fee: the bump is inside it, not added on top. /// /// The bound uses a child-weight relaxation when ancestors are present (see -/// [`bound`](BnbMetric::bound)): a funded node uses `score − reachable surplus`, while an unfunded -/// one estimates the least child weight needed to meet each fee constraint. The `None` prunes stay -/// off — funding is not monotone, so "select everything and it's still unfunded" does not mean the -/// subtree is empty. +/// [`bound`](BnbMetric::bound)): a funded node credits reachable ancestor surplus and possible future +/// change, clamped to the monotone fee floor, while an unfunded one estimates the least child weight +/// needed to meet each fee constraint. The `None` prunes stay off: funding is not monotone, so +/// "select everything and it is still unfunded" does not mean the subtree is empty. /// /// [`SelectionProblem`]: crate::SelectionProblem #[derive(Clone, Copy)] diff --git a/src/metrics/lowest_fee_changeless.rs b/src/metrics/lowest_fee_changeless.rs index 334a949..d45f8bb 100644 --- a/src/metrics/lowest_fee_changeless.rs +++ b/src/metrics/lowest_fee_changeless.rs @@ -7,13 +7,16 @@ use super::LowestFee; /// /// This reuses [`LowestFee`]'s change decision, including the future cost of spending change, its /// dust threshold, and the transaction weight cap. A selection is only valid here when that -/// decision returns no change output. +/// decision returns no change output. That includes change that is uneconomical or dust, as well as +/// change that cannot fit the weight cap. /// /// Unlike constraining an arbitrary metric after the fact, this metric has a changeless-specific /// lower bound. A changeless selection's score is its selected value minus the target value. Since /// selected value can only increase down a branch, the current no-change fee is a lower bound for -/// every descendant, including when unconfirmed ancestry makes funding non-monotone. The bound -/// combines that fact with [`LowestFee`]'s funding relaxation. +/// every descendant, including when unconfirmed ancestry makes funding non-monotone. For pools of at +/// most 24 candidates, the bound combines that fact with [`LowestFee`]'s funding relaxation. Larger +/// pools retain only the `LowestFee` bound and ordering because the selected-value bound can starve +/// useful branches under a finite round limit. #[derive(Clone, Copy, Debug)] pub struct LowestFeeChangeless { /// The estimated feerate needed to spend a potential change output later. diff --git a/src/selection_problem.rs b/src/selection_problem.rs index d15a6fd..74cbcd5 100644 --- a/src/selection_problem.rs +++ b/src/selection_problem.rs @@ -51,9 +51,12 @@ impl From> for InputGroup { /// [`CoinSelector::new`]. /// /// Ancestor bump figures are stored here (not on [`Candidate`]) so candidates stay a plain -/// description of inputs. Unknown parent ids are treated as confirmed and ignored. There is no -/// mempool "mine" step — deficits are computed against the full ancestor set and may overestimate -/// what Bitcoin Core would charge. +/// description of inputs. Every unconfirmed transaction that created an input, and all of its +/// transitive unconfirmed ancestors, must be supplied for accurate CPFP pricing. Any absent id, +/// including an [`Input::residing_txid`] or parent id, is treated as confirmed and ignored, which +/// can underestimate the required fee. Deficits are computed against the full supplied ancestor +/// union; unlike Bitcoin Core, this does not remove transactions that could already be mined at an +/// intermediate feerate, so it may also conservatively overestimate a bump. /// /// What a selection actually owes is /// [`CoinSelector::ancestor_bump`](crate::CoinSelector::ancestor_bump): the shortfall of the @@ -122,6 +125,10 @@ impl SelectionProblem { /// Build candidates from input groups and the unconfirmed ancestors they may drag in. /// + /// Each input group must be non-empty, and every `AncestorToBump::txid` must be unique. Supply + /// every unconfirmed residing transaction and transitive unconfirmed ancestor needed for + /// accurate pricing; absent ids are assumed confirmed. + /// /// For each input group, the residing txids and their transitive parents (restricted to /// `ancestors_to_bump`) form that candidate's `drags_in` set. Ancestors only one candidate can /// reach are folded into [`private_ancestors`](Self::private_ancestors); the rest stay in @@ -247,15 +254,17 @@ impl SelectionProblem { self.candidates.is_empty() } - /// Ancestor units as `(weight, fee)` pairs. + /// Ancestors as `(weight, fee)` pairs, in the order supplied to [`SelectionProblem::new`]. + /// + /// Supplied ancestors that no candidate reaches remain in this slice but are not charged. pub fn ancestors(&self) -> &[(u64, u64)] { &self.ancestors } /// Whether any candidate drags in an unconfirmed ancestor. /// - /// `false` means every fee calculation reduces to the plain (child-only) case, which lets - /// branch and bound use the tighter bounds that assume monotone funding. + /// `false` means every fee calculation reduces to the plain (child-only) case, allowing branch + /// and bound to use its tighter no-ancestor bounds. pub fn has_ancestors(&self) -> bool { self.has_private_ancestors || self.has_shared_ancestors } diff --git a/src/selection_view.rs b/src/selection_view.rs index d18b2d6..7564276 100644 --- a/src/selection_view.rs +++ b/src/selection_view.rs @@ -1,4 +1,4 @@ -//! Cached, read-only selection queries. +//! Cached selection queries and hypothetical updates. use alloc::{borrow::Cow, vec::Vec}; use core::ops::Deref; @@ -219,9 +219,12 @@ impl SelectionCache { } } -/// A read-only view over a [`CoinSelector`] with cached aggregate queries. +/// A cached view over a [`CoinSelector`] that supports hypothetical updates. /// -/// Branch and bound maintains the cache incrementally. For ad-hoc use, +/// [`add`](Self::add) and [`sub`](Self::sub) update this view's copy-on-write aggregates without +/// changing the underlying selector. Aggregate methods on `SelectionView` reflect those updates, +/// while [`selector`](Self::selector) and methods reached through [`Deref`] still reflect the base +/// selector's selected set. Branch and bound maintains the cache incrementally. For ad-hoc use, /// [`CoinSelector::compute_view`] builds it from the current selection. #[derive(Clone, Debug)] pub struct SelectionView<'a> { @@ -252,7 +255,7 @@ impl<'a> SelectionView<'a> { } } - /// The selector represented by this view. + /// The underlying selector, which is not changed by hypothetical view updates. pub fn selector(&self) -> &'a CoinSelector<'a> { self.selector } @@ -270,7 +273,8 @@ impl<'a> SelectionView<'a> { /// Apply a hypothetical selection to this view's cached aggregates. /// - /// Does nothing if the candidate was already selected in the view. + /// Does nothing if the candidate was already selected in the view. Aggregate query methods on + /// this view reflect the update; selection-set methods reached through [`Deref`] do not. pub fn add(&mut self, index: usize) { self.track_selected(); let candidate = self.selector.candidate(index); @@ -284,7 +288,8 @@ impl<'a> SelectionView<'a> { /// Apply a hypothetical deselection to this view's cached aggregates. /// - /// Does nothing if the candidate was not selected in the view. + /// Does nothing if the candidate was not selected in the view. Aggregate query methods on this + /// view reflect the update; selection-set methods reached through [`Deref`] do not. pub fn sub(&mut self, index: usize) { self.track_selected(); let candidate = self.selector.candidate(index); From 2398ab2bd3a3539c11b7d4d11281bf013854ee29 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=BF=97=E5=AE=87?= Date: Fri, 14 Aug 2026 09:24:22 +0000 Subject: [PATCH 14/26] perf: search branch and bound with in-place DFS Replace the best-first BinaryHeap frontier with depth-first search that visits the better-bound child first and backtracks in place. This drops per-branch selector/cache clones and, under a round cap, finds complete solutions on large pools where the old frontier often exhausted the budget without a selection. --- CHANGELOG.md | 1 + benches/coin_selector.rs | 6 +- src/bnb.rs | 414 ++++++++++++++++++++++----------------- src/coin_selector.rs | 4 + src/selection_view.rs | 4 + tests/bnb.rs | 4 +- 6 files changed, 250 insertions(+), 183 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 143e142..d0594b1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ - **Breaking:** `LowestFee` no longer takes a `change_policy`. It now takes `dust_relay_feerate: FeeRate` and `drain_weights: DrainWeights`, and adds change only when doing so lowers the long-term fee, the value is at least the dust threshold, and the transaction with change fits `Target::max_weight`. - Add `DrainWeights::dust_threshold(dust_relay_feerate)`, the minimum value a change output with these weights must have to not be dust. - Add `CoinSelector::select_srd`, a Single Random Draw selector (port of Bitcoin Core's `SelectCoinsSRD`) that adds candidates in random order until the change reaches `change_lower`, producing a healthy-sized (privacy-friendly) change output instead of minimizing fees. Adds the `CHANGE_LOWER` constant for Core's value. +- Search branch and bound depth-first (best-child first, in-place backtracking) instead of best-first over a heap of cloned branches. Under a round cap this finds complete solutions on large pools where the old frontier often exhausted the budget without a selection. - Add `LowestFeeChangeless`, a dedicated lowest-fee changeless metric. It applies its changeless-specific bound to pools of at most 24 candidates and retains `LowestFee`'s search bound and ordering for larger pools to avoid starving useful branches under finite round limits. - **Breaking:** Remove `Changeless` and the `BnbMetric` tuple implementations (`impl BnbMetric for ((A, f32), ...)`). Generic metric composition is no longer supported. Use `LowestFeeChangeless` when a changeless transaction is required; otherwise use `LowestFee`. - **Breaking:** `CoinSelector::selected_indices` and `CoinSelector::banned` now return `&Bitset` instead of `&BTreeSet`. `Bitset` exposes `contains`/`len`/`is_empty`/`iter` (#46) diff --git a/benches/coin_selector.rs b/benches/coin_selector.rs index 22ccc07..96e133d 100644 --- a/benches/coin_selector.rs +++ b/benches/coin_selector.rs @@ -8,8 +8,8 @@ //! was introduced to make cheap. //! - `run_bnb_lowest_fee`: end-to-end Branch-and-Bound solution finding on a deterministic //! synthetic pool using the `LowestFee` metric. -//! - `run_bnb_lowest_fee_exhaust_cap`: frontier expansion at sizes that exhaust the fixed round -//! cap, isolating the cache and cursor hot path. +//! - `run_bnb_lowest_fee_exhaust_cap`: large-pool BnB under the same fixed round cap. DFS still +//! produces a solution at these sizes; the cap mainly limits how long we spend proving it. //! - `run_bnb_lowest_fee_ancestors`: the same, but where the coins sit on unconfirmed ancestors that //! need bumping — covering both the private and shared ancestor paths, which cost different //! amounts per fee calculation. @@ -170,7 +170,7 @@ fn bench_run_bnb_lowest_fee_exhaust_cap(c: &mut Criterion) { c, "run_bnb_lowest_fee_exhaust_cap", &[200, 500, 1_000], - false, + true, ); } diff --git a/src/bnb.rs b/src/bnb.rs index fae92d0..49be9a3 100644 --- a/src/bnb.rs +++ b/src/bnb.rs @@ -1,191 +1,143 @@ -use core::cmp::Reverse; - use crate::{float::Ordf32, Drain, SelectionCache, SelectionView}; use super::CoinSelector; -use alloc::collections::BinaryHeap; +use alloc::vec::Vec; /// An [`Iterator`] that iterates over rounds of branch and bound to minimize the score of the /// provided [`BnbMetric`]. #[derive(Debug)] pub(crate) struct BnbIter<'a, M: BnbMetric> { - queue: BinaryHeap>, + selector: CoinSelector<'a>, + cache: SelectionCache, + stack: Vec, best: Option, + exhausted: bool, /// The `BnBMetric` that will score each selection pub(crate) metric: M, } +#[derive(Debug)] +struct Frame { + is_inclusion: bool, + index: usize, + cursor: usize, + next_cursor: usize, + banned: Vec, + sibling_pending: bool, +} + impl<'a, M: BnbMetric> Iterator for BnbIter<'a, M> { type Item = Option<(CoinSelector<'a>, Ordf32)>; fn next(&mut self) -> Option { - // { - // println!("=========================== {:?}", self.best); - // for thing in self.queue.iter() { - // println!("{} {:?}", &thing.selector, thing.lower_bound); - // } - // let _ = std::io::stdin().read_line(&mut alloc::string::String::new()); - // } - - let branch = self.queue.pop()?; - if let Some(best) = &self.best { - // If the next thing in queue is not better than our best we're done. - if *best < branch.lower_bound { - // println!( - // "\t\t(SKIP) branch={} inclusion={} lb={:?}, score={:?}", - // branch.selector, - // !branch.is_exclusion, - // branch.lower_bound, - // self.metric.score(&branch.selector), - // ); - return None; - } + if self.exhausted { + return None; } - // println!( - // "\t\t( POP) branch={} inclusion={} lb={:?}, score={:?}", - // branch.selector, - // !branch.is_exclusion, - // branch.lower_bound, - // self.metric.score(&branch.selector), - // ); - - let Branch { - selector, - cache, - is_exclusion, - cursor, - .. - } = branch; - - let mut return_val = None; - if !is_exclusion { - if let Some(score) = self - .metric - .score(&SelectionView::with_cache(&selector, &cache)) - { - let better = match self.best { - Some(best_score) => score < best_score, - None => true, - }; - if better { - self.best = Some(score); - return_val = Some(score); - } - }; + + let return_val = if !self.is_exclusion_node() { + self.try_record_best() + .map(|score| (self.selector.clone(), score)) + } else { + None + }; + + if !self.descend() && !self.backtrack_to_next_branch() { + self.exhausted = true; } - self.insert_new_branches(&selector, &cache, cursor); - Some(return_val.map(|score| (selector, score))) + Some(return_val) } } impl<'a, M: BnbMetric> BnbIter<'a, M> { pub(crate) fn new(mut selector: CoinSelector<'a>, metric: M) -> Self { + if metric.requires_ordering_by_descending_value_pwu() { + selector.sort_candidates_by_descending_value_pwu(); + } + + let cache = SelectionCache::from_selector(&selector); let mut iter = BnbIter { - queue: BinaryHeap::default(), + selector, + cache, + stack: Vec::new(), best: None, + exhausted: false, metric, }; - if iter.metric.requires_ordering_by_descending_value_pwu() { - selector.sort_candidates_by_descending_value_pwu(); + if !iter.bound_is_promising() { + iter.exhausted = true; } - let cache = SelectionCache::from_selector(&selector); - iter.consider_adding_to_queue(&selector, &cache, false, 0); - iter } - fn consider_adding_to_queue( - &mut self, - cs: &CoinSelector<'a>, - cache: &SelectionCache, - is_exclusion: bool, - cursor: usize, - ) { - let bound = self.metric.bound(&SelectionView::with_cache(cs, cache)); - if let Some(bound) = bound { - let is_good_enough = match self.best { - Some(best) => best > bound, - None => true, - }; - if is_good_enough { - let branch = Branch { - lower_bound: bound, - selector: cs.clone(), - cache: cache.clone(), - is_exclusion, - cursor, - }; - /*println!( - "\t\t(PUSH) branch={} inclusion={} lb={:?} score={:?}", - branch.selector, - !branch.is_exclusion, - branch.lower_bound, - self.metric.score(&branch.selector), - );*/ - self.queue.push(branch); - } /* else { - println!( - "\t\t( REJ) branch={} inclusion={} lb={:?} score={:?}", - cs, - !is_exclusion, - bound, - self.metric.score(cs), - ); - }*/ - } /*else { - println!( - "\t\t(NO B) branch={} inclusion={} score={:?}", - cs, - !is_exclusion, - self.metric.score(cs), - ); - }*/ + fn is_exclusion_node(&self) -> bool { + self.stack.last().map_or(false, |frame| !frame.is_inclusion) } - fn insert_new_branches(&mut self, cs: &CoinSelector<'a>, cache: &SelectionCache, start: usize) { - let mut iter = cs.candidates().skip(start); - let mut cursor = start; - let (next_index, next) = loop { - match iter.next() { - None => return, - Some((index, candidate)) => { - if !cs.is_selected(index) && !cs.banned().contains(index) { - break (index, candidate); - } - cursor += 1; - } - } + fn try_record_best(&mut self) -> Option { + let score = self + .metric + .score(&SelectionView::with_cache(&self.selector, &self.cache))?; + let better = match self.best { + Some(best_score) => score < best_score, + None => true, }; + if better { + self.best = Some(score); + Some(score) + } else { + None + } + } + + fn bound_of_current(&mut self) -> Option { + self.metric + .bound(&SelectionView::with_cache(&self.selector, &self.cache)) + } + + fn is_promising(&self, bound: Option) -> bool { + match (bound, self.best) { + (Some(bound), Some(best)) => best > bound, + (Some(_), None) => true, + (None, _) => false, + } + } + + fn bound_is_promising(&mut self) -> bool { + let bound = self.bound_of_current(); + self.is_promising(bound) + } + + fn cursor(&self) -> usize { + self.stack.last().map_or(0, |frame| frame.next_cursor) + } + + fn next_candidate(&self, start: usize) -> Option<(usize, usize)> { + for (cursor, (index, _)) in (start..).zip(self.selector.candidates().skip(start)) { + if !self.selector.is_selected(index) && !self.selector.banned().contains(index) { + return Some((index, cursor)); + } + } + None + } - let mut inclusion_cs = cs.clone(); - let mut inclusion_cache = cache.clone(); - inclusion_cs.select(next_index); - inclusion_cache.add(cs.problem(), next_index, next, true); - self.consider_adding_to_queue(&inclusion_cs, &inclusion_cache, false, cursor + 1); - - // For the exclusion branch, we keep banning candidates that are interchangeable with the one - // we just excluded: same value and weight, and dragging in exactly the same unconfirmed - // ancestors (two coins of equal value and weight are *not* interchangeable if one of them - // drags in an ancestor that needs bumping). Candidates are only compared until the first - // mismatch, since this exploits them being adjacent in the sorted order. - let mut exclusion_cs = cs.clone(); - let mut exclusion_cache = cache.clone(); + fn exclusion_plan(&self, index: usize, cursor: usize) -> (Vec, usize) { + let next = self.selector.candidate(index); let to_ban = ( next.value, next.weight, next.segwit_count, next.legacy_count, ); - let to_ban_drags_in = cs.problem().drags_in(next_index); - exclusion_cs.ban(next_index); - exclusion_cache.ban(cs.problem(), next_index); - let mut exclusion_cursor = cursor + 1; - for (next_index, next) in iter { - if cs.is_selected(next_index) || cs.banned().contains(next_index) { - exclusion_cursor += 1; + let to_ban_drags_in = self.selector.problem().drags_in(index); + let mut banned = alloc::vec![index]; + let mut next_cursor = cursor + 1; + for (next_index, next) in self.selector.candidates().skip(cursor + 1) { + if self.selector.is_selected(next_index) || self.selector.banned().contains(next_index) + { + next_cursor += 1; continue; } if ( @@ -194,54 +146,160 @@ impl<'a, M: BnbMetric> BnbIter<'a, M> { next.segwit_count, next.legacy_count, ) != to_ban - || cs.problem().drags_in(next_index) != to_ban_drags_in + || self.selector.problem().drags_in(next_index) != to_ban_drags_in { break; } - exclusion_cs.ban(next_index); - exclusion_cache.ban(cs.problem(), next_index); - exclusion_cursor += 1; + banned.push(next_index); + next_cursor += 1; } - self.consider_adding_to_queue(&exclusion_cs, &exclusion_cache, true, exclusion_cursor); + (banned, next_cursor) } -} -#[derive(Debug, Clone)] -struct Branch<'a> { - lower_bound: Ordf32, - selector: CoinSelector<'a>, - cache: SelectionCache, - is_exclusion: bool, - cursor: usize, -} + fn apply_include(&mut self, index: usize) { + let candidate = self.selector.candidate(index); + self.selector.select(index); + self.cache + .add(self.selector.problem(), index, candidate, true); + } -impl Ord for Branch<'_> { - fn cmp(&self, other: &Self) -> core::cmp::Ordering { - // NOTE: Reverse comparison `lower_bound` because we want a min-heap (by default BinaryHeap - // is a max-heap). - // NOTE: We tiebreak equal scores based on whether it's exlusion or not (preferring - // inclusion). We do this because we want to try and get to evaluating complete selection - // returning actual scores as soon as possible. - core::cmp::Ord::cmp( - &(Reverse(&self.lower_bound), !self.is_exclusion), - &(Reverse(&other.lower_bound), !other.is_exclusion), - ) + fn undo_include(&mut self, index: usize) { + let candidate = self.selector.candidate(index); + self.selector.deselect(index); + self.cache + .sub(self.selector.problem(), index, candidate, true); } -} -impl PartialOrd for Branch<'_> { - fn partial_cmp(&self, other: &Self) -> Option { - Some(self.cmp(other)) + fn apply_exclude(&mut self, banned: &[usize]) { + for &index in banned { + self.selector.ban(index); + self.cache.ban(self.selector.problem(), index); + } } -} -impl PartialEq for Branch<'_> { - fn eq(&self, other: &Self) -> bool { - self.lower_bound == other.lower_bound + fn undo_exclude(&mut self, banned: &[usize]) { + for &index in banned.iter().rev() { + self.selector.unban(index); + self.cache.unban(self.selector.problem(), index); + } + } + + fn push_include(&mut self, index: usize, cursor: usize, sibling_pending: bool) { + self.apply_include(index); + self.stack.push(Frame { + is_inclusion: true, + index, + cursor, + next_cursor: cursor + 1, + banned: Vec::new(), + sibling_pending, + }); } -} -impl Eq for Branch<'_> {} + fn push_exclude( + &mut self, + index: usize, + cursor: usize, + banned: Vec, + next_cursor: usize, + sibling_pending: bool, + ) { + self.apply_exclude(&banned); + self.stack.push(Frame { + is_inclusion: false, + index, + cursor, + next_cursor, + banned, + sibling_pending, + }); + } + + fn descend(&mut self) -> bool { + let (index, cursor) = match self.next_candidate(self.cursor()) { + Some(next) => next, + None => return false, + }; + + self.apply_include(index); + let inc_bound = self.bound_of_current(); + let inc_ok = self.is_promising(inc_bound); + self.undo_include(index); + + let (banned, exc_next_cursor) = self.exclusion_plan(index, cursor); + self.apply_exclude(&banned); + let exc_bound = self.bound_of_current(); + let exc_ok = self.is_promising(exc_bound); + self.undo_exclude(&banned); + + match (inc_ok, exc_ok) { + (false, false) => false, + (true, false) => { + self.push_include(index, cursor, false); + true + } + (false, true) => { + self.push_exclude(index, cursor, banned, exc_next_cursor, false); + true + } + (true, true) => { + // Equal bounds prefer inclusion, matching the previous best-first tie-break. + let include_first = match (inc_bound, exc_bound) { + (Some(inc), Some(exc)) => inc <= exc, + _ => true, + }; + if include_first { + self.push_include(index, cursor, true); + } else { + self.push_exclude(index, cursor, banned, exc_next_cursor, true); + } + true + } + } + } + + fn backtrack_to_next_branch(&mut self) -> bool { + while let Some(frame) = self.stack.pop() { + if frame.is_inclusion { + self.undo_include(frame.index); + if frame.sibling_pending { + let (banned, next_cursor) = self.exclusion_plan(frame.index, frame.cursor); + self.apply_exclude(&banned); + if self.bound_is_promising() { + self.stack.push(Frame { + is_inclusion: false, + index: frame.index, + cursor: frame.cursor, + next_cursor, + banned, + sibling_pending: false, + }); + return true; + } + self.undo_exclude(&banned); + } + } else { + self.undo_exclude(&frame.banned); + if frame.sibling_pending { + self.apply_include(frame.index); + if self.bound_is_promising() { + self.stack.push(Frame { + is_inclusion: true, + index: frame.index, + cursor: frame.cursor, + next_cursor: frame.cursor + 1, + banned: Vec::new(), + sibling_pending: false, + }); + return true; + } + self.undo_include(frame.index); + } + } + } + false + } +} /// A branch and bound metric where we minimize the [`Ordf32`] score. /// diff --git a/src/coin_selector.rs b/src/coin_selector.rs index e9ed12b..73dac5f 100644 --- a/src/coin_selector.rs +++ b/src/coin_selector.rs @@ -115,6 +115,10 @@ impl<'a> CoinSelector<'a> { self.banned.insert(index); } + pub(crate) fn unban(&mut self, index: usize) { + self.banned.remove(index); + } + /// Gets the list of inputs that have been banned by [`ban`]. /// /// [`ban`]: Self::ban diff --git a/src/selection_view.rs b/src/selection_view.rs index 7564276..f4c4d8d 100644 --- a/src/selection_view.rs +++ b/src/selection_view.rs @@ -217,6 +217,10 @@ impl SelectionCache { pub(crate) fn ban(&mut self, problem: &SelectionProblem, index: usize) { self.remove_reachable(problem, index); } + + pub(crate) fn unban(&mut self, problem: &SelectionProblem, index: usize) { + self.add_reachable(problem, index); + } } /// A cached view over a [`CoinSelector`] that supports hypothetical updates. diff --git a/tests/bnb.rs b/tests/bnb.rs index 7562f66..ba1d321 100644 --- a/tests/bnb.rs +++ b/tests/bnb.rs @@ -102,7 +102,7 @@ fn bnb_finds_an_exact_solution_in_n_iter() { .last() .expect("it found a solution"); - assert_eq!(rounds, 3194); + assert_eq!(rounds, 62452); assert_eq!(best.input_weight(), solution_weight); assert_eq!(best.selected_value(), target_value, "score={:?}", score); } @@ -137,7 +137,7 @@ fn bnb_finds_solution_if_possible_in_n_iter() { .last() .expect("found a solution"); - assert_eq!(rounds, 164); + assert_eq!(rounds, 94); let excess = sol.excess(Drain::NONE); assert_eq!(excess, 0); } From 5c71ff8cf27356cd96e9ad55a24fed743195285b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=BF=97=E5=AE=87?= Date: Fri, 14 Aug 2026 10:04:03 +0000 Subject: [PATCH 15/26] perf: drop the pool-size cap on the changeless bound MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `LowestFeeChangeless` only applied its selected-value bound to pools of at most 24 candidates. The cap existed because best-first search treats a bound as a priority: a bound that grows with the selection pushed funded branches to the back of the heap, so on a big pool the frontier starved before it reached one. Depth-first search reads a bound as a cut instead of a ranking — it finishes a branch's descendants before its siblings — so the bound can be applied at every pool size, where it prunes inclusion branches that have already overshot the incumbent. --- CHANGELOG.md | 2 +- src/metrics/lowest_fee_changeless.rs | 11 ++++------- 2 files changed, 5 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d0594b1..48ea45c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,7 +11,7 @@ - Add `DrainWeights::dust_threshold(dust_relay_feerate)`, the minimum value a change output with these weights must have to not be dust. - Add `CoinSelector::select_srd`, a Single Random Draw selector (port of Bitcoin Core's `SelectCoinsSRD`) that adds candidates in random order until the change reaches `change_lower`, producing a healthy-sized (privacy-friendly) change output instead of minimizing fees. Adds the `CHANGE_LOWER` constant for Core's value. - Search branch and bound depth-first (best-child first, in-place backtracking) instead of best-first over a heap of cloned branches. Under a round cap this finds complete solutions on large pools where the old frontier often exhausted the budget without a selection. -- Add `LowestFeeChangeless`, a dedicated lowest-fee changeless metric. It applies its changeless-specific bound to pools of at most 24 candidates and retains `LowestFee`'s search bound and ordering for larger pools to avoid starving useful branches under finite round limits. +- Add `LowestFeeChangeless`, a dedicated lowest-fee changeless metric, bounding the changeless objective directly instead of constraining `LowestFee` after the fact. - **Breaking:** Remove `Changeless` and the `BnbMetric` tuple implementations (`impl BnbMetric for ((A, f32), ...)`). Generic metric composition is no longer supported. Use `LowestFeeChangeless` when a changeless transaction is required; otherwise use `LowestFee`. - **Breaking:** `CoinSelector::selected_indices` and `CoinSelector::banned` now return `&Bitset` instead of `&BTreeSet`. `Bitset` exposes `contains`/`len`/`is_empty`/`iter` (#46) - Replace the internal `Cow`/`Cow<[usize]>` selection state with a `Bitset` and an `Arc`-shared candidate order, making the per-branch clones in branch-and-bound substantially cheaper (#46) diff --git a/src/metrics/lowest_fee_changeless.rs b/src/metrics/lowest_fee_changeless.rs index d45f8bb..9c5c314 100644 --- a/src/metrics/lowest_fee_changeless.rs +++ b/src/metrics/lowest_fee_changeless.rs @@ -13,10 +13,10 @@ use super::LowestFee; /// Unlike constraining an arbitrary metric after the fact, this metric has a changeless-specific /// lower bound. A changeless selection's score is its selected value minus the target value. Since /// selected value can only increase down a branch, the current no-change fee is a lower bound for -/// every descendant, including when unconfirmed ancestry makes funding non-monotone. For pools of at -/// most 24 candidates, the bound combines that fact with [`LowestFee`]'s funding relaxation. Larger -/// pools retain only the `LowestFee` bound and ordering because the selected-value bound can starve -/// useful branches under a finite round limit. +/// every descendant, including when unconfirmed ancestry makes funding non-monotone. The bound +/// combines that fact with [`LowestFee`]'s funding relaxation, at every pool size: the depth-first +/// search visits a branch's own descendants before its siblings, so a bound that grows with the +/// selection cuts the branch instead of merely reordering the frontier away from it. #[derive(Clone, Copy, Debug)] pub struct LowestFeeChangeless { /// The estimated feerate needed to spend a potential change output later. @@ -68,9 +68,6 @@ impl BnbMetric for LowestFeeChangeless { fn bound(&mut self, cs: &SelectionView<'_>) -> Option { let mut lowest_fee = self.lowest_fee(); let funding_bound = lowest_fee.bound(cs)?; - if cs.problem().len() > 24 { - return Some(funding_bound); - } let no_change_fee = Ordf32(cs.selected_value().saturating_sub(cs.target().value()) as f32); Some(funding_bound.max(no_change_fee)) } From d43fcb28a633845c7503fa9a2aa86b224d7af043 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=BF=97=E5=AE=87?= Date: Fri, 14 Aug 2026 10:05:40 +0000 Subject: [PATCH 16/26] feat: seed branch and bound with a greedy incumbent MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Yield the greedy selection before expanding the first node, and adopt its score as the incumbent. The search is otherwise not anytime: a caller whose round budget runs out before the first complete selection gets `NoBnbSolution::RoundLimit` and falls through to whatever fallback it has, which on a large pool is far worse than the selection a single greedy pass would have handed it for free. Only the incumbent changes, not the bound, so the optimum stays reachable and the improving-solutions contract is unaffected. Metrics that reject the greedy prefix outright — `LowestFeeChangeless`, which will not score a selection that overshoots — are unchanged, and `RoundLimit` still means what it did for them. The two round-count assertions in `tests/bnb.rs` each move by one: the seed is a round. --- CHANGELOG.md | 1 + src/bnb.rs | 32 ++++++++++++++++++++++++++++++++ tests/bnb.rs | 4 ++-- tests/lowest_fee.rs | 19 +++++++++++++++++++ 4 files changed, 54 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 48ea45c..10fdeb2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ - Add `DrainWeights::dust_threshold(dust_relay_feerate)`, the minimum value a change output with these weights must have to not be dust. - Add `CoinSelector::select_srd`, a Single Random Draw selector (port of Bitcoin Core's `SelectCoinsSRD`) that adds candidates in random order until the change reaches `change_lower`, producing a healthy-sized (privacy-friendly) change output instead of minimizing fees. Adds the `CHANGE_LOWER` constant for Core's value. - Search branch and bound depth-first (best-child first, in-place backtracking) instead of best-first over a heap of cloned branches. Under a round cap this finds complete solutions on large pools where the old frontier often exhausted the budget without a selection. +- Seed branch and bound with the greedy selection, so a search that runs out of rounds returns the best selection it has instead of `NoBnbSolution::RoundLimit`. `RoundLimit` now means the metric rejected the greedy selection too, as `LowestFeeChangeless` does. - Add `LowestFeeChangeless`, a dedicated lowest-fee changeless metric, bounding the changeless objective directly instead of constraining `LowestFee` after the fact. - **Breaking:** Remove `Changeless` and the `BnbMetric` tuple implementations (`impl BnbMetric for ((A, f32), ...)`). Generic metric composition is no longer supported. Use `LowestFeeChangeless` when a changeless transaction is required; otherwise use `LowestFee`. - **Breaking:** `CoinSelector::selected_indices` and `CoinSelector::banned` now return `&Bitset` instead of `&BTreeSet`. `Bitset` exposes `contains`/`len`/`is_empty`/`iter` (#46) diff --git a/src/bnb.rs b/src/bnb.rs index 49be9a3..f69d5ff 100644 --- a/src/bnb.rs +++ b/src/bnb.rs @@ -11,6 +11,9 @@ pub(crate) struct BnbIter<'a, M: BnbMetric> { cache: SelectionCache, stack: Vec, best: Option, + /// The greedy selection, yielded before the first node is expanded. See + /// [`seed_greedy_incumbent`](BnbIter::seed_greedy_incumbent). + seed: Option<(CoinSelector<'a>, Ordf32)>, exhausted: bool, /// The `BnBMetric` that will score each selection pub(crate) metric: M, @@ -30,6 +33,10 @@ impl<'a, M: BnbMetric> Iterator for BnbIter<'a, M> { type Item = Option<(CoinSelector<'a>, Ordf32)>; fn next(&mut self) -> Option { + if let Some(seed) = self.seed.take() { + return Some(Some(seed)); + } + if self.exhausted { return None; } @@ -61,10 +68,13 @@ impl<'a, M: BnbMetric> BnbIter<'a, M> { cache, stack: Vec::new(), best: None, + seed: None, exhausted: false, metric, }; + iter.seed_greedy_incumbent(); + if !iter.bound_is_promising() { iter.exhausted = true; } @@ -72,6 +82,28 @@ impl<'a, M: BnbMetric> BnbIter<'a, M> { iter } + /// Score the greedy prefix and adopt it as the incumbent. + /// + /// Without this the search is not anytime: a caller that runs out of rounds before the first + /// complete selection gets nothing back and falls through to whatever fallback it has, which on + /// a large pool is far worse than the selection a single greedy pass would have handed it. The + /// seed costs one round and one scored selection, and since it is only an incumbent — the bound + /// is unchanged and still admissible — the optimum stays reachable. + /// + /// It yields nothing for a metric that rejects the greedy prefix outright, such as + /// [`LowestFeeChangeless`](crate::metrics::LowestFeeChangeless): overshooting the target is + /// exactly what a greedy pass does, and exactly what that metric will not score. + fn seed_greedy_incumbent(&mut self) { + let mut seed = self.selector.clone(); + if seed.select_until_target_met().is_err() { + return; + } + if let Some(score) = self.metric.score(&seed.compute_view()) { + self.best = Some(score); + self.seed = Some((seed, score)); + } + } + fn is_exclusion_node(&self) -> bool { self.stack.last().map_or(false, |frame| !frame.is_inclusion) } diff --git a/tests/bnb.rs b/tests/bnb.rs index ba1d321..a931480 100644 --- a/tests/bnb.rs +++ b/tests/bnb.rs @@ -102,7 +102,7 @@ fn bnb_finds_an_exact_solution_in_n_iter() { .last() .expect("it found a solution"); - assert_eq!(rounds, 62452); + assert_eq!(rounds, 62453); assert_eq!(best.input_weight(), solution_weight); assert_eq!(best.selected_value(), target_value, "score={:?}", score); } @@ -137,7 +137,7 @@ fn bnb_finds_solution_if_possible_in_n_iter() { .last() .expect("found a solution"); - assert_eq!(rounds, 94); + assert_eq!(rounds, 95); let excess = sol.excess(Drain::NONE); assert_eq!(excess, 0); } diff --git a/tests/lowest_fee.rs b/tests/lowest_fee.rs index d6606d2..624322e 100644 --- a/tests/lowest_fee.rs +++ b/tests/lowest_fee.rs @@ -414,6 +414,25 @@ fn run_bnb_reports_max_weight_exceeded() { ); } +/// The search is seeded with the greedy selection, so a budget too small to search anything still +/// comes back with a usable answer instead of `RoundLimit`. Without that, a caller on a large pool +/// falls through to whatever fallback it has for something branch and bound could have covered. +#[test] +fn run_bnb_returns_the_greedy_selection_on_a_tight_budget() { + let candidates = core::iter::repeat(err_candidate(100_000)) + .take(500) + .collect::>(); + let target = Target { + outputs: err_outputs(1_000_000), + fee: TargetFee::ZERO, + max_weight: None, + }; + let problem_12 = SelectionProblem::new_no_ancestors(target, candidates.iter().copied()); + let mut cs = CoinSelector::new(&problem_12); + cs.run_bnb(err_metric(), 1).expect("the seed is a solution"); + assert!(cs.is_funded()); +} + #[test] fn run_bnb_reports_round_limit() { // A solvable target, but zero rounds: we can't conclude infeasibility, only that we gave up. From 7d48f88c29d95e8f3ec2bcf14e4e7edd8fcfc2c6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=BF=97=E5=AE=87?= Date: Fri, 14 Aug 2026 10:06:34 +0000 Subject: [PATCH 17/26] fixup! perf: search branch and bound with in-place DFS --- src/bnb.rs | 32 ++++++++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/src/bnb.rs b/src/bnb.rs index f69d5ff..c20ba50 100644 --- a/src/bnb.rs +++ b/src/bnb.rs @@ -41,6 +41,21 @@ impl<'a, M: BnbMetric> Iterator for BnbIter<'a, M> { return None; } + // { + // println!("=========================== {:?}", self.best); + // println!("{} {:?}", &self.selector, self.bound_of_current()); + // for frame in self.stack.iter() { + // println!( + // "\t{} [{}] cursor={} sibling_pending={}", + // if frame.is_inclusion { "IN " } else { "EX " }, + // frame.index, + // frame.cursor, + // frame.sibling_pending, + // ); + // } + // let _ = std::io::stdin().read_line(&mut alloc::string::String::new()); + // } + let return_val = if !self.is_exclusion_node() { self.try_record_best() .map(|score| (self.selector.clone(), score)) @@ -182,6 +197,7 @@ impl<'a, M: BnbMetric> BnbIter<'a, M> { { break; } + // println!("banning: [{}] {:?}", next_index, next); banned.push(next_index); next_cursor += 1; } @@ -264,6 +280,16 @@ impl<'a, M: BnbMetric> BnbIter<'a, M> { let exc_ok = self.is_promising(exc_bound); self.undo_exclude(&banned); + // println!( + // "\t\t(DESC) branch={} next=[{}] inc_lb={:?}{} exc_lb={:?}{}", + // self.selector, + // index, + // inc_bound, + // if inc_ok { "" } else { " (REJ)" }, + // exc_bound, + // if exc_ok { "" } else { " (REJ)" }, + // ); + match (inc_ok, exc_ok) { (false, false) => false, (true, false) => { @@ -292,6 +318,12 @@ impl<'a, M: BnbMetric> BnbIter<'a, M> { fn backtrack_to_next_branch(&mut self) -> bool { while let Some(frame) = self.stack.pop() { + // println!( + // "\t\t(BACK) undo {} [{}] sibling_pending={}", + // if frame.is_inclusion { "IN " } else { "EX " }, + // frame.index, + // frame.sibling_pending, + // ); if frame.is_inclusion { self.undo_include(frame.index); if frame.sibling_pending { From 84816f80d201e092d609b8ba50dde3a06fe94131 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=BF=97=E5=AE=87?= Date: Fri, 14 Aug 2026 10:41:48 +0000 Subject: [PATCH 18/26] perf: let the ancestor bound prove the deficits it can MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bitcoin Core's `SelectCoinsBnB` computes `is_feerate_high` once and lets it decide whether a prune that is only sometimes valid may fire; it does not drop the prune because the general case is unsound. `bound_with_ancestors` took the other route — "never returns `None`" — on the grounds that a fat private deficit can un-fund a prefix a subset would have funded, so infeasibility is not something it may claim. That argument covers "select everything and it is still unfunded". It does not cover the case this relaxation can prove outright: a fee constraint whose deficit the best input still available cannot close at *any* weight. Descendants only add, the deficit is already computed against the branch-wide `ancestor_bump_lower_bound`, and the gain already ignores whatever ancestors those inputs would drag in — so the estimate is optimistic on every axis, and a deficit it still cannot close belongs to an empty subtree. The scan that finds the best value-per-weight candidate already runs, so the test is free. It also prunes the unfunded leaves that had nothing left to add, which the old path could only rank. --- CHANGELOG.md | 1 + src/metrics/lowest_fee.rs | 39 +++++++++++++++++++++++++++++---------- 2 files changed, 30 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 10fdeb2..cf40122 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ - Add `DrainWeights::dust_threshold(dust_relay_feerate)`, the minimum value a change output with these weights must have to not be dust. - Add `CoinSelector::select_srd`, a Single Random Draw selector (port of Bitcoin Core's `SelectCoinsSRD`) that adds candidates in random order until the change reaches `change_lower`, producing a healthy-sized (privacy-friendly) change output instead of minimizing fees. Adds the `CHANGE_LOWER` constant for Core's value. - Search branch and bound depth-first (best-child first, in-place backtracking) instead of best-first over a heap of cloned branches. Under a round cap this finds complete solutions on large pools where the old frontier often exhausted the budget without a selection. +- Let the ancestor-aware `LowestFee` bound prune a subtree when the best input still available cannot close a fee deficit at any weight. Previously this path was never allowed to claim infeasibility at all, because funding is not monotone with unconfirmed ancestors; that argument does not cover this case, which the relaxation can prove outright. - Seed branch and bound with the greedy selection, so a search that runs out of rounds returns the best selection it has instead of `NoBnbSolution::RoundLimit`. `RoundLimit` now means the metric rejected the greedy selection too, as `LowestFeeChangeless` does. - Add `LowestFeeChangeless`, a dedicated lowest-fee changeless metric, bounding the changeless objective directly instead of constraining `LowestFee` after the fact. - **Breaking:** Remove `Changeless` and the `BnbMetric` tuple implementations (`impl BnbMetric for ((A, f32), ...)`). Generic metric composition is no longer supported. Use `LowestFeeChangeless` when a changeless transaction is required; otherwise use `LowestFee`. diff --git a/src/metrics/lowest_fee.rs b/src/metrics/lowest_fee.rs index e923738..bac2ee7 100644 --- a/src/metrics/lowest_fee.rs +++ b/src/metrics/lowest_fee.rs @@ -29,8 +29,9 @@ use crate::{float::Ordf32, BnbMetric, Drain, DrainWeights, FeeRate, SelectionVie /// The bound uses a child-weight relaxation when ancestors are present (see /// [`bound`](BnbMetric::bound)): a funded node credits reachable ancestor surplus and possible future /// change, clamped to the monotone fee floor, while an unfunded one estimates the least child weight -/// needed to meet each fee constraint. The `None` prunes stay off: funding is not monotone, so -/// "select everything and it is still unfunded" does not mean the subtree is empty. +/// needed to meet each fee constraint. That relaxation may call a subtree empty only when the most +/// optimistic input still available cannot close a deficit — never from "select everything and it is +/// still unfunded", which does not follow while funding is not monotone. /// /// [`SelectionProblem`]: crate::SelectionProblem #[derive(Clone, Copy)] @@ -126,15 +127,17 @@ impl LowestFee { /// Tighter than [`CoinSelector::fee_floor`] once the value shortfall proves that every funded /// descendant must add some child input weight. /// - /// Never returns `None`: a fat private deficit can un-fund a prefix that a subset would have - /// funded, so infeasibility is not something this path is allowed to claim. (The caller has - /// already hard-pruned on child `max_weight`, which is monotone.) + /// Returns `None` only for the one infeasibility this relaxation can actually prove: a fee + /// constraint whose deficit the best input still available cannot close at any weight. It is + /// *not* allowed to reason from "select everything and it is still unfunded" — a fat private + /// deficit can un-fund a prefix that a subset would have funded. (The caller has already + /// hard-pruned on child `max_weight`, which is monotone.) /// /// The three fee constraints get independent fractional relaxations. Their maximum is still a /// lower bound on the real added child weight. Candidate ancestry is ignored and the global bump /// floor is used instead, avoiding package-surplus double counting. Flooring the fractional /// weight keeps floating-point error in the safe direction. - fn bound_with_ancestors(&self, cs: &SelectionView<'_>) -> Ordf32 { + fn bound_with_ancestors(&self, cs: &SelectionView<'_>) -> Option { if cs.is_funded() { let (_, drain) = self.fee_score(cs).unwrap(); let current_score = cs.fee(cs.target().value(), drain.value) as u64 @@ -159,7 +162,7 @@ impl LowestFee { bound = bound.min(with_change); } } - return Ordf32(bound.max(cs.fee_floor()) as f32); + return Some(Ordf32(bound.max(cs.fee_floor()) as f32)); } let target = cs.target(); @@ -196,6 +199,22 @@ impl LowestFee { let best_rate_gain = (best_value - target_rate).max(0.0); let best_replace_gain = (best_value - replace_rate).max(0.0); + // Bitcoin Core computes `is_feerate_high` once and lets it decide whether a prune that is + // only sometimes valid may fire, rather than dropping the prune outright. Same shape here. + // A deficit that no available input can close at any weight is not a claim about + // monotonicity: descendants only add, the deficit already uses the branch-wide bump floor, + // and the gain already ignores whatever ancestors those inputs would drag in. So this much + // infeasibility is provable even though the general case is not, and saying so prunes the + // subtree instead of ranking it. + let unreachable = + |deficit: f64, gain_pwu: f64| !weightless_value && deficit > 0.0 && gain_pwu <= 0.0; + if unreachable(rate_deficit, best_rate_gain) + || unreachable(absolute_deficit, best_value) + || unreachable(replace_deficit, best_replace_gain) + { + return None; + } + // Treat the best candidate as unlimited fractional input. If no positive gain is available, // or a positive-value zero-weight candidate exists, fall back to zero added weight rather // than claiming infeasibility. @@ -211,13 +230,13 @@ impl LowestFee { let added_weight = added_weight as u64; let weight = match current_weight.checked_add(added_weight) { Some(weight) if added_weight != u64::MAX => weight, - _ => return Ordf32(cs.fee_floor() as f32), + _ => return Some(Ordf32(cs.fee_floor() as f32)), }; let mut bound = (target.fee.rate.implied_fee_wu(weight) + bump).max(target.fee.absolute); if let Some(replace) = target.fee.replace { bound = bound.max(replace.min_fee_to_do_replacement_wu(weight)); } - Ordf32(bound as f32) + Some(Ordf32(bound as f32)) } } @@ -254,7 +273,7 @@ impl BnbMetric for LowestFee { // With unconfirmed ancestors, funding is not monotone. Use the child-weight relaxation in // `bound_with_ancestors`; never claim the subtree is empty. if cs.problem().has_ancestors() { - return Some(self.bound_with_ancestors(cs)); + return self.bound_with_ancestors(cs); } if cs.is_funded() { From dc4fe6a1005d6d277995ff475eeb00fdabbb151e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=BF=97=E5=AE=87?= Date: Fri, 14 Aug 2026 10:29:59 +0000 Subject: [PATCH 19/26] perf: hard-prune on a lookahead over the undecided candidates Port Bitcoin Core's `SelectCoinsBnB` lookahead. Core keeps a running `curr_available_value` over the coins it has not decided on yet and backtracks as soon as that total cannot close the gap to the target; the cut needs no incumbent, so it fires from the very first descent. We had the same idea only in `LowestFee::bound`'s no-ancestor path, as an O(n) rescan that ran after the relaxation had already been set up, and not at all when the problem has ancestors. `SelectionCache` now carries the value and weight of the undecided candidates worth selecting, maintained by the same add/sub/ban/unban hooks that already track reachable ancestor surplus, so the test is O(1). Two one-sided relaxations keep it from pruning a branch that holds a solution: only candidates with positive standalone effective value count toward the total, and the current ancestor bump is swapped for `ancestor_bump_lower_bound`, which holds for the whole subtree. That second one is what lets the prune run with ancestors present, where funding is not monotone and "select everything and it is still unfunded" would have been an unsound claim. --- CHANGELOG.md | 1 + src/metrics/lowest_fee.rs | 22 ++++++++++--- src/selection_view.rs | 68 +++++++++++++++++++++++++++++++++------ 3 files changed, 77 insertions(+), 14 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index cf40122..d079383 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ - Add `DrainWeights::dust_threshold(dust_relay_feerate)`, the minimum value a change output with these weights must have to not be dust. - Add `CoinSelector::select_srd`, a Single Random Draw selector (port of Bitcoin Core's `SelectCoinsSRD`) that adds candidates in random order until the change reaches `change_lower`, producing a healthy-sized (privacy-friendly) change output instead of minimizing fees. Adds the `CHANGE_LOWER` constant for Core's value. - Search branch and bound depth-first (best-child first, in-place backtracking) instead of best-first over a heap of cloned branches. Under a round cap this finds complete solutions on large pools where the old frontier often exhausted the budget without a selection. +- Hard-prune branch-and-bound nodes whose remaining candidates cannot meet the target feerate, using a running total the selection cache maintains (a port of Bitcoin Core's `SelectCoinsBnB` lookahead). The relaxation credits still-reachable ancestor surplus, so it holds with unconfirmed ancestors too. - Let the ancestor-aware `LowestFee` bound prune a subtree when the best input still available cannot close a fee deficit at any weight. Previously this path was never allowed to claim infeasibility at all, because funding is not monotone with unconfirmed ancestors; that argument does not cover this case, which the relaxation can prove outright. - Seed branch and bound with the greedy selection, so a search that runs out of rounds returns the best selection it has instead of `NoBnbSolution::RoundLimit`. `RoundLimit` now means the metric rejected the greedy selection too, as `LowestFeeChangeless` does. - Add `LowestFeeChangeless`, a dedicated lowest-fee changeless metric, bounding the changeless objective directly instead of constraining `LowestFee` after the fact. diff --git a/src/metrics/lowest_fee.rs b/src/metrics/lowest_fee.rs index bac2ee7..ce23fcd 100644 --- a/src/metrics/lowest_fee.rs +++ b/src/metrics/lowest_fee.rs @@ -29,9 +29,11 @@ use crate::{float::Ordf32, BnbMetric, Drain, DrainWeights, FeeRate, SelectionVie /// The bound uses a child-weight relaxation when ancestors are present (see /// [`bound`](BnbMetric::bound)): a funded node credits reachable ancestor surplus and possible future /// change, clamped to the monotone fee floor, while an unfunded one estimates the least child weight -/// needed to meet each fee constraint. That relaxation may call a subtree empty only when the most -/// optimistic input still available cannot close a deficit — never from "select everything and it is -/// still unfunded", which does not follow while funding is not monotone. +/// needed to meet each fee constraint. Neither reasons from "select everything and it is still +/// unfunded", which does not follow while funding is not monotone. Infeasibility comes from the two +/// prunes that can prove it: the lookahead in [`bound`](BnbMetric::bound), which relaxes the +/// ancestor bump to its branch-wide floor rather than assuming it only grows, and the relaxation's +/// own test for a deficit the most optimistic input still available cannot close at any weight. /// /// [`SelectionProblem`]: crate::SelectionProblem #[derive(Clone, Copy)] @@ -270,8 +272,18 @@ impl BnbMetric for LowestFee { return None; } - // With unconfirmed ancestors, funding is not monotone. Use the child-weight relaxation in - // `bound_with_ancestors`; never claim the subtree is empty. + // Lookahead hard-prune (Bitcoin Core's `curr_available_value` test): if everything still + // undecided cannot close the feerate gap, no descendant is funded, so the subtree is empty. + // Funding needs every fee constraint met, so failing this one alone is enough to prune. + // Constant-time, and it fires before either relaxation below does any work. + if cs.best_reachable_rate_excess_wu() < 0 { + return None; + } + + // With unconfirmed ancestors, funding is not monotone, so neither this path nor the one + // below may reason from "select everything and it is still unfunded". Emptiness is claimed + // only where it is provable: by the lookahead above, which relaxes the bump to its + // branch-wide floor, and by `bound_with_ancestors`' own unclosable-deficit test. if cs.problem().has_ancestors() { return self.bound_with_ancestors(cs); } diff --git a/src/selection_view.rs b/src/selection_view.rs index f4c4d8d..1d1f6e3 100644 --- a/src/selection_view.rs +++ b/src/selection_view.rs @@ -26,6 +26,12 @@ pub(crate) struct SelectionCache { shared_reachable_refcounts: Vec, shared_reachable_surplus: f64, ancestor_fee_precision_slack: u64, + /// Value and weight of the still-undecided candidates worth selecting, i.e. those neither + /// selected nor banned whose standalone effective value is positive. Candidates that cost more + /// weight than they bring are left out because they only ever lower the total, so the pair + /// stays an upper bound on what the rest of this branch can still contribute. + undecided_value: u64, + undecided_weight: u64, selected: Bitset, } @@ -59,23 +65,23 @@ impl SelectionCache { ], shared_reachable_surplus: 0.0, ancestor_fee_precision_slack: selector.problem().ancestor_fee_precision_slack(), + undecided_value: 0, + undecided_weight: 0, // BnB transitions each candidate exactly once, so it needs no duplicate-tracking // bitset. Public hypothetical updates allocate this lazily in `track_selected`. selected: Bitset::default(), }; - if selector.problem().has_ancestors() { - for (index, _) in selector.candidates() { - cache.add_reachable(selector.problem(), index); - } + // Every candidate starts undecided and reachable; the two loops below then account for the + // ones that are already selected or already banned. + for (index, _) in selector.candidates() { + cache.add_reachable(selector.problem(), index); } for (index, candidate) in selector.selected() { cache.add(selector.problem(), index, candidate, true); } - if selector.problem().has_ancestors() { - for index in selector.banned().iter() { - if !selector.is_selected(index) { - cache.ban(selector.problem(), index); - } + for index in selector.banned().iter() { + if !selector.is_selected(index) { + cache.ban(selector.problem(), index); } } cache @@ -85,7 +91,22 @@ impl SelectionCache { (fee as f64 - weight as f64 * problem.target().fee.rate.spwu() as f64).max(0.0) } + /// Whether this candidate brings in more value than its own weight costs at the target + /// feerate. One that doesn't can only ever lower a running total, so the undecided aggregate + /// leaves it out and stays an upper bound. + fn is_worth_selecting(problem: &SelectionProblem, index: usize) -> bool { + problem + .candidate(index) + .effective_value(problem.target().fee.rate) + > 0.0 + } + fn add_reachable(&mut self, problem: &SelectionProblem, index: usize) { + if Self::is_worth_selecting(problem, index) { + let candidate = problem.candidate(index); + self.undecided_value += candidate.value; + self.undecided_weight += candidate.weight; + } if !problem.has_ancestors() { return; } @@ -107,6 +128,11 @@ impl SelectionCache { } fn remove_reachable(&mut self, problem: &SelectionProblem, index: usize) { + if Self::is_worth_selecting(problem, index) { + let candidate = problem.candidate(index); + self.undecided_value -= candidate.value; + self.undecided_weight -= candidate.weight; + } if !problem.has_ancestors() { return; } @@ -371,6 +397,30 @@ impl<'a> SelectionView<'a> { } } + /// The most any descendant of this branch could still improve the feerate constraint. + /// + /// This is Bitcoin Core's `SelectCoinsBnB` lookahead (`curr_available_value`): the search keeps + /// a running total of what the undecided candidates can contribute, and a node whose total + /// still cannot close the gap has an empty subtree. Constant-time against the cache. + /// + /// Both terms are one-sided, so the result is an over-estimate and never prunes a branch that + /// holds a solution. The undecided pair counts only candidates worth selecting, and the current + /// ancestor bump is swapped for [`ancestor_bump_lower_bound`](Self::ancestor_bump_lower_bound), + /// which holds for this branch and every descendant — so a subsidizing ancestor that a + /// descendant might drag in is credited here rather than assumed away. The input-count varint + /// and witness overhead those candidates would add is ignored for the same reason: leaving it + /// out can only make this larger. + pub(crate) fn best_reachable_rate_excess_wu(&self) -> i64 { + self.rate_excess_wu(Drain::NONE) + self.ancestor_bump() as i64 + - self.ancestor_bump_lower_bound() as i64 + + self.cache.undecided_value as i64 + - self + .target() + .fee + .rate + .implied_fee_wu(self.cache.undecided_weight) as i64 + } + fn implied_fee_from_feerate(&self, drain_weights: DrainWeights) -> u64 { self.target() .fee From a71f693e338703f769275c91425c00566fa37a50 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=BF=97=E5=AE=87?= Date: Sat, 15 Aug 2026 22:21:43 +0000 Subject: [PATCH 20/26] feat!: remove the changeless metrics LowestFee already decides for itself whether a selection should carry a change output, adding one only when it lowers the long-term fee, clears the dust threshold and fits max_weight. A separate changeless objective duplicates that decision and constrains it, and nothing in the crate needs the constraint. Removes LowestFeeChangeless along with the Changeless wrapper the unreleased changelog already retired, plus their tests and proptest regressions. BREAKING CHANGE: LowestFeeChangeless and Changeless are gone. Callers that required a changeless transaction should use LowestFee and inspect the Drain it returns. --- CHANGELOG.md | 5 +- src/bnb.rs | 5 +- src/metrics.rs | 2 - src/metrics/lowest_fee_changeless.rs | 78 ----------- tests/ancestor.rs | 98 +------------- tests/changeless.proptest-regressions | 9 -- tests/lowest_fee.rs | 46 +------ tests/lowest_fee_changeless.rs | 178 -------------------------- 8 files changed, 8 insertions(+), 413 deletions(-) delete mode 100644 src/metrics/lowest_fee_changeless.rs delete mode 100644 tests/changeless.proptest-regressions delete mode 100644 tests/lowest_fee_changeless.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index d079383..08f54e1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,9 +13,8 @@ - Search branch and bound depth-first (best-child first, in-place backtracking) instead of best-first over a heap of cloned branches. Under a round cap this finds complete solutions on large pools where the old frontier often exhausted the budget without a selection. - Hard-prune branch-and-bound nodes whose remaining candidates cannot meet the target feerate, using a running total the selection cache maintains (a port of Bitcoin Core's `SelectCoinsBnB` lookahead). The relaxation credits still-reachable ancestor surplus, so it holds with unconfirmed ancestors too. - Let the ancestor-aware `LowestFee` bound prune a subtree when the best input still available cannot close a fee deficit at any weight. Previously this path was never allowed to claim infeasibility at all, because funding is not monotone with unconfirmed ancestors; that argument does not cover this case, which the relaxation can prove outright. -- Seed branch and bound with the greedy selection, so a search that runs out of rounds returns the best selection it has instead of `NoBnbSolution::RoundLimit`. `RoundLimit` now means the metric rejected the greedy selection too, as `LowestFeeChangeless` does. -- Add `LowestFeeChangeless`, a dedicated lowest-fee changeless metric, bounding the changeless objective directly instead of constraining `LowestFee` after the fact. -- **Breaking:** Remove `Changeless` and the `BnbMetric` tuple implementations (`impl BnbMetric for ((A, f32), ...)`). Generic metric composition is no longer supported. Use `LowestFeeChangeless` when a changeless transaction is required; otherwise use `LowestFee`. +- Seed branch and bound with the greedy selection, so a search that runs out of rounds returns the best selection it has instead of `NoBnbSolution::RoundLimit`. `RoundLimit` now means the metric rejected the greedy selection too. +- **Breaking:** Remove the changeless metrics: `Changeless`, `LowestFeeChangeless`, and the `BnbMetric` tuple implementations (`impl BnbMetric for ((A, f32), ...)`). Generic metric composition is no longer supported. `LowestFee` decides for itself whether a selection should carry change, so a separate changeless objective is no longer maintained. - **Breaking:** `CoinSelector::selected_indices` and `CoinSelector::banned` now return `&Bitset` instead of `&BTreeSet`. `Bitset` exposes `contains`/`len`/`is_empty`/`iter` (#46) - Replace the internal `Cow`/`Cow<[usize]>` selection state with a `Bitset` and an `Arc`-shared candidate order, making the per-branch clones in branch-and-bound substantially cheaper (#46) - Fix compilation error when building with `--no-default-features` (#36) diff --git a/src/bnb.rs b/src/bnb.rs index c20ba50..0c0f9f9 100644 --- a/src/bnb.rs +++ b/src/bnb.rs @@ -105,9 +105,8 @@ impl<'a, M: BnbMetric> BnbIter<'a, M> { /// seed costs one round and one scored selection, and since it is only an incumbent — the bound /// is unchanged and still admissible — the optimum stays reachable. /// - /// It yields nothing for a metric that rejects the greedy prefix outright, such as - /// [`LowestFeeChangeless`](crate::metrics::LowestFeeChangeless): overshooting the target is - /// exactly what a greedy pass does, and exactly what that metric will not score. + /// It yields nothing for a metric that rejects the greedy prefix outright: overshooting the + /// target is exactly what a greedy pass does. fn seed_greedy_incumbent(&mut self) { let mut seed = self.selector.clone(); if seed.select_until_target_met().is_err() { diff --git a/src/metrics.rs b/src/metrics.rs index 36192aa..2d841d0 100644 --- a/src/metrics.rs +++ b/src/metrics.rs @@ -5,5 +5,3 @@ //! [`CoinSelector::run_bnb`]: crate::CoinSelector::run_bnb mod lowest_fee; pub use lowest_fee::*; -mod lowest_fee_changeless; -pub use lowest_fee_changeless::*; diff --git a/src/metrics/lowest_fee_changeless.rs b/src/metrics/lowest_fee_changeless.rs deleted file mode 100644 index 9c5c314..0000000 --- a/src/metrics/lowest_fee_changeless.rs +++ /dev/null @@ -1,78 +0,0 @@ -use crate::{float::Ordf32, BnbMetric, Drain, DrainWeights, FeeRate, SelectionView}; - -use super::LowestFee; - -/// Metric that minimizes fees while only accepting selections for which [`LowestFee`] chooses no -/// change output. -/// -/// This reuses [`LowestFee`]'s change decision, including the future cost of spending change, its -/// dust threshold, and the transaction weight cap. A selection is only valid here when that -/// decision returns no change output. That includes change that is uneconomical or dust, as well as -/// change that cannot fit the weight cap. -/// -/// Unlike constraining an arbitrary metric after the fact, this metric has a changeless-specific -/// lower bound. A changeless selection's score is its selected value minus the target value. Since -/// selected value can only increase down a branch, the current no-change fee is a lower bound for -/// every descendant, including when unconfirmed ancestry makes funding non-monotone. The bound -/// combines that fact with [`LowestFee`]'s funding relaxation, at every pool size: the depth-first -/// search visits a branch's own descendants before its siblings, so a bound that grows with the -/// selection cuts the branch instead of merely reordering the frontier away from it. -#[derive(Clone, Copy, Debug)] -pub struct LowestFeeChangeless { - /// The estimated feerate needed to spend a potential change output later. - pub long_term_feerate: FeeRate, - /// The feerate used to determine the dust threshold of a potential change output. - pub dust_relay_feerate: FeeRate, - /// The weights of the potential change output. - pub drain_weights: DrainWeights, -} - -impl LowestFeeChangeless { - fn lowest_fee(self) -> LowestFee { - LowestFee { - long_term_feerate: self.long_term_feerate, - dust_relay_feerate: self.dust_relay_feerate, - drain_weights: self.drain_weights, - } - } -} - -impl From for LowestFeeChangeless { - fn from(metric: LowestFee) -> Self { - Self { - long_term_feerate: metric.long_term_feerate, - dust_relay_feerate: metric.dust_relay_feerate, - drain_weights: metric.drain_weights, - } - } -} - -impl BnbMetric for LowestFeeChangeless { - fn drain(&mut self, _cs: &SelectionView<'_>) -> Drain { - Drain::NONE - } - - fn score(&mut self, cs: &SelectionView<'_>) -> Option { - if !cs.is_funded() - || !cs.is_within_max_weight(DrainWeights::NONE) - || self.lowest_fee().drain_value(cs).is_some() - { - return None; - } - - Some(Ordf32( - cs.selected_value().saturating_sub(cs.target().value()) as f32, - )) - } - - fn bound(&mut self, cs: &SelectionView<'_>) -> Option { - let mut lowest_fee = self.lowest_fee(); - let funding_bound = lowest_fee.bound(cs)?; - let no_change_fee = Ordf32(cs.selected_value().saturating_sub(cs.target().value()) as f32); - Some(funding_bound.max(no_change_fee)) - } - - fn requires_ordering_by_descending_value_pwu(&self) -> bool { - true - } -} diff --git a/tests/ancestor.rs b/tests/ancestor.rs index 72ecb6b..e4f74c9 100644 --- a/tests/ancestor.rs +++ b/tests/ancestor.rs @@ -9,10 +9,9 @@ mod common; use bdk_coin_select::{ - float::Ordf32, - metrics::{LowestFee, LowestFeeChangeless}, - AncestorToBump, BnbMetric, Candidate, CoinSelector, Drain, DrainWeights, FeeRate, Input, - Replace, SelectionProblem, Target, TargetFee, TargetOutputs, TX_FIXED_FIELD_WEIGHT, + float::Ordf32, metrics::LowestFee, AncestorToBump, BnbMetric, Candidate, CoinSelector, Drain, + DrainWeights, FeeRate, Input, Replace, SelectionProblem, Target, TargetFee, TargetOutputs, + TX_FIXED_FIELD_WEIGHT, }; use proptest::prelude::*; @@ -327,70 +326,6 @@ fn score_is_the_childs_fee_which_already_covers_the_bump() { ); } -/// A changeless solution can be reachable *only* by adding a coin whose ancestor eats the excess, -/// which a changeless bound must account for. -/// -/// Here the coin that kills the change looks profitable on its own (1000 sats for 200 wu). It only -/// shrinks the excess because it drags in an ancestor owing 10_800 sats, so a bound cannot assume -/// excess is monotone or infer that change is unavoidable from standalone effective values. -#[test] -fn changeless_solution_reachable_only_via_an_ancestor_is_not_pruned() { - let t = target(1.0, 100_000); - let problem = SelectionProblem::new( - t, - [ - Input { - value: 110_000, - weight: 200, - is_segwit: true, - residing_txid: CONFIRMED, - }, - Input { - value: 1_000, - weight: 200, - is_segwit: true, - residing_txid: "P", - }, - ], - // 43_200 wu at 0.25 sat/wu => 10_800 sats owed. - [ancestor("P", 43_200, 0, vec![])], - ); - - let mut m = metric(); - - // The coin that drags in the ancestor is *not* one the prune would pick up: on its own it is - // worth more than it costs to spend. - assert!(problem.candidate(1).effective_value(t.fee.rate) > 0.0); - - let mut clean_only = problem.selector(); - clean_only.select(0); - assert!(clean_only.is_funded()); - assert!( - m.drain(&clean_only.compute_view()).is_some(), - "the clean coin on its own overshoots enough to warrant change" - ); - - let mut both = problem.selector(); - both.select(0); - both.select(1); - assert_eq!(both.ancestor_bump(), 10_800); - assert!(both.is_funded(), "still funded after paying the bump"); - assert!( - m.drain(&both.compute_view()).is_none(), - "the bump leaves too little excess to be worth a change output" - ); - - // So the only changeless solution is both coins together, reachable only *through* the node - // that has change. - let mut cs = problem.selector(); - let (score, drain) = cs - .run_bnb(LowestFeeChangeless::from(metric()), 100_000) - .expect("the changeless solution must not be pruned"); - assert!(drain.is_none()); - assert!(cs.is_selected(0) && cs.is_selected(1)); - assert_eq!(score, Ordf32(11_000.0)); -} - // --- the bump lower bound used by `LowestFee`'s bound --- /// With nothing overpaying within reach, no descendant can owe less than this selection does, so the @@ -1076,31 +1011,4 @@ proptest! { } } - /// Same for the dedicated changeless metric, including capped problems. - #[test] - fn changeless_bnb_finds_the_brute_force_optimum( - spec in spec_strategy(), - ) { - let problem = spec.build(); - - let mut exhaustive_cs = problem.selector(); - let mut exhaustive_metric = LowestFeeChangeless::from(metric()); - let expected = common::exhaustive_search(&mut exhaustive_cs, &mut exhaustive_metric); - - let mut bnb_cs = problem.selector(); - let found = common::bnb_search(&mut bnb_cs, LowestFeeChangeless::from(metric()), usize::MAX); - - match (expected, found) { - (Some((expected_score, _)), Ok((score, _))) => { - prop_assert_eq!(score, expected_score, "bnb={} exhaustive={}", bnb_cs, exhaustive_cs); - } - (None, Err(_)) => {} - (expected, found) => prop_assert!( - false, - "disagreement: exhaustive={:?} bnb={:?}", - expected.map(|(score, _)| score), - found.map(|(score, _)| score), - ), - } - } } diff --git a/tests/changeless.proptest-regressions b/tests/changeless.proptest-regressions deleted file mode 100644 index 97089e2..0000000 --- a/tests/changeless.proptest-regressions +++ /dev/null @@ -1,9 +0,0 @@ -# Seeds for failure cases proptest has generated in the past. It is -# automatically read and these particular cases re-run before any -# novel cases are generated. -# -# It is recommended to check this file in to source control so that -# everyone who runs the test benefits from these saved cases. -cc b03fc0267d15cf4455c7f00feed18d1ba82a783a38bf689dacdd572356013877 # shrinks to num_inputs = 7, target = 1277, feerate = 1.0, min_fee = 177, base_weight = 0, long_term_feerate_diff = 0.0, change_weight = 1, change_spend_weight = 1 -cc 2ba2cfa2412c0f3c9de4eb35caeefa1a086797f5c3f5fc0528396cc90a85d993 # shrinks to num_inputs = 5, target = 908, feerate = 7.823237, replace = None, base_weight = 222, long_term_feerate_diff = 2.4063222, change_weight = 14, change_spend_weight = 14 -cc fc2f2211d811690b78ca4206be874e5c3e99727626f79306bbdd25d59df9c27b # shrinks to num_inputs = 10, target = 3821, feerate = 4.104783, replace = None, base_weight = 321, long_term_feerate_diff = 2.7914581, change_weight = 1, change_spend_weight = 1 diff --git a/tests/lowest_fee.rs b/tests/lowest_fee.rs index 624322e..b6cb209 100644 --- a/tests/lowest_fee.rs +++ b/tests/lowest_fee.rs @@ -1,6 +1,6 @@ #![allow(unused_imports)] mod common; -use bdk_coin_select::metrics::{LowestFee, LowestFeeChangeless}; +use bdk_coin_select::metrics::LowestFee; use bdk_coin_select::{ BnbMetric, Candidate, ChangePolicy, CoinSelector, Drain, DrainWeights, FeeRate, NoBnbSolution, Replace, SelectionProblem, Target, TargetFee, TargetOutputs, TX_FIXED_FIELD_WEIGHT, @@ -174,50 +174,6 @@ proptest! { } } -/// The dedicated changeless metric shares `LowestFee`'s eligibility rules while bounding its -/// narrower objective directly. -#[test] -fn combined_changeless_metric() { - let params = common::StrategyParams { - n_candidates: 100, - target_value: 100_000, - target_weight: 1000 - TX_FIXED_FIELD_WEIGHT as u32 - 1, - replace: None, - feerate: 5.0, - feerate_lt_diff: -4.0, - drain_weight: 200, - drain_spend_weight: 600, - drain_dust: 200, - n_target_outputs: 1, - n_drain_outputs: 1, - max_weight: None, - }; - - let candidates = common::gen_candidates(params.n_candidates); - let target = params.target(); - let problem_4 = SelectionProblem::new_no_ancestors(target, candidates.iter().copied()); - let mut cs_a = CoinSelector::new(&problem_4); - let problem_5 = SelectionProblem::new_no_ancestors(target, candidates.iter().copied()); - let mut cs_b = CoinSelector::new(&problem_5); - let metric_lowest_fee = params.lowest_fee_metric(); - - let metric_changeless = LowestFeeChangeless::from(params.lowest_fee_metric()); - - // cs_a uses the unconstrained metric - let (score, rounds) = - common::bnb_search(&mut cs_a, metric_lowest_fee, usize::MAX).expect("must find solution"); - println!("score={:?} rounds={}", score, rounds); - - // cs_b uses the changeless-constrained metric - let (combined_score, combined_rounds) = - common::bnb_search(&mut cs_b, metric_changeless, usize::MAX).expect("must find solution"); - println!("score={:?} rounds={}", combined_score, combined_rounds); - - assert!(combined_score >= score); - let mut change_decision = params.lowest_fee_metric(); - assert!(change_decision.drain(&cs_b.compute_view()).is_none()); -} - /// Because this metric decides change optimally, it never creates a change output whose value /// wouldn't cover the future cost of spending it. Here a single input overshoots the target by only /// ~130 sats — far less than the drain's spend cost — so the fee-optimal choice is to burn the diff --git a/tests/lowest_fee_changeless.rs b/tests/lowest_fee_changeless.rs deleted file mode 100644 index 8bec18d..0000000 --- a/tests/lowest_fee_changeless.rs +++ /dev/null @@ -1,178 +0,0 @@ -mod common; -use bdk_coin_select::{ - float::Ordf32, metrics::LowestFeeChangeless, BnbMetric, Candidate, DrainWeights, FeeRate, - SelectionProblem, Target, TargetFee, TargetOutputs, -}; -#[cfg(not(debug_assertions))] -use proptest::{prelude::*, proptest, test_runner::*}; -#[cfg(not(debug_assertions))] -use rand::{Rng, RngCore}; - -#[test] -fn funded_changeful_branch_is_bounded_by_its_no_change_fee() { - let target = Target { - outputs: TargetOutputs { - n_outputs: 1, - value_sum: 100_000, - weight_sum: 100, - }, - fee: TargetFee::from_feerate(FeeRate::from_sat_per_vb(1.0)), - max_weight: None, - }; - let problem = SelectionProblem::new_no_ancestors( - target, - [Candidate { - value: 110_000, - weight: 200, - segwit_count: 1, - legacy_count: 0, - }], - ); - let mut selector = problem.selector(); - selector.select(0); - let mut metric = LowestFeeChangeless { - long_term_feerate: FeeRate::ZERO, - dust_relay_feerate: FeeRate::ZERO, - drain_weights: DrainWeights { - output_weight: 100, - spend_weight: 100, - n_outputs: 1, - }, - }; - let view = selector.compute_view(); - - assert!(metric.score(&view).is_none(), "this selection wants change"); - assert_eq!(metric.bound(&view), Some(Ordf32(10_000.0))); -} - -#[test] -fn mixed_serialization_overhead_does_not_prune_exact_solution() { - let target = Target { - outputs: TargetOutputs { - n_outputs: 0, - value_sum: 1_000, - weight_sum: 0, - }, - fee: TargetFee::from_feerate(FeeRate::from_sat_per_vb(4.0)), - max_weight: None, - }; - let candidates = [ - Candidate { - value: 1_201, - weight: 158, - segwit_count: 1, - legacy_count: 0, - }, - Candidate { - value: 167, - weight: 164, - segwit_count: 0, - legacy_count: 3, - }, - ]; - let problem = SelectionProblem::new_no_ancestors(target, candidates); - let mut selector = problem.selector(); - let metric = LowestFeeChangeless { - long_term_feerate: FeeRate::ZERO, - dust_relay_feerate: FeeRate::ZERO, - drain_weights: DrainWeights::NONE, - }; - - let mut expected = problem.selector(); - expected.select_all(); - assert_eq!(expected.excess(bdk_coin_select::Drain::NONE), 0); - assert!(metric.clone().score(&expected.compute_view()).is_some()); - - selector.run_bnb(metric, 100).expect("exact solution"); - assert_eq!( - selector.selected_indices().iter().collect::>(), - [0, 1] - ); - assert_eq!(selector.excess(bdk_coin_select::Drain::NONE), 0); -} - -#[cfg(not(debug_assertions))] -fn test_wv(mut rng: impl RngCore) -> impl Iterator { - core::iter::repeat_with(move || { - let value = rng.random_range(0..1_000); - Candidate { - value, - weight: rng.random_range(0..100), - segwit_count: rng.random_range(1..2), - legacy_count: 0, - } - }) -} - -#[cfg(not(debug_assertions))] -proptest! { - #![proptest_config(ProptestConfig::default())] - - #[test] - fn compare_against_benchmarks( - n_candidates in 0..15_usize, // candidates (n) - target_value in 500..1_000_000_u64, // target value (sats) - n_target_outputs in 1..150_usize, // the number of outputs we're funding - target_weight in 0..10_000_u32, // the sum of the weight of the outputs (wu) - replace in common::maybe_replace(0..10_000u64), // The weight of the transaction we're replacing - feerate in 1.0..100.0_f32, // feerate (sats/vb) - drain_weight in 100..=500_u32, // drain weight (wu) - drain_spend_weight in 1..=2000_u32, // drain spend weight (wu) - n_drain_outputs in 1..150usize, // the number of drain outputs - ) { - println!("======================================="); - let mut rng = TestRng::deterministic_rng(RngAlgorithm::ChaCha); - let feerate = FeeRate::from_sat_per_vb(feerate); - let drain_weights = DrainWeights { - output_weight: drain_weight as u64, - spend_weight: drain_spend_weight as u64, - n_outputs: n_drain_outputs, - }; - - let wv = test_wv(&mut rng); - let candidates = wv.take(n_candidates).collect::>(); - - - let target = Target { - outputs: TargetOutputs { - n_outputs: n_target_outputs, - value_sum: target_value, - weight_sum: target_weight as u64, - }, - fee: TargetFee { - rate: feerate, - replace, - ..TargetFee::ZERO - }, - max_weight: None, - }; - let problem = SelectionProblem::new_no_ancestors(target, candidates.iter().copied()); - let make_metric = || { - LowestFeeChangeless { - long_term_feerate: feerate, - dust_relay_feerate: FeeRate::from_sat_per_vb(1.0), - drain_weights, - } - }; - - let mut exhaustive_cs = problem.selector(); - let mut exhaustive_metric = make_metric(); - let expected = common::exhaustive_search(&mut exhaustive_cs, &mut exhaustive_metric); - - let mut bnb_cs = problem.selector(); - let found = common::bnb_search(&mut bnb_cs, make_metric(), usize::MAX); - - match (expected, found) { - (Some((expected_score, _)), Ok((score, _))) => { - prop_assert_eq!(score, expected_score, "bnb={} exhaustive={}", bnb_cs, exhaustive_cs); - } - (None, Err(_)) => {} - (expected, found) => prop_assert!( - false, - "disagreement: exhaustive={:?} bnb={:?}", - expected.map(|(score, _)| score), - found.map(|(score, _)| score), - ), - } - } -} From ac5119dc0c0ff0be98c9dbe32eb137c9504804cd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=BF=97=E5=AE=87?= Date: Sat, 15 Aug 2026 23:58:24 +0000 Subject: [PATCH 21/26] perf: make the ancestor bound's candidate scan independent of pool size `bound_with_ancestors` scanned every undecided candidate at each unfunded node to find the greatest value-per-weight and to notice weightless value. Branch and bound asks for that bound at every unfunded node, so an O(n) scan there made per-node cost grow with the pool: measured on shared_ancestry_*, 2389 ns/round at n=500 rising to 9384 at n=2000, against 385-2056 for the no-ancestry fixtures. The metric already requires candidates in descending value-per-weight order, and that order is keyed on f32. The exact f64 maximum can therefore only lie inside the run sharing the first undecided candidate's f32 key, which is why the old code scanned in f64 rather than taking the first: two exact ratios can tie in f32 and be ordered either way. Scanning just that run keeps the exact answer without touching the tail. Weightless value becomes a counter kept where the undecided aggregates already are. 5.9x to 8.8x faster per round at n=500 to 2000, and byte-identical results: across all 42 benchmark fixtures the score, selection, round count and exhausted flag are unchanged. A debug assertion checks the tie-run result against a full scan, so the ordering assumption is verified on every node the test suite searches. --- src/metrics/lowest_fee.rs | 13 ++------- src/selection_view.rs | 59 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 61 insertions(+), 11 deletions(-) diff --git a/src/metrics/lowest_fee.rs b/src/metrics/lowest_fee.rs index ce23fcd..d7a3d73 100644 --- a/src/metrics/lowest_fee.rs +++ b/src/metrics/lowest_fee.rs @@ -187,17 +187,8 @@ impl LowestFee { ) }); - // Scan in f64 rather than trusting the f32 candidate ordering: two exact ratios can tie in - // f32, and choosing the lower one would overstate the required weight. - let mut best_value = 0.0_f64; - let mut weightless_value = false; - for (_, candidate) in cs.unselected() { - if candidate.weight == 0 { - weightless_value |= candidate.value > 0; - } else { - best_value = best_value.max(candidate.value as f64 / candidate.weight as f64); - } - } + let best_value = cs.best_undecided_value_pwu(); + let weightless_value = cs.has_weightless_undecided_value(); let best_rate_gain = (best_value - target_rate).max(0.0); let best_replace_gain = (best_value - replace_rate).max(0.0); diff --git a/src/selection_view.rs b/src/selection_view.rs index 1d1f6e3..3148b70 100644 --- a/src/selection_view.rs +++ b/src/selection_view.rs @@ -32,6 +32,9 @@ pub(crate) struct SelectionCache { /// stays an upper bound on what the rest of this branch can still contribute. undecided_value: u64, undecided_weight: u64, + /// Undecided candidates that weigh nothing but carry value. They make every fee deficit + /// closable at zero added weight, so the bound must know whether any remain. + undecided_weightless_value: usize, selected: Bitset, } @@ -67,6 +70,7 @@ impl SelectionCache { ancestor_fee_precision_slack: selector.problem().ancestor_fee_precision_slack(), undecided_value: 0, undecided_weight: 0, + undecided_weightless_value: 0, // BnB transitions each candidate exactly once, so it needs no duplicate-tracking // bitset. Public hypothetical updates allocate this lazily in `track_selected`. selected: Bitset::default(), @@ -106,6 +110,9 @@ impl SelectionCache { let candidate = problem.candidate(index); self.undecided_value += candidate.value; self.undecided_weight += candidate.weight; + if candidate.weight == 0 && candidate.value > 0 { + self.undecided_weightless_value += 1; + } } if !problem.has_ancestors() { return; @@ -132,6 +139,9 @@ impl SelectionCache { let candidate = problem.candidate(index); self.undecided_value -= candidate.value; self.undecided_weight -= candidate.weight; + if candidate.weight == 0 && candidate.value > 0 { + self.undecided_weightless_value -= 1; + } } if !problem.has_ancestors() { return; @@ -376,6 +386,55 @@ impl<'a> SelectionView<'a> { .saturating_sub(fee) } + /// Whether any undecided candidate weighs nothing but carries value. + /// + /// Such a candidate closes any fee deficit at zero added weight, so no relaxation may claim a + /// deficit is unreachable while one remains. + pub(crate) fn has_weightless_undecided_value(&self) -> bool { + self.cache.undecided_weightless_value > 0 + } + + /// The greatest value-per-weight among undecided candidates, in `f64`. + /// + /// Candidates are ordered by *`f32`* value-per-weight, so the `f64` maximum can only lie inside + /// the run sharing the first undecided candidate's `f32` key: two exact ratios can tie in `f32` + /// and be ordered either way, and picking the lower one would overstate the weight a deficit + /// needs. Scanning that run rather than the whole tail keeps the exact answer while making the + /// query independent of the pool size, which matters because branch and bound asks it at every + /// unfunded node. + /// + /// Zero-weight candidates sort first (their ratio is infinite) and are skipped here; use + /// [`has_weightless_undecided_value`](Self::has_weightless_undecided_value) for those. + /// + /// Assumes the descending value-per-weight order that + /// [`BnbMetric::requires_ordering_by_descending_value_pwu`](crate::BnbMetric::requires_ordering_by_descending_value_pwu) + /// asks for; a debug assertion checks the result against a full scan. + pub(crate) fn best_undecided_value_pwu(&self) -> f64 { + let mut best = 0.0_f64; + let mut key: Option = None; + for (_, candidate) in self.unselected() { + if candidate.weight == 0 { + continue; + } + let candidate_key = crate::float::Ordf32(candidate.value_pwu()); + match key { + None => key = Some(candidate_key), + Some(first) if candidate_key != first => break, + _ => {} + } + best = best.max(candidate.value as f64 / candidate.weight as f64); + } + debug_assert_eq!( + best, + self.unselected() + .filter(|(_, c)| c.weight > 0) + .map(|(_, c)| c.value as f64 / c.weight as f64) + .fold(0.0_f64, f64::max), + "candidates are not in descending value-per-weight order, so the tie-run scan is wrong" + ); + best + } + /// Lower bound on the ancestor bump owed by this branch or any descendant. /// /// Branch and bound maintains both selected obligations and still-reachable surplus in the From deae1821f0f1b9ba33918ba38c9e8b71498e9a18 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=BF=97=E5=AE=87?= Date: Sat, 15 Aug 2026 23:58:24 +0000 Subject: [PATCH 22/26] feat!: drop CoinSelector methods that SelectionView already caches `SelectionView` overrides these with cache-backed versions, so every call site in the crate and its tests already resolved to the view; the `CoinSelector` copies recomputed the same answers by iterating and had no callers left. Removes `effective_value`, `implied_feerate`, `rate_excess_wu`, `replacement_excess_wu` and `waste`, plus the two private helpers they were the last users of. `missing` and `drain` are deliberately kept even though the view also has them: the crate's own front-page example calls them on a bare `CoinSelector`, which is the case they exist for. The same argument keeps the rest of the overlap -- `weight`, `excess`, `is_funded` and friends all have live callers holding a selector rather than a view, and routing those through `compute_view` would cost an O(n) cache build to replace an O(n) method. BREAKING CHANGE: obtain a `SelectionView` with `CoinSelector::compute_view` and call the removed methods there. --- CHANGELOG.md | 1 + src/coin_selector.rs | 87 -------------------------------------------- 2 files changed, 1 insertion(+), 87 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 08f54e1..98aa30a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,6 @@ # Unreleased +- **Breaking:** Remove `CoinSelector::{effective_value, implied_feerate, rate_excess_wu, replacement_excess_wu, waste}`, which recomputed what `SelectionView` already caches and had no callers left. Obtain a view with `CoinSelector::compute_view` and call them there. - **Breaking:** Replace `Candidate`'s `input_count` and `is_segwit` fields with `segwit_count` and `legacy_count`, fixing `CoinSelector::input_weight` undercounting candidates that group multiple inputs: in a segwit transaction every legacy input still serializes an empty witness (1 WU), which was previously paid once per candidate instead of once per legacy input, so a group of N legacy inputs came out N-1 WU short. Splitting the count by script type also means a single candidate may now mix legacy and segwit inputs and still be priced exactly. Replaces `Candidate::new` with `Candidate::new_segwit` and `Candidate::new_legacy`. - **Breaking:** Add `SelectionProblem`, which owns the target, candidates, and optional unconfirmed-ancestor data for a selection run. `CoinSelector::new` now takes `&SelectionProblem`, and the selector borrows it for its lifetime. - Charge selections for the fee needed to bring the union of their unconfirmed ancestors up to the target feerate. Build ancestor-aware problems from `Input`/`InputGroup` and `AncestorToBump` with `SelectionProblem::new`; use `SelectionProblem::new_no_ancestors` for prebuilt candidates that need no CPFP bump. diff --git a/src/coin_selector.rs b/src/coin_selector.rs index 73dac5f..dbf5ce9 100644 --- a/src/coin_selector.rs +++ b/src/coin_selector.rs @@ -401,15 +401,6 @@ impl<'a> CoinSelector<'a> { - self.implied_fee_from_feerate(drain.weights) as i64 } - /// Same as [rate_excess](Self::rate_excess) except `self.target().fee.rate` is applied to the - /// implied transaction's weight units directly without any conversion to vbytes. - pub fn rate_excess_wu(&self, drain: Drain) -> i64 { - self.selected_value() as i64 - - self.target().value() as i64 - - drain.value as i64 - - self.implied_fee_from_feerate_wu(drain.weights) as i64 - } - /// How much the current selection overshoots the value needed to satisfy `self.target().fee.absolute` /// and `self.target().value` (while ignoring `self.target().fee.rate`). pub fn absolute_excess(&self, drain: Drain) -> i64 { @@ -432,37 +423,6 @@ impl<'a> CoinSelector<'a> { - replacement_excess_needed as i64 } - /// Same as [replacement_excess](Self::replacement_excess) except the replacement fee - /// is calculated using weight units directly without any conversion to vbytes. - pub fn replacement_excess_wu(&self, drain: Drain) -> i64 { - let mut replacement_excess_needed = 0; - if let Some(replace) = self.target().fee.replace { - replacement_excess_needed = replace - .min_fee_to_do_replacement_wu(self.weight(self.target().outputs, drain.weights)) - } - self.selected_value() as i64 - - self.target().value() as i64 - - drain.value as i64 - - replacement_excess_needed as i64 - } - - /// The feerate the transaction would have if we were to use this selection of inputs to achieve - /// the `target`'s value and weight. It is essentially telling you what target feerate you currently have. - /// - /// This is the *child* transaction's feerate: the fee and weight of any unconfirmed ancestors - /// this selection drags in are not included, so it is not the package feerate. - /// - /// Returns `None` if the feerate would be negative or infinity. - pub fn implied_feerate(&self, target_outputs: TargetOutputs, drain: Drain) -> Option { - let numerator = - self.selected_value() as i64 - target_outputs.value_sum as i64 - drain.value as i64; - let denom = self.weight(target_outputs, drain.weights); - if numerator < 0 || denom == 0 { - return None; - } - Some(FeeRate::from_sat_per_wu(numerator as f32 / denom as f32)) - } - /// The fee the current selection and `drain_weight` should pay to satisfy `target_fee`. /// /// This compares the fee calculated from the target feerate with the fee calculated from the @@ -496,14 +456,6 @@ impl<'a> CoinSelector<'a> { + self.ancestor_bump() } - fn implied_fee_from_feerate_wu(&self, drain_weights: DrainWeights) -> u64 { - self.target() - .fee - .rate - .implied_fee_wu(self.weight(self.target().outputs, drain_weights)) - + self.ancestor_bump() - } - /// The actual fee the selection would pay if it was used in a transaction that had /// `target_value` value for outputs and change output of `drain_value`. /// @@ -512,18 +464,7 @@ impl<'a> CoinSelector<'a> { self.selected_value() as i64 - target_value as i64 - drain_value as i64 } - /// The value of the current selected inputs minus the fee needed to pay for the selected inputs - /// - /// Only the selected inputs' own weight is charged; any [`ancestor_bump`](Self::ancestor_bump) - /// they drag in is not. - pub fn effective_value(&self, feerate: FeeRate) -> i64 { - self.selected_value() as i64 - (self.input_weight() as f32 * feerate.spwu()).ceil() as i64 - } - // /// Waste sum of all selected inputs. - fn input_waste(&self, feerate: FeeRate, long_term_feerate: FeeRate) -> f32 { - self.input_weight() as f32 * (feerate.spwu() - long_term_feerate.spwu()) - } /// Sorts the candidates by the comparison function. /// @@ -578,34 +519,6 @@ impl<'a> CoinSelector<'a> { } } - /// The waste created by the current selection as measured by the [waste metric]. - /// - /// You can pass in an `excess_discount` which must be between `0.0..1.0`. Passing in `1.0` gives you no discount - /// - /// [waste metric]: https://bitcoin.stackexchange.com/questions/113622/what-does-waste-metric-mean-in-the-context-of-coin-selection - pub fn waste(&self, long_term_feerate: FeeRate, drain: Drain, excess_discount: f32) -> f32 { - debug_assert!((0.0..=1.0).contains(&excess_discount)); - let mut waste = self.input_waste(self.target().fee.rate, long_term_feerate); - - if drain.is_none() { - // We don't allow negative excess waste since negative excess just means you haven't - // satisified target yet in which case you probably shouldn't be calling this function. - let mut excess_waste = self.excess(drain).max(0) as f32; - // we allow caller to discount this waste depending on how wasteful excess actually is - // to them. - excess_waste *= excess_discount.clamp(0.0, 1.0); - waste += excess_waste; - } else { - waste += drain.weights.waste( - self.target().fee.rate, - long_term_feerate, - self.target().outputs.n_outputs, - ); - } - - waste - } - /// The selected candidates with their index. pub fn selected( &self, From db34d811e26cd55c534089a5104c81c811abb925 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=BF=97=E5=AE=87?= Date: Sun, 16 Aug 2026 00:12:43 +0000 Subject: [PATCH 23/26] feat!: move the remaining aggregate queries onto SelectionView `SelectionView` answered every one of these from its cache while the `CoinSelector` copy recomputed the same figure by iterating the selection. Keeping both meant two implementations of the weight model, the excess model and the ancestor bump, and the slower one was the default a caller reached for. Removes `absolute_excess`, `ancestor_bump`, `ancestor_bump_lower_bound`, `drain`, `drain_value`, `excess`, `fee`, `implied_fee`, `input_weight`, `is_funded`, `is_funded_with_drain`, `is_within_max_weight`, `missing`, `rate_excess`, `replacement_excess`, `selected_value` and `weight` from `CoinSelector`, along with the two private helpers they were the last users of. `select_until` now hands its predicate a `&SelectionView` and maintains that view's cache incrementally, so the greedy pass behind `select_until_target_met` -- which seeds every branch-and-bound search -- costs one cache build plus O(1) per step instead of rescanning the selection on every iteration. The crate's own front-page example now goes through `compute_view` too, which is what the removed methods were kept for. BREAKING CHANGE: obtain a `SelectionView` with `CoinSelector::compute_view` and call the removed methods there. `CoinSelector::select_until` takes a predicate over `&SelectionView` rather than `&CoinSelector`. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01HLiTkESMktypGJhFag2ZBM --- CHANGELOG.md | 4 +- README.md | 10 +- src/coin_selector.rs | 413 +++----------------------------------- src/drain.rs | 3 +- src/metrics/lowest_fee.rs | 8 +- src/selection_problem.rs | 6 +- src/selection_view.rs | 67 ++++--- tests/ancestor.rs | 134 +++++++------ tests/bnb.rs | 23 ++- tests/common.rs | 32 ++- tests/lowest_fee.rs | 2 +- tests/srd.rs | 8 +- tests/weight.rs | 12 +- 13 files changed, 203 insertions(+), 519 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 98aa30a..ef85dc9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,7 +1,7 @@ # Unreleased -- **Breaking:** Remove `CoinSelector::{effective_value, implied_feerate, rate_excess_wu, replacement_excess_wu, waste}`, which recomputed what `SelectionView` already caches and had no callers left. Obtain a view with `CoinSelector::compute_view` and call them there. -- **Breaking:** Replace `Candidate`'s `input_count` and `is_segwit` fields with `segwit_count` and `legacy_count`, fixing `CoinSelector::input_weight` undercounting candidates that group multiple inputs: in a segwit transaction every legacy input still serializes an empty witness (1 WU), which was previously paid once per candidate instead of once per legacy input, so a group of N legacy inputs came out N-1 WU short. Splitting the count by script type also means a single candidate may now mix legacy and segwit inputs and still be priced exactly. Replaces `Candidate::new` with `Candidate::new_segwit` and `Candidate::new_legacy`. +- **Breaking:** Move the aggregate queries off `CoinSelector` and onto `SelectionView`, which already answered them from its cache while `CoinSelector` recomputed them by iterating. Obtain a view with `CoinSelector::compute_view` and call them there. Removed: `absolute_excess`, `ancestor_bump`, `ancestor_bump_lower_bound`, `drain`, `drain_value`, `effective_value`, `excess`, `fee`, `implied_fee`, `implied_feerate`, `input_weight`, `is_funded`, `is_funded_with_drain`, `is_within_max_weight`, `missing`, `rate_excess`, `rate_excess_wu`, `replacement_excess`, `replacement_excess_wu`, `selected_value`, `waste` and `weight`. `CoinSelector::select_until` now hands its predicate a `&SelectionView` instead of a `&CoinSelector`, and maintains that view's aggregates incrementally, so a predicate such as `|view| view.is_funded()` costs the same at every step rather than rescanning the growing selection. +- **Breaking:** Replace `Candidate`'s `input_count` and `is_segwit` fields with `segwit_count` and `legacy_count`, fixing `SelectionView::input_weight` undercounting candidates that group multiple inputs: in a segwit transaction every legacy input still serializes an empty witness (1 WU), which was previously paid once per candidate instead of once per legacy input, so a group of N legacy inputs came out N-1 WU short. Splitting the count by script type also means a single candidate may now mix legacy and segwit inputs and still be priced exactly. Replaces `Candidate::new` with `Candidate::new_segwit` and `Candidate::new_legacy`. - **Breaking:** Add `SelectionProblem`, which owns the target, candidates, and optional unconfirmed-ancestor data for a selection run. `CoinSelector::new` now takes `&SelectionProblem`, and the selector borrows it for its lifetime. - Charge selections for the fee needed to bring the union of their unconfirmed ancestors up to the target feerate. Build ancestor-aware problems from `Input`/`InputGroup` and `AncestorToBump` with `SelectionProblem::new`; use `SelectionProblem::new_no_ancestors` for prebuilt candidates that need no CPFP bump. - Add `SelectionView`, a cached view obtained with `CoinSelector::compute_view`. `BnbMetric::{score, bound, drain}` now consume `&SelectionView`; branch and bound maintains its aggregates incrementally while the underlying selector remains unchanged. diff --git a/README.md b/README.md index e107a18..caa2f54 100644 --- a/README.md +++ b/README.md @@ -57,10 +57,12 @@ let problem = SelectionProblem::new_no_ancestors(target, candidates); let mut coin_selector = CoinSelector::new(&problem); coin_selector.select(0); -assert!(!coin_selector.is_funded(), "we didn't select enough"); -println!("we didn't select enough yet we're missing: {}", coin_selector.missing()); +// Aggregate queries live on a cached view of the current selection. +let view = coin_selector.compute_view(); +assert!(!view.is_funded(), "we didn't select enough"); +println!("we didn't select enough yet we're missing: {}", view.missing()); coin_selector.select(1); -assert!(coin_selector.is_funded(), "we should have enough now"); +assert!(coin_selector.compute_view().is_funded(), "we should have enough now"); // Now we need to know if we need a change output to drain the excess if we overshot too much // @@ -69,7 +71,7 @@ assert!(coin_selector.is_funded(), "we should have enough now"); let drain_weights = DrainWeights::TR_KEYSPEND; // Our policy is to only add a change output if the value is over 1_000 sats let change_policy = ChangePolicy::min_value(drain_weights, 1_000); -let change = coin_selector.drain(change_policy); +let change = coin_selector.compute_view().drain(change_policy); if change.is_some() { println!("We need to add our change output to the transaction with {} value", change.value); } else { diff --git a/src/coin_selector.rs b/src/coin_selector.rs index dbf5ce9..b3e4c0e 100644 --- a/src/coin_selector.rs +++ b/src/coin_selector.rs @@ -1,9 +1,7 @@ use super::*; #[allow(unused)] // some bug in <= 1.48.0 sees this as unused when it isn't use crate::float::FloatExt; -use crate::{ - bitset::Bitset, bnb::BnbMetric, float::Ordf32, ChangePolicy, FeeRate, SelectionProblem, Target, -}; +use crate::{bitset::Bitset, bnb::BnbMetric, float::Ordf32, FeeRate, SelectionProblem, Target}; use alloc::{sync::Arc, vec::Vec}; /// The minimum change amount Bitcoin Core's `SelectCoinsSRD` targets; a sensible default for the @@ -136,42 +134,6 @@ impl<'a> CoinSelector<'a> { self.selected.is_empty() } - /// The weight of the inputs including the witness header and the varint for the number of - /// inputs. - pub fn input_weight(&self) -> u64 { - let is_segwit_tx = self.selected().any(|(_, wv)| wv.segwit_count > 0); - let witness_header_extra_weight = is_segwit_tx as u64 * 2; - - let input_count = self - .selected() - .map(|(_, wv)| wv.segwit_count + wv.legacy_count) - .sum::(); - let input_varint_weight = varint_size(input_count) * 4; - - let selected_weight: u64 = self - .selected() - .map(|(_, candidate)| { - let mut weight = candidate.weight; - if is_segwit_tx { - // Legacy inputs do not have the witness length included in their weight field - // so we need to add 1 to each if it's a segwit tx. - weight += candidate.legacy_count as u64; - } - weight - }) - .sum(); - - input_varint_weight + selected_weight + witness_header_extra_weight - } - - /// Absolute value sum of all selected inputs. - pub fn selected_value(&self) -> u64 { - self.selected - .iter() - .map(|index| self.problem.candidates()[index].value) - .sum() - } - /// The unconfirmed ancestors the current selection drags in (indices into /// [`SelectionProblem::ancestors`]). /// @@ -190,53 +152,6 @@ impl<'a> CoinSelector<'a> { union } - /// The fee (sats) this selection must pay *on top of* its own feerate obligation so the - /// unconfirmed ancestors it drags in reach `target.fee.rate` (CPFP). - /// - /// Charged over the ancestors this selection drags in, taken **once each** — never by summing - /// [`SelectionProblem::local_bump`], which would charge a shared ancestor once per candidate. - /// Weight and fee are netted across them, so an ancestor paying above the rate offsets one paying - /// below it, and the result saturates at 0 (an ancestor that overpays never funds the child). - /// - /// Most ancestors are reachable through a single candidate, and - /// [`SelectionProblem`] has already folded those into a per-candidate - /// [`private_ancestors`](SelectionProblem::private_ancestors) pair, so all this does is add them - /// up. Only ancestors several candidates can reach still need de-duplicating here. - /// - /// Note this makes funding **non-monotone**: selecting a candidate that drags in an - /// underpaying ancestor can lower [`excess`](Self::excess). It also means the bump is not - /// additive over candidates, and a descendant selection can owe *less* than its parent (by - /// dragging in an ancestor that already overpays). - pub fn ancestor_bump(&self) -> u64 { - if !self.problem.has_ancestors() { - return 0; - } - - let (mut weight, mut fee) = (0_u64, 0_u64); - if self.problem.has_private_ancestors() { - for cand_index in self.selected.iter() { - let (private_weight, private_fee) = self.problem.private_ancestors(cand_index); - weight += private_weight; - fee += private_fee; - } - } - - if self.problem.has_shared_ancestors() { - let shared = self.selected_shared_ancestors(); - for anc_index in shared.iter() { - let (shared_weight, shared_fee) = self.problem.ancestors()[anc_index]; - weight += shared_weight; - fee += shared_fee; - } - } - - self.target() - .fee - .rate - .implied_fee_wu(weight) - .saturating_sub(fee) - } - /// The unconfirmed ancestors that are not dragged in yet but could still be, i.e. those of the /// [`unselected`](Self::unselected) candidates. Respects [`ban`](Self::ban). /// @@ -256,216 +171,6 @@ impl<'a> CoinSelector<'a> { union } - /// The least [`ancestor_bump`](Self::ancestor_bump) this selection — or any selection extending - /// it — could still owe. - /// - /// This is **not** the bump of the current selection. A later coin can drag in an ancestor that - /// already overpays the target rate; that surplus nets against the deficit, so a descendant can - /// owe *less*. This method credits every still-reachable surplus and floors at zero: - /// - /// ```text - /// bump of this selection, and of every selection that adds more coins - /// >= max(0, currently_owed − reachable_surplus) - /// ``` - /// - /// where `currently_owed` is `rate · ancestor_weight − ancestor_fee` of this selection, and - /// `reachable_surplus` is how much still-addable ancestors overpay the target rate. - /// - /// Surplus cannot be picked up ancestor by ancestor: ancestors arrive by selecting a - /// *candidate*, which drags in its whole transitive set. So `reachable_surplus` is accumulated - /// per group that must arrive together — the split [`SelectionProblem`] already computed: - /// - /// - Ancestors only one candidate can reach ([`private_ancestors`]) are netted as a group, and - /// contribute only if the group as a whole is in surplus. A chain whose tip overpays but which - /// nets to a deficit therefore offers nothing. - /// - Ancestors several candidates can reach ([`shared_drags_in`]) are credited individually, - /// since which candidate brings them — and what else it brings — is not pinned down. - /// - /// This is still a relaxation: those groups may not be reachable *together*, and reaching them at - /// all means adding candidates (and their child weight). Both only push the real figure up. When - /// nothing reachable overpays, the bound equals the current bump. - /// - /// Computed in floating point and floored, so it can sit a fraction of a satoshi below the exact - /// value — in the safe direction. - /// - /// [`private_ancestors`]: SelectionProblem::private_ancestors - /// [`shared_drags_in`]: SelectionProblem::shared_drags_in - pub fn ancestor_bump_lower_bound(&self) -> u64 { - if !self.problem.has_ancestors() { - return 0; - } - let spwu = self.target().fee.rate.spwu() as f64; - // What a group of ancestors still owes; negative means it pays above the target rate. - let owes = |(weight, fee): (u64, u64)| weight as f64 * spwu - fee as f64; - - // Ancestors only one candidate can reach are netted as a group, so they need no - // de-duplicating: what this selection owes for them is a plain sum, and the most a descendant - // could shed is one group at a time. - let mut owed = 0.0; - let mut shed = 0.0; - if self.problem.has_private_ancestors() { - for cand_index in self.selected.iter() { - owed += owes(self.problem.private_ancestors(cand_index)); - } - for cand_index in self.unselected_indices() { - shed += (-owes(self.problem.private_ancestors(cand_index))).max(0.0); - } - } - - // Only ancestors several candidates can reach have to be gathered up, and they are credited - // individually since no single candidate owns them. - if self.problem.has_shared_ancestors() { - let selected_shared = self.selected_shared_ancestors(); - for anc_index in selected_shared.iter() { - owed += owes(self.problem.ancestors()[anc_index]); - } - - let mut addable_shared = Bitset::with_capacity(self.problem.ancestors().len()); - for cand_index in self.unselected_indices() { - for anc_index in self.problem.shared_drags_in(cand_index).iter() { - if !selected_shared.contains(anc_index) { - addable_shared.insert(anc_index); - } - } - } - for anc_index in addable_shared.iter() { - shed += (-owes(self.problem.ancestors()[anc_index])).max(0.0); - } - } - - let bound = owed - shed; - if bound <= 0.0 { - 0 - } else { - // Truncating a positive float rounds down. Account for the lower precision used by the - // actual f32 fee calculation so this cannot sit above a descendant's real bump. - (bound as u64).saturating_sub(self.problem.ancestor_fee_precision_slack()) - } - } - - /// The ancestors this selection drags in that several candidates could have dragged in, taken - /// once each. Empty unless [`SelectionProblem::has_shared_ancestors`]. - fn selected_shared_ancestors(&self) -> Bitset { - let mut shared = Bitset::with_capacity(match self.problem.has_shared_ancestors() { - true => self.problem.ancestors().len(), - false => 0, - }); - if self.problem.has_shared_ancestors() { - for cand_index in self.selected.iter() { - for anc_index in self.problem.shared_drags_in(cand_index).iter() { - shared.insert(anc_index); - } - } - } - shared - } - - /// Current weight of transaction implied by the selection. - /// - /// If you don't have any drain outputs (only target outputs) just set drain_weights to - /// [`DrainWeights::NONE`]. - pub fn weight(&self, target_ouputs: TargetOutputs, drain_weight: DrainWeights) -> u64 { - TX_FIXED_FIELD_WEIGHT - + self.input_weight() - + target_ouputs.output_weight_with_drain(drain_weight) - } - - /// How much the current selection overshoots the value needed to achieve `target`. - /// - /// In order for the resulting transaction to be valid this must be 0 or above. If it's above 0 - /// this means the transaction will overpay for what it needs to reach `target`. - pub fn excess(&self, drain: Drain) -> i64 { - self.rate_excess(drain) - .min(self.absolute_excess(drain)) - .min(self.replacement_excess(drain)) - } - - /// How much extra value needs to be selected to reach the self.target(). - pub fn missing(&self) -> u64 { - let excess = self.excess(Drain::NONE); - if excess < 0 { - excess.unsigned_abs() - } else { - 0 - } - } - - /// How much the current selection overshoots the value need to satisfy `self.target().fee.rate` and - /// `self.target().value` (while ignoring `self.target().fee.absolute`). - /// - /// The feerate obligation includes the [`ancestor_bump`](Self::ancestor_bump). - pub fn rate_excess(&self, drain: Drain) -> i64 { - self.selected_value() as i64 - - self.target().value() as i64 - - drain.value as i64 - - self.implied_fee_from_feerate(drain.weights) as i64 - } - - /// How much the current selection overshoots the value needed to satisfy `self.target().fee.absolute` - /// and `self.target().value` (while ignoring `self.target().fee.rate`). - pub fn absolute_excess(&self, drain: Drain) -> i64 { - self.selected_value() as i64 - - self.target().value() as i64 - - drain.value as i64 - - self.target().fee.absolute as i64 - } - - /// How much the current selection overshoots the value needed to satisfy RBF's rule 4. - pub fn replacement_excess(&self, drain: Drain) -> i64 { - let mut replacement_excess_needed = 0; - if let Some(replace) = self.target().fee.replace { - replacement_excess_needed = - replace.min_fee_to_do_replacement(self.weight(self.target().outputs, drain.weights)) - } - self.selected_value() as i64 - - self.target().value() as i64 - - drain.value as i64 - - replacement_excess_needed as i64 - } - - /// The fee the current selection and `drain_weight` should pay to satisfy `target_fee`. - /// - /// This compares the fee calculated from the target feerate with the fee calculated from the - /// [`Replace`] constraints and returns the larger of the two. - /// - /// The feerate component includes the [`ancestor_bump`](Self::ancestor_bump); the absolute and - /// replacement components are child-transaction constraints and are left alone. - /// - /// `drain_weight` can be 0 to indicate no draining output. - pub fn implied_fee(&self, drain_weights: DrainWeights) -> u64 { - let mut implied_fee = self - .implied_fee_from_feerate(drain_weights) - .max(self.target().fee.absolute); - - if let Some(replace) = self.target().fee.replace { - implied_fee = Ord::max( - implied_fee, - replace - .min_fee_to_do_replacement(self.weight(self.target().outputs, drain_weights)), - ); - } - - implied_fee - } - - fn implied_fee_from_feerate(&self, drain_weights: DrainWeights) -> u64 { - self.target() - .fee - .rate - .implied_fee(self.weight(self.target().outputs, drain_weights)) - + self.ancestor_bump() - } - - /// The actual fee the selection would pay if it was used in a transaction that had - /// `target_value` value for outputs and change output of `drain_value`. - /// - /// This can be negative when the selection is invalid (outputs are greater than inputs). - pub fn fee(&self, target_value: u64, drain_value: u64) -> i64 { - self.selected_value() as i64 - target_value as i64 - drain_value as i64 - } - - // /// Waste sum of all selected inputs. - /// Sorts the candidates by the comparison function. /// /// The comparison function takes the candidate's index and the [`Candidate`]. @@ -570,42 +275,6 @@ impl<'a> CoinSelector<'a> { self.unselected_indices().next().is_none() } - /// Whether the tx implied by the current selection plus a drain of `drain_weights` is within - /// [`Target::max_weight`]. Pass [`DrainWeights::NONE`] for a changeless tx. - /// - /// Always `true` when `max_weight` is `None`. Adding inputs cannot reduce child transaction - /// weight, so this constraint is kept separate from value funding. - pub fn is_within_max_weight(&self, drain_weights: DrainWeights) -> bool { - match self.target().max_weight { - Some(max_weight) => self.weight(self.target().outputs, drain_weights) <= max_weight, - None => true, - } - } - - /// Whether the selection covers the target value (i.e. [`excess`](Self::excess) is - /// non-negative), ignoring [`Target::max_weight`]. - /// - /// Adding an input normally helps, but can increase serialization overhead, and unconfirmed - /// ancestors add stronger non-monotonicity when their bump exceeds the input's value (see - /// [`ancestor_bump`](Self::ancestor_bump)). This deliberately excludes the weight cap; see - /// [`is_within_max_weight`](Self::is_within_max_weight). - pub fn is_funded_with_drain(&self, drain: Drain) -> bool { - self.excess(drain) >= 0 - } - - /// Whether the selection covers the target **value** (net of input fees), i.e. [`excess`] is - /// non-negative. It deliberately does *not* check [`Target::max_weight`]; use - /// [`is_within_max_weight`] for that constraint. See [`is_funded_with_drain`] for the version - /// that accounts for a specific `drain`. - /// - /// [`excess`]: Self::excess - /// [`ancestor_bump`]: Self::ancestor_bump - /// [`is_within_max_weight`]: Self::is_within_max_weight - /// [`is_funded_with_drain`]: Self::is_funded_with_drain - pub fn is_funded(&self) -> bool { - self.is_funded_with_drain(Drain::NONE) - } - /// Select all unselected candidates pub fn select_all(&mut self) { loop { @@ -615,58 +284,13 @@ impl<'a> CoinSelector<'a> { } } - /// The value of the change output should have to drain the excess value while maintaining the - /// constraints of `target` and respecting `change_policy`. - /// - /// If no change output should be added according to policy, this returns `None`. - pub fn drain_value(&self, change_policy: ChangePolicy) -> Option { - let excess = self.excess(Drain { - weights: change_policy.drain_weights, - value: 0, - }); - if excess > change_policy.min_value as i64 { - debug_assert_eq!( - self.is_funded(), - self.is_funded_with_drain(Drain { - weights: change_policy.drain_weights, - value: excess as u64 - }), - "if the target is met without a drain it must be met after adding the drain" - ); - Some(excess as u64) - } else { - None - } - } - - /// Figures out whether the current selection should have a change output given the - /// `change_policy`. If it should not, then it will return [`Drain::NONE`]. The value of the - /// `Drain` will be the same as [`drain_value`]. - /// - /// If [`is_funded`] returns true for this selection then [`is_funded_with_drain`] will - /// also be true if you pass in the drain returned from this method. - /// - /// [`drain_value`]: Self::drain_value - /// [`is_funded_with_drain`]: Self::is_funded_with_drain - /// [`is_funded`]: Self::is_funded - #[must_use] - pub fn drain(&self, change_policy: ChangePolicy) -> Drain { - match self.drain_value(change_policy) { - Some(value) => Drain { - weights: change_policy.drain_weights, - value, - }, - None => Drain::NONE, - } - } - /// Select all candidates with an *effective value* greater than 0 at the provided `feerate`. /// /// A candidate is effective if it provides more value than it costs at `feerate`. /// /// This looks at each candidate's own value and weight only: a candidate that pays for itself /// but drags in an unconfirmed ancestor still counts as effective, even if the resulting - /// [`ancestor_bump`](Self::ancestor_bump) outweighs it. Selection-dependent input-count and + /// [`ancestor_bump`](SelectionView::ancestor_bump) outweighs it. Selection-dependent input-count and /// witness serialization overhead are also excluded from this standalone calculation. pub fn select_all_effective(&mut self, feerate: FeeRate) { for i in 0..self.candidate_order.len() { @@ -695,31 +319,44 @@ impl<'a> CoinSelector<'a> { /// This is especially relevant with unconfirmed ancestors: selecting everything can fail while /// a subset that drags in less ancestor fee debt would meet the target. pub fn select_until_target_met(&mut self) -> Result<(), SelectError> { - self.select_until(|cs| cs.is_funded()).ok_or_else(|| { + let mut excess = 0_i64; + let mut is_within_max_weight = true; + self.select_until(|view| { + excess = view.excess(Drain::NONE); + is_within_max_weight = view.is_within_max_weight(DrainWeights::NONE); + excess >= 0 + }) + .ok_or_else(|| { SelectError::InsufficientFunds(InsufficientFunds { - missing: self.excess(Drain::NONE).unsigned_abs(), + missing: excess.unsigned_abs(), }) })?; - if !self.is_within_max_weight(DrainWeights::NONE) { + if !is_within_max_weight { return Err(SelectError::MaxWeightExceeded); } Ok(()) } /// Select candidates until some predicate has been satisfied. + /// + /// The predicate is handed a [`SelectionView`] whose aggregates are updated incrementally as + /// candidates are selected, so a predicate built from cached queries costs the same at every + /// step regardless of how much is already selected. #[must_use] pub fn select_until( &mut self, - mut predicate: impl FnMut(&CoinSelector<'a>) -> bool, + mut predicate: impl FnMut(&SelectionView<'_>) -> bool, ) -> Option<()> { + let mut cache = SelectionCache::from_selector(self); loop { - if predicate(&*self) { + if predicate(&SelectionView::with_cache(self, &cache)) { break Some(()); } - if !self.select_next() { - break None; - } + let index = self.unselected_indices().next()?; + let candidate = self.candidate(index); + self.select(index); + cache.add(self.problem, index, candidate, true); } } @@ -1018,7 +655,7 @@ pub struct Candidate { /// /// For legacy inputs, do *not* include the `scriptWitnessLen` byte: a legacy input only /// serializes an (empty) witness when the transaction has a witness section, and - /// [`CoinSelector::input_weight`] adds that 1 WU per legacy input once any segwit input is + /// [`SelectionView::input_weight`] adds that 1 WU per legacy input once any segwit input is /// selected. pub weight: u64, /// Total number of segwit inputs. @@ -1030,7 +667,7 @@ pub struct Candidate { /// Total number of legacy (non-segwit) inputs. /// /// Each legacy input serializes an empty witness (1 WU) when the transaction has a witness - /// section; [`CoinSelector::input_weight`] prices this per legacy input, so grouped legacy + /// section; [`SelectionView::input_weight`] prices this per legacy input, so grouped legacy /// inputs are counted exactly. pub legacy_count: usize, } diff --git a/src/drain.rs b/src/drain.rs index 9c58347..d5ed673 100644 --- a/src/drain.rs +++ b/src/drain.rs @@ -70,11 +70,10 @@ impl DrainWeights { /// A drain (A.K.A. change) output. /// Technically it could represent multiple outputs. /// -/// This is returned from [`CoinSelector::drain`] and [`SelectionView::drain`]. Note if `drain` +/// This is returned from [`SelectionView::drain`]. Note if `drain` /// returns a drain where `is_none()` returns true then **no change should be added** to the /// transaction. /// -/// [`CoinSelector::drain`]: crate::CoinSelector::drain /// [`SelectionView::drain`]: crate::SelectionView::drain #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Default)] pub struct Drain { diff --git a/src/metrics/lowest_fee.rs b/src/metrics/lowest_fee.rs index d7a3d73..baf8bf9 100644 --- a/src/metrics/lowest_fee.rs +++ b/src/metrics/lowest_fee.rs @@ -20,7 +20,7 @@ use crate::{float::Ordf32, BnbMetric, Drain, DrainWeights, FeeRate, SelectionVie /// # Unconfirmed ancestors /// /// When the [`SelectionProblem`] has unconfirmed ancestors, the fee a selection must pay includes -/// the [`CoinSelector::ancestor_bump`](crate::CoinSelector::ancestor_bump) of the ancestors it drags +/// the [`SelectionView::ancestor_bump`](crate::SelectionView::ancestor_bump) of the ancestors it drags /// in, so the search naturally prefers coins that drag in nothing or share an already-paid-for /// ancestor. Ancestor fees are netted over the union, allowing an overpaying ancestor to offset an /// underpaying one without subsidizing the child itself. The score remains the child transaction's @@ -91,8 +91,8 @@ impl LowestFee { /// check so the drain is decided once. /// /// The score is the *child* transaction's fee (plus the future cost of spending its change). - /// Any [`CoinSelector::ancestor_bump`] is not added on top: it is already inside the child's fee, - /// because covering it is what [`CoinSelector::is_funded`] demands and what the change + /// Any [`SelectionView::ancestor_bump`] is not added on top: it is already inside the child's fee, + /// because covering it is what [`SelectionView::is_funded`] demands and what the change /// calculation gives up. fn fee_score(&self, cs: &SelectionView<'_>) -> Option<(Ordf32, Drain)> { if !cs.is_funded() { @@ -126,7 +126,7 @@ impl LowestFee { } } - /// Tighter than [`CoinSelector::fee_floor`] once the value shortfall proves that every funded + /// Tighter than [`SelectionView::fee_floor`] once the value shortfall proves that every funded /// descendant must add some child input weight. /// /// Returns `None` only for the one infeasibility this relaxation can actually prove: a fee diff --git a/src/selection_problem.rs b/src/selection_problem.rs index 74cbcd5..7fd401f 100644 --- a/src/selection_problem.rs +++ b/src/selection_problem.rs @@ -59,7 +59,7 @@ impl From> for InputGroup { /// intermediate feerate, so it may also conservatively overestimate a bump. /// /// What a selection actually owes is -/// [`CoinSelector::ancestor_bump`](crate::CoinSelector::ancestor_bump): the shortfall of the +/// [`SelectionView::ancestor_bump`](crate::SelectionView::ancestor_bump): the shortfall of the /// ancestors its selected candidates drag in, each charged once, weight and fee netted over the /// union. #[derive(Debug, Clone)] @@ -278,7 +278,7 @@ impl SelectionProblem { /// /// Deliberately not reduced to a bump: the target rate applies to the total ancestor weight of /// the whole selection at once, and an ancestor paying above the rate must be able to subsidize - /// one paying below it. See [`CoinSelector::ancestor_bump`](crate::CoinSelector::ancestor_bump). + /// one paying below it. See [`SelectionView::ancestor_bump`](crate::SelectionView::ancestor_bump). pub fn private_ancestors(&self, index: usize) -> (u64, u64) { self.private[index] } @@ -330,7 +330,7 @@ impl SelectionProblem { /// [`Target::fee`](crate::TargetFee)'s rate, as if it were the only selected candidate. /// /// Informational: must never be summed over a selection (shared ancestors would be charged - /// twice). What a selection owes is [`CoinSelector::ancestor_bump`](crate::CoinSelector::ancestor_bump). + /// twice). What a selection owes is [`SelectionView::ancestor_bump`](crate::SelectionView::ancestor_bump). pub fn local_bump(&self, index: usize) -> u64 { bump_of(&self.ancestors, self.target.fee.rate, self.drags_in(index)) } diff --git a/src/selection_view.rs b/src/selection_view.rs index 3148b70..200b448 100644 --- a/src/selection_view.rs +++ b/src/selection_view.rs @@ -712,23 +712,19 @@ mod tests { } } - #[test] - fn mixed_candidate_counts_match_selector() { - let candidates = [ - Candidate { - value: 1, - weight: 200, - segwit_count: 1, - legacy_count: 2, - }, - Candidate::new_legacy(2, 100), - ]; - let problem = SelectionProblem::new_no_ancestors(target(), candidates); - let mut selector = problem.selector(); - selector.select_all(); - let view = selector.compute_view(); - assert_eq!(view.input_weight(), selector.input_weight()); - assert_eq!(view.selected_value(), selector.selected_value()); + /// Straightforward iteration over the selected set, kept here as an independent reference for + /// the running aggregates the cache maintains. + fn expected_input_weight(selector: &CoinSelector<'_>) -> u64 { + let is_segwit_tx = selector.selected().any(|(_, c)| c.segwit_count > 0); + let input_count: usize = selector + .selected() + .map(|(_, c)| c.segwit_count + c.legacy_count) + .sum(); + let selected_weight: u64 = selector + .selected() + .map(|(_, c)| c.weight + is_segwit_tx as u64 * c.legacy_count as u64) + .sum(); + varint_size(input_count) * 4 + selected_weight + is_segwit_tx as u64 * 2 } #[test] @@ -801,7 +797,7 @@ mod tests { } #[test] - fn cache_matches_selector_for_every_mixed_selection() { + fn cache_matches_iteration_for_every_mixed_selection() { let candidates = [ Candidate::new_segwit(1_000, 100), Candidate::new_legacy(2_000, 200), @@ -821,9 +817,11 @@ mod tests { } } let view = selector.compute_view(); - assert_eq!(view.selected_value(), selector.selected_value()); - assert_eq!(view.input_weight(), selector.input_weight()); - assert_eq!(view.excess(Drain::NONE), selector.excess(Drain::NONE)); + assert_eq!( + view.selected_value(), + selector.selected().map(|(_, c)| c.value).sum::() + ); + assert_eq!(view.input_weight(), expected_input_weight(&selector)); } } @@ -858,7 +856,7 @@ mod tests { assert_eq!(view.input_weight(), { let mut expected = problem.selector(); expected.select(1); - expected.input_weight() + expected_input_weight(&expected) }); } @@ -903,11 +901,11 @@ mod tests { let problem = SelectionProblem::new_no_ancestors(target, candidates); let mut selector = problem.selector(); selector.select(0); - assert!(selector.is_funded()); + assert!(selector.compute_view().is_funded()); let mut all = selector.clone(); all.select(1); - assert!(!all.is_funded()); + assert!(!all.compute_view().is_funded()); assert!(selector.compute_view().is_fundable()); } @@ -959,20 +957,29 @@ mod tests { for index in 0..2 { actual.select(index); hypothetical.add(index); - assert_eq!(hypothetical.ancestor_bump(), actual.ancestor_bump()); + assert_eq!( + hypothetical.ancestor_bump(), + actual.compute_view().ancestor_bump() + ); assert_eq!( hypothetical.ancestor_bump_lower_bound(), - actual.ancestor_bump_lower_bound() + actual.compute_view().ancestor_bump_lower_bound() + ); + assert_eq!( + hypothetical.excess(Drain::NONE), + actual.compute_view().excess(Drain::NONE) ); - assert_eq!(hypothetical.excess(Drain::NONE), actual.excess(Drain::NONE)); } actual.deselect(0); hypothetical.sub(0); - assert_eq!(hypothetical.ancestor_bump(), actual.ancestor_bump()); + assert_eq!( + hypothetical.ancestor_bump(), + actual.compute_view().ancestor_bump() + ); assert_eq!( hypothetical.ancestor_bump_lower_bound(), - actual.ancestor_bump_lower_bound() + actual.compute_view().ancestor_bump_lower_bound() ); actual.ban(0); @@ -982,7 +989,7 @@ mod tests { .ban(hypothetical.selector.problem(), 0); assert_eq!( hypothetical.ancestor_bump_lower_bound(), - actual.ancestor_bump_lower_bound() + actual.compute_view().ancestor_bump_lower_bound() ); } } diff --git a/tests/ancestor.rs b/tests/ancestor.rs index e4f74c9..0683df7 100644 --- a/tests/ancestor.rs +++ b/tests/ancestor.rs @@ -78,7 +78,7 @@ fn bump_is_charged_on_top_of_the_childs_own_fee() { let mut cs = problem.selector(); cs.select(0); - assert_eq!(cs.ancestor_bump(), 2_500); + assert_eq!(cs.compute_view().ancestor_bump(), 2_500); let no_ancestors = SelectionProblem::new_no_ancestors( t, @@ -92,19 +92,21 @@ fn bump_is_charged_on_top_of_the_childs_own_fee() { let mut clean_cs = no_ancestors.selector(); clean_cs.select(0); - assert_eq!(clean_cs.ancestor_bump(), 0); + assert_eq!(clean_cs.compute_view().ancestor_bump(), 0); assert_eq!( - cs.weight(t.outputs, DrainWeights::NONE), - clean_cs.weight(t.outputs, DrainWeights::NONE) + cs.compute_view().weight(t.outputs, DrainWeights::NONE), + clean_cs + .compute_view() + .weight(t.outputs, DrainWeights::NONE) ); assert_eq!( - cs.excess(Drain::NONE), - clean_cs.excess(Drain::NONE) - 2_500, + cs.compute_view().excess(Drain::NONE), + clean_cs.compute_view().excess(Drain::NONE) - 2_500, "the bump is the only difference between the two selections" ); assert_eq!( - cs.implied_fee(DrainWeights::NONE), - clean_cs.implied_fee(DrainWeights::NONE) + 2_500 + cs.compute_view().implied_fee(DrainWeights::NONE), + clean_cs.compute_view().implied_fee(DrainWeights::NONE) + 2_500 ); } @@ -122,13 +124,13 @@ fn dragged_in_ancestor_can_unfund_a_selection() { let mut clean_only = problem.selector(); clean_only.select(0); - assert!(clean_only.is_funded()); + assert!(clean_only.compute_view().is_funded()); let mut both = problem.selector(); both.select(0); both.select(1); assert!( - !both.is_funded(), + !both.compute_view().is_funded(), "adding a coin with an expensive ancestor un-funds a funded selection" ); } @@ -152,9 +154,9 @@ fn shared_ancestor_is_charged_once() { cs.select(1); assert_eq!(cs.selected_ancestors().len(), 1); - assert_eq!(cs.ancestor_bump(), 2_500); + assert_eq!(cs.compute_view().ancestor_bump(), 2_500); assert_ne!( - cs.ancestor_bump(), + cs.compute_view().ancestor_bump(), problem.local_bump(0) + problem.local_bump(1) ); } @@ -177,20 +179,28 @@ fn deselecting_keeps_an_ancestor_another_candidate_still_drags_in() { cs.select(0); cs.select(1); - assert_eq!(cs.ancestor_bump(), 2_500); + assert_eq!(cs.compute_view().ancestor_bump(), 2_500); cs.deselect(0); - assert_eq!(cs.ancestor_bump(), 2_500, "candidate 1 still drags in P"); + assert_eq!( + cs.compute_view().ancestor_bump(), + 2_500, + "candidate 1 still drags in P" + ); cs.select(2); assert_eq!( - cs.ancestor_bump(), + cs.compute_view().ancestor_bump(), 2_500, "a confirmed coin drags in nothing" ); cs.deselect(1); - assert_eq!(cs.ancestor_bump(), 0, "nothing selected drags in P anymore"); + assert_eq!( + cs.compute_view().ancestor_bump(), + 0, + "nothing selected drags in P anymore" + ); } /// The whole transitive chain is charged, and fees are netted across it (not per ancestor). @@ -210,7 +220,7 @@ fn transitive_ancestors_are_netted_as_one_package() { // Union: weight 800 => 2000 sats owed at 2.5 sat/wu, of which B already paid 1000. assert_eq!(cs.selected_ancestors().len(), 2); - assert_eq!(cs.ancestor_bump(), 1_000); + assert_eq!(cs.compute_view().ancestor_bump(), 1_000); } /// Dragging in an ancestor that overpays *lowers* what the selection owes, because the deficit is @@ -230,17 +240,21 @@ fn overpaying_ancestor_offsets_an_underpaying_one() { let mut poor_only = problem.selector(); poor_only.select(1); - assert_eq!(poor_only.ancestor_bump(), 100); + assert_eq!(poor_only.compute_view().ancestor_bump(), 100); let mut rich_only = problem.selector(); rich_only.select(0); - assert_eq!(rich_only.ancestor_bump(), 0, "never credits the child"); + assert_eq!( + rich_only.compute_view().ancestor_bump(), + 0, + "never credits the child" + ); let mut both = problem.selector(); both.select(0); both.select(1); assert_eq!( - both.ancestor_bump(), + both.compute_view().ancestor_bump(), 0, "RICH's surplus covers POOR's deficit, so the superset owes less" ); @@ -260,7 +274,7 @@ fn ancestor_weight_does_not_count_against_max_weight() { let mut cs = problem.selector(); cs.select(0); - let child_weight = cs.weight(t.outputs, DrainWeights::NONE); + let child_weight = cs.compute_view().weight(t.outputs, DrainWeights::NONE); assert!(child_weight < heavy); t.max_weight = Some(child_weight); @@ -271,7 +285,9 @@ fn ancestor_weight_does_not_count_against_max_weight() { ); let mut capped_cs = capped.selector(); capped_cs.select(0); - assert!(capped_cs.is_within_max_weight(DrainWeights::NONE)); + assert!(capped_cs + .compute_view() + .is_within_max_weight(DrainWeights::NONE)); } /// Two coins of equal value and weight are *not* interchangeable when only one of them drags in an @@ -316,12 +332,12 @@ fn score_is_the_childs_fee_which_already_covers_the_bump() { assert_eq!( score, Ordf32( - (cs.fee(t.value(), drain.value) as u64 + drain.weights.spend_fee(m.long_term_feerate)) - as f32 + (cs.compute_view().fee(t.value(), drain.value) as u64 + + drain.weights.spend_fee(m.long_term_feerate)) as f32 ) ); assert!( - cs.fee(t.value(), drain.value) as u64 >= cs.ancestor_bump(), + cs.compute_view().fee(t.value(), drain.value) as u64 >= cs.compute_view().ancestor_bump(), "a funded selection's child fee covers the bump" ); } @@ -348,9 +364,9 @@ fn bump_lower_bound_is_the_full_bump_when_nothing_overpays() { let mut cs = problem.selector(); cs.select(0); - assert_eq!(cs.ancestor_bump(), 2_500); + assert_eq!(cs.compute_view().ancestor_bump(), 2_500); assert_eq!( - cs.ancestor_bump_lower_bound(), + cs.compute_view().ancestor_bump_lower_bound(), 2_500, "Q only ever adds to what is owed, so it cannot lower the floor" ); @@ -376,9 +392,9 @@ fn bump_lower_bound_gives_up_the_reachable_surplus() { let mut cs = problem.selector(); cs.select(0); - assert_eq!(cs.ancestor_bump(), 1_000); + assert_eq!(cs.compute_view().ancestor_bump(), 1_000); assert_eq!( - cs.ancestor_bump_lower_bound(), + cs.compute_view().ancestor_bump_lower_bound(), 0, "RICH's 9_900 surplus swamps the 1_000 owed" ); @@ -386,7 +402,7 @@ fn bump_lower_bound_gives_up_the_reachable_surplus() { // Which is not pessimism: that descendant really does owe nothing. let mut both = cs.clone(); both.select(1); - assert_eq!(both.ancestor_bump(), 0); + assert_eq!(both.compute_view().ancestor_bump(), 0); } /// Only the surplus actually within reach is given up. @@ -404,22 +420,22 @@ fn bump_lower_bound_only_credits_reachable_surplus() { let mut cs = problem.selector(); cs.select(0); - assert_eq!(cs.ancestor_bump(), 1_000); - assert_eq!(cs.ancestor_bump_lower_bound(), 0); + assert_eq!(cs.compute_view().ancestor_bump(), 1_000); + assert_eq!(cs.compute_view().ancestor_bump_lower_bound(), 0); // Ban the coin that would bring RICH in and the surplus is out of reach again. let mut banned = cs.clone(); banned.ban(1); assert!(banned.addable_ancestors().is_empty()); - assert_eq!(banned.ancestor_bump_lower_bound(), 1_000); + assert_eq!(banned.compute_view().ancestor_bump_lower_bound(), 1_000); // Likewise once there is nothing left to add. let mut exhausted = cs.clone(); exhausted.select(1); assert!(exhausted.is_exhausted()); assert_eq!( - exhausted.ancestor_bump_lower_bound(), - exhausted.ancestor_bump() + exhausted.compute_view().ancestor_bump_lower_bound(), + exhausted.compute_view().ancestor_bump() ); } @@ -439,7 +455,7 @@ fn bound_credits_the_bump_when_nothing_overpays() { let child_fee = t .fee .rate - .implied_fee_wu(cs.weight(t.outputs, DrainWeights::NONE)); + .implied_fee_wu(cs.compute_view().weight(t.outputs, DrainWeights::NONE)); let bound = metric() .bound(&cs.compute_view()) .expect("within max_weight"); @@ -463,7 +479,7 @@ fn bump_lower_bound_accounts_for_large_f32_fee_rounding() { cs.select(0); assert!( - cs.ancestor_bump_lower_bound() <= cs.ancestor_bump(), + cs.compute_view().ancestor_bump_lower_bound() <= cs.compute_view().ancestor_bump(), "the f64 relaxation must not exceed the f32 fee obligation" ); let view = cs.compute_view(); @@ -537,9 +553,9 @@ fn funded_bound_gives_up_reachable_surplus() { let mut cs = problem.selector(); cs.select(0); - assert!(cs.is_funded()); - assert_eq!(cs.ancestor_bump(), 1_000); - assert_eq!(cs.ancestor_bump_lower_bound(), 0); + assert!(cs.compute_view().is_funded()); + assert_eq!(cs.compute_view().ancestor_bump(), 1_000); + assert_eq!(cs.compute_view().ancestor_bump_lower_bound(), 0); let score = metric().score(&cs.compute_view()).unwrap(); let bound = metric().bound(&cs.compute_view()).unwrap(); @@ -607,8 +623,8 @@ fn funded_bound_subtracts_surplus_before_float_conversion() { let mut node = problem.selector(); node.select(0); - assert_eq!(node.ancestor_bump(), 1_998_000_000); - assert_eq!(node.ancestor_bump_lower_bound(), 0); + assert_eq!(node.compute_view().ancestor_bump(), 1_998_000_000); + assert_eq!(node.compute_view().ancestor_bump_lower_bound(), 0); let bound = metric.bound(&node.compute_view()).unwrap(); let mut descendant = node.clone(); @@ -629,7 +645,7 @@ fn unfunded_bound_does_not_claim_infeasibility() { ); let cs = problem.selector(); - assert!(!cs.is_funded()); + assert!(!cs.compute_view().is_funded()); assert!( metric().bound(&cs.compute_view()).is_some(), "an unfunded root with a live funded subset must not be pruned" @@ -652,7 +668,7 @@ fn unfunded_bound_credits_selected_package_surplus() { let mut node = problem.selector(); node.select(0); - assert!(!node.is_funded()); + assert!(!node.compute_view().is_funded()); let bound = metric().bound(&node.compute_view()).unwrap(); let mut descendant = node.clone(); @@ -724,14 +740,14 @@ fn bump_lower_bound_nets_ancestors_that_must_arrive_together() { let mut cs = problem.selector(); cs.select(0); - assert_eq!(cs.ancestor_bump(), 1_000); + assert_eq!(cs.compute_view().ancestor_bump(), 1_000); // RICH's 9_900 surplus is real, but only comes with GRAN's 2_000 deficit: still a net surplus. - assert_eq!(cs.ancestor_bump_lower_bound(), 0); + assert_eq!(cs.compute_view().ancestor_bump_lower_bound(), 0); let mut both = cs.clone(); both.select(1); assert_eq!( - both.ancestor_bump(), + both.compute_view().ancestor_bump(), 0, "that descendant really owes nothing" ); @@ -749,9 +765,9 @@ fn bump_lower_bound_nets_ancestors_that_must_arrive_together() { ); let mut cs = deep.selector(); cs.select(0); - assert_eq!(cs.ancestor_bump(), 1_000); + assert_eq!(cs.compute_view().ancestor_bump(), 1_000); assert_eq!( - cs.ancestor_bump_lower_bound(), + cs.compute_view().ancestor_bump_lower_bound(), 1_000, "taking RICH means taking GRAN, which costs far more than RICH's surplus is worth" ); @@ -759,7 +775,7 @@ fn bump_lower_bound_nets_ancestors_that_must_arrive_together() { let mut both = cs.clone(); both.select(1); assert!( - both.ancestor_bump() > 1_000, + both.compute_view().ancestor_bump() > 1_000, "confirmed by the descendant, which owes more, not less" ); } @@ -786,9 +802,9 @@ fn bump_lower_bound_credits_shared_surplus_on_its_own() { let mut cs = problem.selector(); cs.select(0); - assert_eq!(cs.ancestor_bump(), 1_000); + assert_eq!(cs.compute_view().ancestor_bump(), 1_000); assert_eq!( - cs.ancestor_bump_lower_bound(), + cs.compute_view().ancestor_bump_lower_bound(), 0, "RICH is reachable without HEAVY, so its surplus counts" ); @@ -890,11 +906,11 @@ proptest! { let feerate = problem.target().fee.rate; let cs = problem.selector(); - prop_assert_eq!(cs.ancestor_bump(), expected_bump(&problem, &cs, feerate)); + prop_assert_eq!(cs.compute_view().ancestor_bump(), expected_bump(&problem, &cs, feerate)); for (node, _) in common::ExhaustiveIter::new(&cs).into_iter().flatten() { prop_assert_eq!( - node.ancestor_bump(), + node.compute_view().ancestor_bump(), expected_bump(&problem, &node, feerate), "selection={}", node ); @@ -916,10 +932,10 @@ proptest! { ); for node in nodes { - let lower_bound = node.ancestor_bump_lower_bound(); + let lower_bound = node.compute_view().ancestor_bump_lower_bound(); prop_assert!( - lower_bound <= node.ancestor_bump(), - "node={} lb={} owes={}", node, lower_bound, node.ancestor_bump() + lower_bound <= node.compute_view().ancestor_bump(), + "node={} lb={} owes={}", node, lower_bound, node.compute_view().ancestor_bump() ); for (descendant, inclusion) in common::ExhaustiveIter::new(&node).into_iter().flatten() { @@ -927,9 +943,9 @@ proptest! { continue; } prop_assert!( - lower_bound <= descendant.ancestor_bump(), + lower_bound <= descendant.compute_view().ancestor_bump(), "node={} lb={} descendant={} owes={}", - node, lower_bound, descendant, descendant.ancestor_bump() + node, lower_bound, descendant, descendant.compute_view().ancestor_bump() ); } } diff --git a/tests/bnb.rs b/tests/bnb.rs index a931480..080c9e1 100644 --- a/tests/bnb.rs +++ b/tests/bnb.rs @@ -47,7 +47,7 @@ impl BnbMetric for MinExcessThenWeight { fn bound(&mut self, cs: &SelectionView<'_>) -> Option { let mut cs = cs.selector().clone(); cs.select_until_target_met().ok()?; - Some(Ordf32(cs.input_weight() as f32)) + Some(Ordf32(cs.compute_view().input_weight() as f32)) } fn drain(&mut self, _cs: &SelectionView<'_>) -> Drain { @@ -87,7 +87,7 @@ fn bnb_finds_an_exact_solution_in_n_iter() { let problem = SelectionProblem::new_no_ancestors(target, solution.iter().copied()); let mut cs = CoinSelector::new(&problem); cs.select_all(); - cs.input_weight() + cs.compute_view().input_weight() }; let problem_2 = SelectionProblem::new_no_ancestors(target, candidates.iter().copied()); @@ -103,8 +103,13 @@ fn bnb_finds_an_exact_solution_in_n_iter() { .expect("it found a solution"); assert_eq!(rounds, 62453); - assert_eq!(best.input_weight(), solution_weight); - assert_eq!(best.selected_value(), target_value, "score={:?}", score); + assert_eq!(best.compute_view().input_weight(), solution_weight); + assert_eq!( + best.compute_view().selected_value(), + target_value, + "score={:?}", + score + ); } #[test] @@ -138,7 +143,7 @@ fn bnb_finds_solution_if_possible_in_n_iter() { .expect("found a solution"); assert_eq!(rounds, 95); - let excess = sol.excess(Drain::NONE); + let excess = sol.compute_view().excess(Drain::NONE); assert_eq!(excess, 0); } @@ -193,7 +198,7 @@ proptest! { let solutions = cs.bnb_solutions(MinExcessThenWeight); match solutions.enumerate().filter_map(|(i, sol)| Some((i, sol?))).last() { - Some((_i, (sol, _score))) => assert!(sol.selected_value() >= target_value), + Some((_i, (sol, _score))) => assert!(sol.compute_view().selected_value() >= target_value), _ => prop_assert!(!cs.compute_view().is_fundable()), } } @@ -225,7 +230,7 @@ proptest! { let problem_5 = SelectionProblem::new_no_ancestors(target, solution.iter().copied()); let mut cs = CoinSelector::new(&problem_5); cs.select_all(); - cs.input_weight() + cs.compute_view().input_weight() }; let problem_6 = SelectionProblem::new_no_ancestors(target, candidates.iter().copied()); @@ -245,7 +250,7 @@ proptest! { .last() .expect("it found a solution"); - prop_assert!(best.input_weight() <= solution_weight); - prop_assert_eq!(best.selected_value(), target.value()); + prop_assert!(best.compute_view().input_weight() <= solution_weight); + prop_assert_eq!(best.compute_view().selected_value(), target.value()); } } diff --git a/tests/common.rs b/tests/common.rs index 544b740..acfc2aa 100644 --- a/tests/common.rs +++ b/tests/common.rs @@ -72,14 +72,17 @@ where ); // bonus check: ensure replacement fee is respected if exp_result.is_some() { - let selected_value = exp_selection.selected_value(); + let selected_value = exp_selection.compute_view().selected_value(); let drain = metric.drain(&exp_selection.compute_view()); let target_value = target.value(); let replace_fee = params .replace .map(|replace| { - replace - .min_fee_to_do_replacement(exp_selection.weight(target.outputs, drain.weights)) + replace.min_fee_to_do_replacement( + exp_selection + .compute_view() + .weight(target.outputs, drain.weights), + ) }) .unwrap_or(0); assert!(selected_value - target_value - drain.value >= replace_fee); @@ -112,14 +115,17 @@ where ); // bonus check: ensure replacement fee is respected - let selected_value = selection.selected_value(); + let selected_value = selection.compute_view().selected_value(); let drain = bnb_metric.drain(&selection.compute_view()); let target_value = target.value(); let replace_fee = params .replace .map(|replace| { - replace - .min_fee_to_do_replacement(selection.weight(target.outputs, drain.weights)) + replace.min_fee_to_do_replacement( + selection + .compute_view() + .weight(target.outputs, drain.weights), + ) }) .unwrap_or(0); assert!(selected_value - target_value - drain.value >= replace_fee); @@ -193,7 +199,7 @@ where cs, parent_has_change, lb_score, - cs.is_funded(), + cs.compute_view().is_funded(), descendant_cs, descendant_has_change, descendant_score, @@ -391,11 +397,14 @@ where /// current selection) meet `target`, i.e. cover the value **and** stay within `max_weight`? /// /// Enumerates every subset via [`ExhaustiveIter`] and reuses the real -/// [`CoinSelector::is_funded`] + [`CoinSelector::is_within_max_weight`], so it inherits the +/// [`SelectionView::is_funded`] + [`SelectionView::is_within_max_weight`], so it inherits the /// exact weight model and is independent of the BnB weight prune it audits. Exponential — small `n` /// only. pub fn exact_selection_possible(cs: &CoinSelector) -> bool { - let feasible = |s: &CoinSelector| s.is_funded() && s.is_within_max_weight(DrainWeights::NONE); + let feasible = |s: &CoinSelector| { + let view = s.compute_view(); + view.is_funded() && view.is_within_max_weight(DrainWeights::NONE) + }; // the current selection itself (no additions) is a valid subset and isn't yielded by the iter feasible(cs) || ExhaustiveIter::new(cs) @@ -550,8 +559,9 @@ fn randomly_satisfy_target<'a, R: rand::Rng>( let mut last_score: Option = None; while let Some(next) = cs.unselected_indices().choose(rng) { cs.select(next); - if cs.is_funded() { - let curr_score = metric.score(&cs.compute_view()); + let view = cs.compute_view(); + if view.is_funded() { + let curr_score = metric.score(&view); if let Some(last_score) = last_score { if curr_score.is_none() || curr_score.unwrap() > last_score { break; diff --git a/tests/lowest_fee.rs b/tests/lowest_fee.rs index b6cb209..ce5c4b5 100644 --- a/tests/lowest_fee.rs +++ b/tests/lowest_fee.rs @@ -386,7 +386,7 @@ fn run_bnb_returns_the_greedy_selection_on_a_tight_budget() { let problem_12 = SelectionProblem::new_no_ancestors(target, candidates.iter().copied()); let mut cs = CoinSelector::new(&problem_12); cs.run_bnb(err_metric(), 1).expect("the seed is a solution"); - assert!(cs.is_funded()); + assert!(cs.compute_view().is_funded()); } #[test] diff --git a/tests/srd.rs b/tests/srd.rs index 3d8cadc..4c22c64 100644 --- a/tests/srd.rs +++ b/tests/srd.rs @@ -50,12 +50,12 @@ fn srd_success_yields_healthy_change_that_meets_target() { ); assert_eq!(drain.weights, drain_weights); assert!( - cs.is_funded_with_drain(drain), + cs.compute_view().is_funded_with_drain(drain), "seed {}: target not met with the returned drain", seed ); // The reported change equals the actual excess available to the drain. - let excess = cs.excess(Drain { + let excess = cs.compute_view().excess(Drain { weights: drain_weights, value: 0, }); @@ -134,7 +134,9 @@ fn srd_max_weight_exceeded() { probe .select_until(|cs| cs.excess(drain) >= CHANGE_LOWER as i64) .expect("candidates can cover target + change_lower"); - let needed_weight = probe.weight(target(200_000, 5.0).outputs, drain_weights); + let needed_weight = probe + .compute_view() + .weight(target(200_000, 5.0).outputs, drain_weights); // Cap just below that, so SRD trips the weight limit as it reaches `change_lower`. let capped = Target { diff --git a/tests/weight.rs b/tests/weight.rs index 4abfbb1..b7e65b7 100644 --- a/tests/weight.rs +++ b/tests/weight.rs @@ -250,7 +250,9 @@ fn legacy_three_inputs_one_segwit() { coin_selector.select_all(); assert_eq!( - coin_selector.weight(target_ouputs, DrainWeights::NONE), + coin_selector + .compute_view() + .weight(target_ouputs, DrainWeights::NONE), tx.weight().to_wu() ); } @@ -290,7 +292,9 @@ fn legacy_three_inputs_grouped() { coin_selector.select_all(); assert_eq!( - coin_selector.weight(target_ouputs, DrainWeights::NONE), + coin_selector + .compute_view() + .weight(target_ouputs, DrainWeights::NONE), tx.weight().to_wu() ); } @@ -334,7 +338,9 @@ fn legacy_pair_grouped_with_segwit_input() { coin_selector.select_all(); assert_eq!( - coin_selector.weight(target_ouputs, DrainWeights::NONE), + coin_selector + .compute_view() + .weight(target_ouputs, DrainWeights::NONE), tx.weight().to_wu() ); } From 9c40ae23aa9386d4dd3e5ac4491e2645f6e3f396 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=BF=97=E5=AE=87?= Date: Sun, 16 Aug 2026 00:43:11 +0000 Subject: [PATCH 24/26] refactor: drop the redundant score from BnbIter's seed `seed` carried `(CoinSelector, Ordf32)` while `best` separately held the same score. They are set together in `seed_greedy_incumbent` and nothing runs between construction and the first `next()`, so the score in the tuple was always exactly `best`. Store the selection alone and read the score from `best` when yielding. No behaviour change: identical score, selection, round count and exhausted flag on all 42 benchmark fixtures. --- src/bnb.rs | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/src/bnb.rs b/src/bnb.rs index 0c0f9f9..048dcbf 100644 --- a/src/bnb.rs +++ b/src/bnb.rs @@ -11,9 +11,10 @@ pub(crate) struct BnbIter<'a, M: BnbMetric> { cache: SelectionCache, stack: Vec, best: Option, - /// The greedy selection, yielded before the first node is expanded. See + /// The greedy selection, yielded before the first node is expanded. Its score is `best`: + /// nothing else can have run yet, so the two are set together. See /// [`seed_greedy_incumbent`](BnbIter::seed_greedy_incumbent). - seed: Option<(CoinSelector<'a>, Ordf32)>, + seed: Option>, exhausted: bool, /// The `BnBMetric` that will score each selection pub(crate) metric: M, @@ -34,7 +35,8 @@ impl<'a, M: BnbMetric> Iterator for BnbIter<'a, M> { fn next(&mut self) -> Option { if let Some(seed) = self.seed.take() { - return Some(Some(seed)); + let score = self.best.expect("the seed and `best` are set together"); + return Some(Some((seed, score))); } if self.exhausted { @@ -114,7 +116,7 @@ impl<'a, M: BnbMetric> BnbIter<'a, M> { } if let Some(score) = self.metric.score(&seed.compute_view()) { self.best = Some(score); - self.seed = Some((seed, score)); + self.seed = Some(seed); } } From 7a0c0d3d22ed4815150ec0a60b34455a9052ec2c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=BF=97=E5=AE=87?= Date: Sun, 16 Aug 2026 05:54:05 +0000 Subject: [PATCH 25/26] feat: search branch and bound with iterative deepening on the bound MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Depth-first traversal is linear in memory but expands whatever is under its feet, so it prunes against whatever incumbent its dive order happened to find. On problems whose candidates share unconfirmed ancestors that is far from the optimum, and the search cannot recover: `subsidizing_ancestry_50` burns forty million nodes without improving on an incumbent 2.5x worse than the answer a priority queue proves in 55,737. This runs the same depth-first traversal in passes under a rising ceiling on the bound. Pass k visits the nodes whose bound is at or below the threshold, which is the set a priority queue expands before it first pops a node of that bound, so the passes reconstruct best-first's expansion order without a frontier. The incumbent carries across passes, and a pass ending with the incumbent at or below the threshold proves it optimal: any better selection would have had every node on its path bounded by its own score, so it could not have been pruned by either rule. The threshold schedule is a speed knob and never a correctness one — raising the threshold past the smallest rejected bound only ever adds nodes to a pass, never skips one — so `eps` is free to trade re-expansion against how closely the queue's order is followed. `bnb_solutions` is unchanged and takes the plain dive; the new behaviour is opt-in through `bnb_solutions_with_deepening`. Measured on coinselect-benchmark's 42 fixtures at a wall-clock budget, eps=0.1: subsidizing_ancestry_50 40,000,000 nodes, not exhausted, child fee 11,332 -> 64,544 nodes, exhausted, child fee 4,508 shared_ancestry_200 36,242 -> 21,069 nested_ancestry_200 30,203 -> 22,477 subsidizing_ancestry_100 30,140 -> 18,925 subsidizing_ancestry_200 27,281 -> 22,999 Exhausted rises from 31 to 34 of 42 and peak RSS stays flat at 3.5 MB. Where both traversals exhaust they agree on all 32 fixtures, and the brute-force oracle confirms the optimum on all 9 fixtures small enough to enumerate. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01HLiTkESMktypGJhFag2ZBM --- src/bnb.rs | 130 +++++++++++++++++++++++++++++++++++++++++-- src/coin_selector.rs | 14 +++++ 2 files changed, 139 insertions(+), 5 deletions(-) diff --git a/src/bnb.rs b/src/bnb.rs index 048dcbf..4631677 100644 --- a/src/bnb.rs +++ b/src/bnb.rs @@ -1,5 +1,28 @@ use crate::{float::Ordf32, Drain, SelectionCache, SelectionView}; +/// Pass counts for iterative deepening, for measurement only. +/// +/// `BnbIter` is `pub(crate)`, so a plain accessor would not be reachable from a benchmark harness. +/// A process-global counter is enough: the harness runs one search at a time. +pub mod deepening_stats { + use core::sync::atomic::{AtomicU64, Ordering}; + + static PASSES: AtomicU64 = AtomicU64::new(0); + + pub(crate) fn record_pass() { + PASSES.fetch_add(1, Ordering::Relaxed); + } + + /// Passes started since [`reset`]. One pass is one traversal from the root under one threshold. + pub fn passes() -> u64 { + PASSES.load(Ordering::Relaxed) + } + + pub fn reset() { + PASSES.store(0, Ordering::Relaxed); + } +} + use super::CoinSelector; use alloc::vec::Vec; @@ -16,6 +39,17 @@ pub(crate) struct BnbIter<'a, M: BnbMetric> { /// [`seed_greedy_incumbent`](BnbIter::seed_greedy_incumbent). seed: Option>, exhausted: bool, + /// Iterative deepening: the current pass's ceiling on the bound. `None` disables deepening + /// entirely, which is the traversal exactly as it was before. + threshold: Option, + /// Smallest bound rejected *by the threshold* this pass — the next pass's floor. + /// + /// Children rejected for being no better than the incumbent are deliberately not recorded: + /// they can never become interesting, and folding them in would waste passes. + next_threshold: Option, + /// Relative step for the threshold schedule. The schedule is a speed knob, never a correctness + /// one: raising the threshold past the next rejected bound only ever *adds* nodes to a pass. + deepening: Option, /// The `BnBMetric` that will score each selection pub(crate) metric: M, } @@ -65,7 +99,7 @@ impl<'a, M: BnbMetric> Iterator for BnbIter<'a, M> { None }; - if !self.descend() && !self.backtrack_to_next_branch() { + if !self.descend() && !self.backtrack_to_next_branch() && !self.start_next_pass() { self.exhausted = true; } @@ -74,7 +108,15 @@ impl<'a, M: BnbMetric> Iterator for BnbIter<'a, M> { } impl<'a, M: BnbMetric> BnbIter<'a, M> { - pub(crate) fn new(mut selector: CoinSelector<'a>, metric: M) -> Self { + pub(crate) fn new(selector: CoinSelector<'a>, metric: M) -> Self { + Self::with_deepening(selector, metric, None) + } + + pub(crate) fn with_deepening( + mut selector: CoinSelector<'a>, + metric: M, + deepening: Option, + ) -> Self { if metric.requires_ordering_by_descending_value_pwu() { selector.sort_candidates_by_descending_value_pwu(); } @@ -87,15 +129,24 @@ impl<'a, M: BnbMetric> BnbIter<'a, M> { best: None, seed: None, exhausted: false, + threshold: None, + next_threshold: None, + deepening, metric, }; iter.seed_greedy_incumbent(); + // Incumbent-only: the root must not be rejected by a threshold that is derived from it. if !iter.bound_is_promising() { iter.exhausted = true; } + // The first pass admits exactly the root. + if deepening.is_some() { + iter.threshold = iter.bound_of_current(); + } + iter } @@ -153,9 +204,78 @@ impl<'a, M: BnbMetric> BnbIter<'a, M> { } } + /// Whether to descend into a child, and the place the deepening threshold is applied. + /// + /// Takes `&mut self` because a child rejected by the threshold contributes the next pass's + /// floor. Rejection by the incumbent contributes nothing. + fn admit(&mut self, bound: Option) -> bool { + if !self.is_promising(bound) { + return false; + } + let bound = match bound { + Some(bound) => bound, + None => return false, + }; + if let Some(threshold) = self.threshold { + if bound > threshold { + self.next_threshold = Some(match self.next_threshold { + Some(next) if next <= bound => next, + _ => bound, + }); + return false; + } + } + true + } + fn bound_is_promising(&mut self) -> bool { let bound = self.bound_of_current(); - self.is_promising(bound) + self.admit(bound) + } + + /// Unwind every frame, leaving the selector and cache as they were at the root. + /// + /// In place, using the same undo paths backtracking uses — rebuilding the `CoinSelector` or the + /// `SelectionCache` per pass is what would put the memory back. + fn reset_to_root(&mut self) { + while let Some(frame) = self.stack.pop() { + if frame.is_inclusion { + self.undo_include(frame.index); + } else { + self.undo_exclude(&frame.banned); + } + } + } + + /// Raise the threshold and restart from the root. `false` means the search is over. + fn start_next_pass(&mut self) -> bool { + let eps = match self.deepening { + Some(eps) => eps, + None => return false, + }; + // The incumbent is proven optimal: every node that could beat it had a bound at or below + // the threshold, so it was visited this pass or an earlier one. + if let (Some(best), Some(threshold)) = (self.best, self.threshold) { + if best <= threshold { + return false; + } + } + // Nothing was rejected by the threshold, so the whole tree is explored. + let next = match self.next_threshold.take() { + Some(next) => next, + None => return false, + }; + let grown = match self.threshold { + Some(threshold) => { + let stepped = threshold.0 * (1.0 + eps); + Ordf32(if stepped > next.0 { stepped } else { next.0 }) + } + None => next, + }; + self.threshold = Some(grown); + crate::bnb::deepening_stats::record_pass(); + self.reset_to_root(); + true } fn cursor(&self) -> usize { @@ -272,13 +392,13 @@ impl<'a, M: BnbMetric> BnbIter<'a, M> { self.apply_include(index); let inc_bound = self.bound_of_current(); - let inc_ok = self.is_promising(inc_bound); + let inc_ok = self.admit(inc_bound); self.undo_include(index); let (banned, exc_next_cursor) = self.exclusion_plan(index, cursor); self.apply_exclude(&banned); let exc_bound = self.bound_of_current(); - let exc_ok = self.is_promising(exc_bound); + let exc_ok = self.admit(exc_bound); self.undo_exclude(&banned); // println!( diff --git a/src/coin_selector.rs b/src/coin_selector.rs index b3e4c0e..f36c46b 100644 --- a/src/coin_selector.rs +++ b/src/coin_selector.rs @@ -440,6 +440,20 @@ impl<'a> CoinSelector<'a> { crate::bnb::BnbIter::new(self.clone(), metric) } + /// [`bnb_solutions`](Self::bnb_solutions), searched with iterative deepening on the bound. + /// + /// The traversal stays depth-first and so stays linear in memory, but it runs in passes under a + /// rising ceiling on the bound, which recovers the node ordering a priority queue would give. + /// `eps` is the relative step between thresholds: smaller follows the queue's order more + /// closely and re-expands more, larger degenerates toward a plain dive. + pub fn bnb_solutions_with_deepening( + &self, + metric: M, + eps: f32, + ) -> impl Iterator, Ordf32)>> { + crate::bnb::BnbIter::with_deepening(self.clone(), metric, Some(eps)) + } + /// Run branch and bound to minimize the score of the provided [`BnbMetric`]. /// /// The method keeps trying until no better solution can be found, or we reach `max_rounds`. If a From 1a281f8bd0714d9a1abf7717723192a55c2e5fd5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=BF=97=E5=AE=87?= Date: Sun, 16 Aug 2026 06:19:25 +0000 Subject: [PATCH 26/26] feat: dive before deepening, and keep the incumbent MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Iterative deepening reconstructs a priority queue's node ordering, but it is not anytime: under a ceiling low enough to be useful the early passes may not reach a complete selection at all. On a pool too large to exhaust that is strictly worse than diving, which reaches leaves immediately — measured at +70% on `wallet_mixed_2000`, and no threshold step fixes both ends, because a step large enough to protect it is large enough to lose `subsidizing_ancestry_50` outright. So dive first and deepen after, carrying the incumbent across. The dive hands over once it has gone as long without an improvement as it took to find the one it holds, which keeps its budget on a pool that is still creeping downward and gives up quickly on one that is stuck — the failure this exists to fix. That rule needs a floor, because the greedy incumbent is set before the first node and so leaves it nothing to measure against. The floor scales on candidate count rather than on the budget, which is not visible here: a dive to a leaf costs at most one node per candidate, so the floor is that depth times a constant. 200 was the best single value over 42 fixtures at three budgets and the metric is not sharply peaked around it. Wallet track, against the plain dive, eps=0.1: 10 ms -0.48% 2 better, 0 worse 100 ms -3.81% 5 better, 0 worse exhausted 28 -> 31 of 42 1000 ms -3.89% 5 better, 1 worse exhausted 31 -> 33 of 42 `subsidizing_ancestry_50` reaches the optimum of 4,508 after a 10,001-node dive and 8 passes. Peak RSS stays at 3.6 MB. The default path is untouched: no flag, no behaviour change, byte-identical to the parent commit on all 42 fixtures. The one regression is opportunity cost, not a lost incumbent: handing over ends the dive, so against a dive that keeps the whole budget the hybrid can come out behind. It cannot come out behind a dive given the same dive budget. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01HLiTkESMktypGJhFag2ZBM --- src/bnb.rs | 70 ++++++++++++++++++++++++++++++++++++++++++-- src/coin_selector.rs | 38 ++++++++++++++++++++++++ 2 files changed, 106 insertions(+), 2 deletions(-) diff --git a/src/bnb.rs b/src/bnb.rs index 4631677..a9f61c8 100644 --- a/src/bnb.rs +++ b/src/bnb.rs @@ -8,6 +8,7 @@ pub mod deepening_stats { use core::sync::atomic::{AtomicU64, Ordering}; static PASSES: AtomicU64 = AtomicU64::new(0); + static HANDOVER: AtomicU64 = AtomicU64::new(0); pub(crate) fn record_pass() { PASSES.fetch_add(1, Ordering::Relaxed); @@ -18,8 +19,18 @@ pub mod deepening_stats { PASSES.load(Ordering::Relaxed) } + pub(crate) fn record_dive_handover(nodes: u64) { + HANDOVER.store(nodes, Ordering::Relaxed); + } + + /// Nodes the opening dive spent before handing over to deepening. 0 means it never handed over. + pub fn dive_handover() -> u64 { + HANDOVER.load(Ordering::Relaxed) + } + pub fn reset() { PASSES.store(0, Ordering::Relaxed); + HANDOVER.store(0, Ordering::Relaxed); } } @@ -50,6 +61,19 @@ pub(crate) struct BnbIter<'a, M: BnbMetric> { /// Relative step for the threshold schedule. The schedule is a speed knob, never a correctness /// one: raising the threshold past the next rejected bound only ever *adds* nodes to a pass. deepening: Option, + /// Still in the opening dive, before any threshold applies. + /// + /// Deepening reconstructs the queue's ordering but is not anytime: under a ceiling low enough to + /// be useful, the early passes may not reach a complete selection at all. On a pool too large to + /// exhaust that is strictly worse than diving, which reaches leaves immediately. So dive first, + /// and deepen only once the dive stops paying — the incumbent carries over, so the deepening + /// phase cannot return anything worse than the dive already found. + diving: bool, + /// Smallest number of nodes the opening dive is always given before it may hand over. + dive_floor: u64, + /// Nodes expanded so far, and the count when the incumbent last improved. + nodes: u64, + last_improvement: u64, /// The `BnBMetric` that will score each selection pub(crate) metric: M, } @@ -77,6 +101,11 @@ impl<'a, M: BnbMetric> Iterator for BnbIter<'a, M> { return None; } + self.nodes += 1; + if self.diving && self.dive_is_stalled() { + self.stop_diving(); + } + // { // println!("=========================== {:?}", self.best); // println!("{} {:?}", &self.selector, self.bound_of_current()); @@ -113,9 +142,18 @@ impl<'a, M: BnbMetric> BnbIter<'a, M> { } pub(crate) fn with_deepening( + selector: CoinSelector<'a>, + metric: M, + deepening: Option, + ) -> Self { + Self::configured(selector, metric, deepening, None) + } + + pub(crate) fn configured( mut selector: CoinSelector<'a>, metric: M, deepening: Option, + dive_first: Option, ) -> Self { if metric.requires_ordering_by_descending_value_pwu() { selector.sort_candidates_by_descending_value_pwu(); @@ -132,6 +170,10 @@ impl<'a, M: BnbMetric> BnbIter<'a, M> { threshold: None, next_threshold: None, deepening, + diving: dive_first.is_some() && deepening.is_some(), + dive_floor: dive_first.unwrap_or(0), + nodes: 0, + last_improvement: 0, metric, }; @@ -142,8 +184,8 @@ impl<'a, M: BnbMetric> BnbIter<'a, M> { iter.exhausted = true; } - // The first pass admits exactly the root. - if deepening.is_some() { + // The first pass admits exactly the root. A hybrid search sets this when it stops diving. + if deepening.is_some() && !iter.diving { iter.threshold = iter.bound_of_current(); } @@ -185,6 +227,7 @@ impl<'a, M: BnbMetric> BnbIter<'a, M> { }; if better { self.best = Some(score); + self.last_improvement = self.nodes; Some(score) } else { None @@ -247,8 +290,31 @@ impl<'a, M: BnbMetric> BnbIter<'a, M> { } } + /// Whether the opening dive has stopped paying for itself. + /// + /// The rule is self-scaling rather than a tuned constant: give up once the search has gone as + /// long without an improvement as it took to find the one it has. A dive that keeps creeping + /// downward — which is what a pool too large to exhaust does — keeps its budget; a dive that is + /// stuck, which is the failure this is here to fix, hands over quickly. + fn dive_is_stalled(&self) -> bool { + self.nodes.saturating_sub(self.last_improvement) + > self.last_improvement.max(self.dive_floor) + } + + /// Leave the opening dive and begin deepening, keeping the incumbent the dive found. + fn stop_diving(&mut self) { + self.diving = false; + self.reset_to_root(); + self.threshold = self.bound_of_current(); + crate::bnb::deepening_stats::record_dive_handover(self.nodes); + } + /// Raise the threshold and restart from the root. `false` means the search is over. fn start_next_pass(&mut self) -> bool { + // A dive that runs out of tree has explored everything; there is nothing to deepen into. + if self.diving { + return false; + } let eps = match self.deepening { Some(eps) => eps, None => return false, diff --git a/src/coin_selector.rs b/src/coin_selector.rs index f36c46b..1ae2579 100644 --- a/src/coin_selector.rs +++ b/src/coin_selector.rs @@ -454,6 +454,44 @@ impl<'a> CoinSelector<'a> { crate::bnb::BnbIter::with_deepening(self.clone(), metric, Some(eps)) } + /// [`bnb_solutions`](Self::bnb_solutions), dived first and then deepened. + /// + /// Depth-first reaches complete selections immediately but prunes against whatever its dive + /// order found; deepening recovers a priority queue's node ordering but reaches complete + /// selections late. This takes both: dive until the incumbent stops improving, then deepen from + /// the root keeping that incumbent. Because the incumbent only ever improves, this cannot + /// return a worse selection than the dive alone would have. + pub fn bnb_solutions_hybrid( + &self, + metric: M, + eps: f32, + ) -> impl Iterator, Ordf32)>> { + self.bnb_solutions_hybrid_with_floor(metric, eps, Self::DEFAULT_DIVE_FLOOR_PER_CANDIDATE) + } + + /// How long the opening dive is protected for, per candidate. + /// + /// The dive needs a floor or it hands over before it has found anything, because the greedy + /// incumbent is set before the first node and so leaves the "time since last improvement" rule + /// with nothing to measure against. The floor has to scale with something, and the budget is not + /// visible here — a caller may be spending rounds or wall clock. Candidate count is: a dive to a + /// leaf costs at most one node per candidate, so this is that depth times a constant. + /// + /// Measured over 42 fixtures at 10 ms, 100 ms and 1000 ms; 200 was the best single value, and + /// the metric is not sharply peaked around it. + pub const DEFAULT_DIVE_FLOOR_PER_CANDIDATE: u64 = 200; + + /// [`bnb_solutions_hybrid`](Self::bnb_solutions_hybrid) with the dive floor chosen explicitly. + pub fn bnb_solutions_hybrid_with_floor( + &self, + metric: M, + eps: f32, + floor_per_candidate: u64, + ) -> impl Iterator, Ordf32)>> { + let floor = floor_per_candidate.saturating_mul(self.candidates().count() as u64); + crate::bnb::BnbIter::configured(self.clone(), metric, Some(eps), Some(floor)) + } + /// Run branch and bound to minimize the score of the provided [`BnbMetric`]. /// /// The method keeps trying until no better solution can be found, or we reach `max_rounds`. If a