Skip to content

Commit 8d74694

Browse files
committed
cpfp candidates and set
1 parent b52ebf8 commit 8d74694

6 files changed

Lines changed: 277 additions & 37 deletions

File tree

examples/common.rs

Lines changed: 52 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,11 @@ use bdk_chain::{
66
};
77
use bdk_coin_select::DrainWeights;
88
use bdk_testenv::{bitcoincore_rpc::RpcApi, TestEnv};
9-
use bdk_tx::{CanonicalUnspents, Input, InputCandidates, RbfParams, TxStatus, TxWithStatus};
10-
use bitcoin::{absolute, Address, Amount, BlockHash, OutPoint, Transaction, TxOut, Txid};
9+
use bdk_tx::{
10+
CPFPParams, CPFPSet, CanonicalUnspents, Input, InputCandidates, RbfParams, TxStatus,
11+
TxWithStatus,
12+
};
13+
use bitcoin::{absolute, Address, BlockHash, FeeRate, OutPoint, Transaction, Txid};
1114
use miniscript::{
1215
plan::{Assets, Plan},
1316
Descriptor, DescriptorPublicKey, ForEachKey,
@@ -201,4 +204,51 @@ impl Wallet {
201204
rbf_set.selector_rbf_params(),
202205
))
203206
}
207+
208+
pub fn cpfp_candidates(
209+
&self,
210+
parent_txids: impl IntoIterator<Item = Txid>,
211+
tip_height: absolute::Height,
212+
target_feerate: FeeRate,
213+
) -> anyhow::Result<(InputCandidates, CPFPParams)> {
214+
let assets = self.assets();
215+
let mut canon_utxos = CanonicalUnspents::new(self.canonical_txs());
216+
let index = &self.graph.index;
217+
let parent_txids: Vec<Txid> = parent_txids.into_iter().collect();
218+
219+
let cpfpset = CPFPSet::new(parent_txids.clone(), self.graph.graph(), tip_height)?;
220+
let cpfp_params = cpfpset.selector_cpfp_params(target_feerate);
221+
222+
let must_select = cpfpset
223+
.must_select_largest_input_of_each_parent(&canon_utxos)?
224+
.into_iter()
225+
.map(|op| {
226+
let plan = self
227+
.plan_of_output(op, &assets)
228+
.ok_or_else(|| anyhow::anyhow!("failed to derive plan for outpoint {}", op))?;
229+
canon_utxos.try_get_unspent(op, plan).ok_or_else(|| {
230+
anyhow::anyhow!("failed to get unspent input for outpoint {}", op)
231+
})
232+
})
233+
.collect::<Result<Vec<Input>, _>>()?;
234+
235+
// Select other spendable outputs as optional candidates
236+
let can_select = index
237+
.outpoints()
238+
.iter()
239+
.filter_map(|(_, op)| {
240+
if canon_utxos.is_unspent(*op) {
241+
self.plan_of_output(*op, &assets)
242+
.map(|plan| canon_utxos.try_get_unspent(*op, plan))
243+
} else {
244+
None
245+
}
246+
})
247+
.flatten();
248+
249+
let candidates = InputCandidates::new(must_select, can_select)
250+
.filter(cpfpset.candidate_filter(&canon_utxos, tip_height));
251+
252+
Ok((candidates, cpfp_params))
253+
}
204254
}

examples/synopsis.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -142,6 +142,7 @@ fn main() -> anyhow::Result<()> {
142142
change_weight: wallet.change_weight(),
143143
// This ensures that we satisfy mempool-replacement policy rules 4 and 6.
144144
replace: Some(rbf_params),
145+
cpfp: None,
145146
},
146147
)?;
147148

src/canonical_unspents.rs

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -224,6 +224,16 @@ impl CanonicalUnspents {
224224
self.try_get_foreign_unspent(op, seq, input, sat_wu, is_coinbase)
225225
})
226226
}
227+
228+
/// Retrieves a transaction by its transaction ID.
229+
pub fn get_tx(&self, txid: &Txid) -> Option<&Arc<Transaction>> {
230+
self.txs.get(txid)
231+
}
232+
233+
/// Retrieves the transaction ID that spends a given output, if any.
234+
pub fn get_spend(&self, outpoint: &OutPoint) -> Option<&Txid> {
235+
self.spends.get(outpoint)
236+
}
227237
}
228238

