Skip to content

Commit 736ef39

Browse files
committed
refactor(cpfp): improve owned output selection to balance dust avoidance and fee efficiency
1 parent f22b3bd commit 736ef39

4 files changed

Lines changed: 70 additions & 29 deletions

File tree

examples/common.rs

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -188,7 +188,14 @@ impl Wallet {
188188
let canon_utxos = CanonicalUnspents::new(self.canonical_txs());
189189

190190
let script_pubkey = self.next_address().unwrap().script_pubkey();
191-
let cpfpset = canon_utxos.build_cpfp_set_from_txids(parent_txids, self.graph.graph())?;
191+
let ownership_check =
192+
|outpoint: OutPoint| -> bool { self.graph.index.txout(outpoint).is_some() };
193+
194+
let cpfpset = canon_utxos.build_cpfp_set_from_txids(
195+
parent_txids,
196+
self.graph.graph(),
197+
ownership_check,
198+
)?;
192199
let plans = cpfpset
193200
.selected_outpoints
194201
.iter()

examples/cpfp.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,7 @@ fn main() -> anyhow::Result<()> {
4040
// Create two low-fee parent transactions
4141
let (tip_height, tip_time) = wallet.tip_info(env.rpc_client())?;
4242
let mut parent_txids = vec![];
43-
for i in 0..3 {
43+
for i in 0..4 {
4444
let low_fee_selection = wallet
4545
.all_candidates()
4646
.regroup(group_by_spk())

src/canonical_unspents.rs

Lines changed: 50 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,8 @@ use alloc::vec::Vec;
33
use core::fmt;
44

55
use crate::{
6-
collections::HashMap, input::CoinbaseMismatch, CPFPError, CPFPSet, FromPsbtInputError, Input,
7-
RbfSet, TxStatus,
6+
collections::HashMap, input::CoinbaseMismatch, CPFPError, CPFPSet,
7+
FromPsbtInputError, Input, RbfSet, TxStatus,
88
};
99
use bdk_chain::TxGraph;
1010
use bitcoin::{psbt, Amount, OutPoint, Sequence, Transaction, TxOut, Txid, Weight};
@@ -21,6 +21,22 @@ pub struct CanonicalUnspents {
2121
spends: HashMap<OutPoint, Txid>,
2222
}
2323

24+
fn select_upper_middle_output(candidates: &[(OutPoint, &TxOut)]) -> OutPoint {
25+
let len = candidates.len();
26+
27+
let index = match len {
28+
1..=2 => len - 1, // select the largest for small sets
29+
3..=5 => len - 2, // select second largest for medium sets
30+
_ => {
31+
let upper_third_start = (len * 2) / 3;
32+
let upper_third_end = len - 1;
33+
(upper_third_start + upper_third_end) / 2
34+
}
35+
};
36+
37+
candidates[index].0
38+
}
39+
2440
impl CanonicalUnspents {
2541
/// Construct [`CanonicalUnspents`] from an iterator of txs with confirmation status.
2642
pub fn new<T>(canonical_txs: impl IntoIterator<Item = TxWithStatus<T>>) -> Self
@@ -128,11 +144,15 @@ impl CanonicalUnspents {
128144
}
129145

130146
/// Constructs a '[CPFPSet]' from a set of parent transaction IDs.
131-
pub fn build_cpfp_set_from_txids(
147+
pub fn build_cpfp_set_from_txids<F>(
132148
&self,
133149
parent_txids: impl IntoIterator<Item = Txid>,
134150
graph: &TxGraph,
135-
) -> Result<CPFPSet, CPFPError> {
151+
ownership_check: F,
152+
) -> Result<CPFPSet, CPFPError>
153+
where
154+
F: Fn(OutPoint) -> bool + Clone,
155+
{
136156
let parent_txids: Vec<Txid> = parent_txids.into_iter().collect();
137157

138158
const MAX_ANCESTORS: usize = 25;
@@ -148,8 +168,9 @@ impl CanonicalUnspents {
148168
weight += tx.weight();
149169
fee += graph.calculate_fee(tx)?;
150170

151-
let base_outpoint = self.select_largest_unspent_output(tx.clone())?;
152-
outpoints.push(base_outpoint);
171+
let selected_outpoint =
172+
self.select_owned_unspent_output(tx.clone(), ownership_check.clone())?;
173+
outpoints.push(selected_outpoint);
153174

154175
Ok((weight, fee, outpoints))
155176
},
@@ -158,13 +179,17 @@ impl CanonicalUnspents {
158179
Ok(CPFPSet::new(parent_fee, parent_weight, selected_outpoints))
159180
}
160181

161-
/// Selects the largest unspent output from a given transaction
162-
pub fn select_largest_unspent_output(
182+
/// Selects the unspent output from a given transaction
183+
pub fn select_owned_unspent_output<F>(
163184
&self,
164185
tx: Arc<Transaction>,
165-
) -> Result<OutPoint, CPFPError> {
186+
ownership_check: F,
187+
) -> Result<OutPoint, CPFPError>
188+
where
189+
F: Fn(OutPoint) -> bool,
190+
{
166191
let txid = tx.compute_txid();
167-
let outpoint = tx
192+
let mut candidates: Vec<_> = tx
168193
.output
169194
.iter()
170195
.enumerate()
@@ -177,12 +202,22 @@ impl CanonicalUnspents {
177202
txout,
178203
)
179204
})
180-
.filter(|(op, _)| self.is_unspent(*op))
181-
.max_by_key(|(_, txout)| txout.value)
182-
.map(|(op, _)| op)
183-
.ok_or(CPFPError::NoUnspentOutput(txid))?;
205+
.filter(|(op, tx_out)| {
206+
// Must be unspent, owned and above dust threshold
207+
self.is_unspent(*op)
208+
&& ownership_check(*op)
209+
&& tx_out.value >= tx_out.script_pubkey.minimal_non_dust()
210+
})
211+
.collect();
212+
213+
if candidates.is_empty() {
214+
return Err(CPFPError::NoUnspentOutput(txid));
215+
}
216+
217+
candidates.sort_by_key(|(_, txout)| txout.value);
218+
let selected_outpoint = select_upper_middle_output(&candidates);
184219

185-
Ok(outpoint)
220+
Ok(selected_outpoint)
186221
}
187222

188223
/// Whether outpoint is a leaf (unspent).

src/cpfp.rs

Lines changed: 11 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -39,22 +39,21 @@ impl CPFPSet {
3939
script_pubkey: &ScriptBuf,
4040
plans: impl IntoIterator<Item = Plan>,
4141
) -> Result<Selection, CPFPError> {
42-
let mut inputs = Vec::new();
43-
let mut total_input_value = Amount::ZERO;
44-
45-
// Get inputs from selected outpoints
46-
for (outpoint, plan) in self.selected_outpoints.iter().zip(plans) {
47-
if let Some(input) = canon_utxos.try_get_unspent(*outpoint, plan) {
48-
total_input_value += input.prev_txout().value;
49-
inputs.push(input);
50-
} else {
51-
return Err(CPFPError::NoUnspentOutput(outpoint.txid));
52-
}
53-
}
42+
let inputs: Vec<Input> = self
43+
.selected_outpoints
44+
.iter()
45+
.zip(plans)
46+
.map(|(op, plan)| {
47+
canon_utxos
48+
.try_get_unspent(*op, plan)
49+
.ok_or(CPFPError::NoUnspentOutput(op.txid))
50+
})
51+
.collect::<Result<Vec<Input>, CPFPError>>()?;
5452

5553
if inputs.is_empty() {
5654
return Err(CPFPError::NoSpendableOutputs);
5755
}
56+
let total_input_value: Amount = inputs.iter().map(|input| input.prev_txout().value).sum();
5857

5958
let child_weight = self.estimate_child_tx_weight(&inputs, script_pubkey);
6059

0 commit comments

Comments
 (0)