229239
/// Canonical unspents error

src/cpfp.rs

Lines changed: 183 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,183 @@
1+
use crate::{CPFPParams, CanonicalUnspents, Input};
2+
use alloc::collections::BTreeMap;
3+
use alloc::sync::Arc;
4+
use alloc::vec::Vec;
5+
use bdk_chain::tx_graph::CalculateFeeError;
6+
use bdk_chain::Anchor;
7+
use miniscript::bitcoin::{absolute::Height, Amount, FeeRate, OutPoint, Transaction, Txid, Weight};
8+
use std::collections::HashSet;
9+
10+
/// Set of CPFP
11+
#[derive(Debug, Clone)]
12+
pub struct CPFPSet {
13+
/// Parent transactions and their unconfirmed ancestors.
14+
pub txs: BTreeMap<Txid, Arc<Transaction>>,
15+
/// Total fee of parent transactions and their ancestors.
16+
pub total_fee: Amount,
17+
/// Total weight of parent transactions and their ancestors.
18+
pub total_weight: Weight,
19+
}
20+
21+
/// CPFP errors.
22+
#[derive(Debug)]
23+
pub enum CPFPError {
24+
/// A specified parent transaction ID does not exist in the transaction graph.
25+
MissingParent(Txid),
26+
/// A previous transaction (prevout) referenced by a parent transaction is missing.
27+
MissingPrevTx(Txid),
28+
/// An output referenced by an outpoint in a parent transaction is missing.
29+
MissingPrevTxOut(OutPoint),
30+
/// No parent transactions were provided for the CPFP operation.
31+
NoParents,
32+
/// A parent transaction has no unspent outputs available to spend in the CPFP transaction.
33+
NoUnspentOutput(Txid),
34+
/// The number of unconfirmed ancestors exceeds the Bitcoin protocol limit (25).
35+
ExcessUnconfirmedAncestor,
36+
/// An error occurred while calculating the fee for a transaction.
37+
CalculateFee(CalculateFeeError),
38+
}
39+
40+
impl core::fmt::Display for CPFPError {
41+
fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
42+
match self {
43+
Self::MissingParent(txid) => write!(f, "parent transaction {} not found", txid),
44+
Self::MissingPrevTx(txid) => write!(f, "previous transaction {} not found", txid),
45+
Self::MissingPrevTxOut(outpoint) => write!(f, "previous output {} not found", outpoint),
46+
Self::NoParents => write!(f, "no parent transactions provided"),
47+
Self::ExcessUnconfirmedAncestor => write!(f, "too many unconfirmed ancestor"),
48+
Self::NoUnspentOutput(txid) => {
49+
write!(f, "no unspent output found for parent transaction {}", txid)
50+
}
51+
Self::CalculateFee(err) => write!(f, "failed to calculate fee: {}", err),
52+
}
53+
}
54+
}
55+
56+
impl From<CalculateFeeError> for CPFPError {
57+
fn from(err: CalculateFeeError) -> Self {
58+
CPFPError::CalculateFee(err)
59+
}
60+
}
61+
62+
#[cfg(feature = "std")]
63+
impl std::error::Error for CPFPError {}
64+
65+
impl CPFPSet {
66+
/// Create a new CPFPSet from parent transactions and their ancestors.
67+
pub fn new(
68+
parent_txids: impl IntoIterator<Item = Txid>,
69+
graph: &bdk_chain::TxGraph,
70+
tip_height: Height,
71+
) -> Result<Self, CPFPError> {
72+
let mut parent_fee = Amount::ZERO;
73+
let mut parent_weight = Weight::ZERO;
74+
75+
let mut txs: BTreeMap<Txid, Arc<Transaction>> = BTreeMap::new();
76+
77+
let parent_txids: Vec<Txid> = parent_txids.into_iter().collect();
78+
79+
for txid in parent_txids {
80+
let mut stack = vec![txid];
81+
while let Some(current_txid) = stack.pop() {
82+
if let Some(tx_node) = graph.get_tx_node(current_txid) {
83+
// Check if transaction is unconfirmed
84+
let is_unconfirmed = tx_node.anchors.is_empty()
85+
|| tx_node.anchors.iter().all(|anchor| {
86+
anchor.anchor_block().height > tip_height.to_consensus_u32()
87+
});
88+
89+
if is_unconfirmed {
90+
// Calculate fees and weights for all unconfirmed ancestors
91+
let tx = tx_node.tx;
92+
parent_fee += graph.calculate_fee(&tx)?;
93+
parent_weight += tx.weight();
94+
txs.insert(txid, tx.clone());
95+
96+
for input in &tx.input {
97+
stack.push(input.previous_output.txid);
98+
}
99+
}
100+
} else {
101+
return Err(CPFPError::MissingParent(txid));
102+
}
103+
}
104+
}
105+
106+
const MAX_ANCESTORS: usize = 25;
107+
if txs.len() > MAX_ANCESTORS {
108+
return Err(CPFPError::NoParents);
109+
}
110+
111+
Ok(Self {
112+
txs,
113+
total_fee: parent_fee,
114+
total_weight: parent_weight,
115+
})
116+
}
117+
118+
/// Select the largest unspent output for each parent transaction.
119+
pub fn must_select_largest_input_of_each_parent(
120+
&self,
121+
canon_utxos: &CanonicalUnspents,
122+
) -> Result<HashSet<OutPoint>, CPFPError> {
123+
let mut must_select = HashSet::new();
124+
125+
for (txid, tx) in &self.txs {
126+
let outpoint = tx
127+
.output
128+
.iter()
129+
.enumerate()
130+
.map(|(vout, _)| OutPoint {
131+
txid: *txid,
132+
vout: vout as u32,
133+
})
134+
.filter(|op| canon_utxos.is_unspent(*op))
135+
.max_by_key(|op| {
136+
canon_utxos
137+
.get_tx(&op.txid)
138+
.and_then(|tx| tx.output.get(op.vout as usize))
139+
.map(|txout| txout.value)
140+
.unwrap_or(Amount::ZERO)
141+
})
142+
.ok_or_else(|| CPFPError::NoUnspentOutput(*txid))?;
143+
144+
must_select.insert(outpoint);
145+
}
146+
147+
Ok(must_select)
148+
}
149+
150+
/// Filter input for candidates
151+
pub fn candidate_filter<'a>(
152+
&'a self,
153+
canon_utxos: &'a CanonicalUnspents,
154+
tip_height: Height,
155+
) -> impl Fn(&Input) -> bool + 'a {
156+
let parent_outpoints = self
157+
.txs
158+
.values()
159+
.flat_map(|tx| tx.input.iter().map(|txin| txin.previous_output))
160+
.collect::<HashSet<OutPoint>>();
161+
162+
move |input: &Input| {
163+
if parent_outpoints.contains(&input.prev_outpoint()) {
164+
return true;
165+
}
166+
167+
input.confirmations(tip_height) > 0
168+
|| canon_utxos
169+
.get_spend(&input.prev_outpoint())
170+
.map_or(false, |txid| self.txs.contains_key(txid))
171+
}
172+
}
173+
174+
/// Generate CPFP parameters for coin selection.
175+
pub fn selector_cpfp_params(&self, target_feerate: FeeRate) -> CPFPParams {
176+
CPFPParams::new(
177+
self.txs.keys().cloned(),
178+
target_feerate,
179+
self.total_fee,
180+
self.total_weight,
181+
)
182+
}
183+
}

src/lib.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ mod rbf;
2121
mod selection;
2222
mod selector;
2323
mod signer;
24+
mod cpfp;
2425

2526
pub use canonical_unspents::*;
2627
pub use finalizer::*;
@@ -34,6 +35,7 @@ pub use rbf::*;
3435
pub use selection::*;
3536
pub use selector::*;
3637
pub use signer::*;
38+
pub use cpfp::*;
3739

3840
#[cfg(feature = "std")]
3941
pub(crate) mod collections {

src/selector.rs

Lines changed: 29 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -96,9 +96,9 @@ pub struct CPFPParams {
9696
/// Target fee rate for the child transaction.
9797
pub target_feerate: FeeRate,
9898
/// Total fee of parent transactions.
99-
pub parent_fee: Amount,
99+
pub parent_total_fee: Amount,
100100
/// Total weight of parent transactions.
101-
pub parent_weight: Weight,
101+
pub parent_total_weight: Weight,
102102
}
103103

104104
/// Change policy type
@@ -159,14 +159,14 @@ impl CPFPParams {
159159
pub fn new(
160160
parent_txids: impl IntoIterator<Item = Txid>,
161161
target_feerate: FeeRate,
162-
parent_fee: Amount,
163-
parent_weight: Weight,
162+
parent_total_fee: Amount,
163+
parent_total_weight: Weight,
164164
) -> Self {
165165
Self {
166166
parent_txids: parent_txids.into_iter().collect(),
167167
target_feerate,
168-
parent_fee,
169-
parent_weight,
168+
parent_total_fee,
169+
parent_total_weight,
170170
}
171171
}
172172
}
@@ -197,9 +197,29 @@ impl SelectorParams {
197197
.replace
198198
.as_ref()
199199
.map_or(FeeRate::ZERO, |r| r.max_feerate());
200+
201+
let mut target_fee_rate = self.target_feerate.max(feerate_lb);
202+
203+
// Adjust target fee rate for CPFP to account for parent fees and weights
204+
if let Some(cpfp) = &self.cpfp {
205+
let child_target_fee = cpfp.target_feerate;
206+
let child_min_vbytes = Weight::from_vb(100).expect("valid vbytes"); // Convert vbytes to Weight
207+
let total_weight = cpfp.parent_total_weight + child_min_vbytes; // Use Weight directly
208+
let total_fee_needed = child_target_fee * total_weight; // FeeRate * Weight = Amount
209+
let child_fee_needed = Amount::from_sat(
210+
total_fee_needed
211+
.to_sat()
212+
.saturating_sub(cpfp.parent_total_fee.to_sat()),
213+
); // Convert to satoshis for subtraction
214+
let child_min_feerate = FeeRate::from_sat_per_vb_unchecked(
215+
child_fee_needed.to_sat() / child_min_vbytes.to_vbytes_ceil(), // Use to_vbytes_ceil
216+
);
217+
target_fee_rate = target_fee_rate.max(child_min_feerate);
218+
}
219+
200220
Target {
201221
fee: TargetFee {
202-
rate: cs_feerate(self.target_feerate.max(feerate_lb)),
222+
rate: cs_feerate(target_fee_rate),
203223
replace: self.replace.as_ref().map(|r| r.to_cs_replace()),
204224
},
205225
outputs: TargetOutputs::fund_outputs(
@@ -293,33 +313,7 @@ impl<'c> Selector<'c> {
293313
let target_outputs = params.target_outputs;
294314
let change_descriptor = params.change_descriptor;
295315

296-
let mut adjusted_target = target;
297-
if let Some(cpfp) = params.cpfp {
298-
// Estimate child transaction weight
299-
let output_weight: u64 = target_outputs
300-
.iter()
301-
.map(|output| output.txout().weight().to_wu())
302-
.sum();
303-
let input_weight: u64 = candidates.groups().map(|grp| grp.weight()).sum();
304-
let input_count = candidates
305-
.groups()
306-
.map(|grp| grp.input_count())
307-
.sum::<usize>();
308-
let output_count = target_outputs.len() + 1;
309-
let estimated_child_weight =
310-
output_weight + input_weight + 4 * 4 + input_count as u64 + output_count as u64;
311-
312-
// Calculate combined fee rate for child + parents
313-
let total_fee = adjusted_target.fee.rate.as_sat_vb() as u64 * adjusted_target.value()
314-
+ cpfp.parent_fee.to_sat();
315-
let total_weight = estimated_child_weight + cpfp.parent_weight.to_wu();
316-
let combined_feerate = (total_fee + total_weight - 1) / total_weight;
317-
adjusted_target.fee.rate = cs_feerate(FeeRate::from_sat_per_vb_unchecked(
318-
combined_feerate.max(cpfp.target_feerate.to_sat_per_vb_ceil() as u64),
319-
));
320-
}
321-
322-
if adjusted_target.value() > candidates.groups().map(|grp| grp.value().to_sat()).sum() {
316+
if target.value() > candidates.groups().map(|grp| grp.value().to_sat()).sum() {
323317
return Err(SelectorError::CannotMeetTarget(CannotMeetTarget));
324318
}
325319
let mut inner = bdk_coin_select::CoinSelector::new(candidates.coin_select_candidates());
@@ -328,7 +322,7 @@ impl<'c> Selector<'c> {
328322
}
329323
Ok(Self {
330324
candidates,
331-
target: adjusted_target,
325+
target,
332326
target_outputs,
333327
change_policy,
334328
change_script,

0 commit comments

Comments
 (0)