Skip to content

Commit ebbebd9

Browse files
committed
cpfp support, integration tests and example
1 parent 8eb08b7 commit ebbebd9

File tree

7 files changed

+580
-4
lines changed

7 files changed

+580
-4
lines changed

Cargo.toml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,9 @@ std = ["miniscript/std"]
2929
[[example]]
3030
name = "synopsis"
3131

32+
[[example]]
33+
name = "cpfp"
34+
3235
[[example]]
3336
name = "common"
3437
crate-type = ["lib"]

examples/common.rs

Lines changed: 87 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
#![allow(dead_code)]
2+
13
use std::sync::Arc;
24

35
use bdk_bitcoind_rpc::{Emitter, NO_EXPECTED_MEMPOOL_TXIDS};
@@ -6,8 +8,13 @@ use bdk_chain::{
68
};
79
use bdk_coin_select::DrainWeights;
810
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};
11+
use bdk_tx::{
12+
CanonicalUnspents, CpfpParams, Input, InputCandidates, RbfParams, ScriptSource, Selection,
13+
TxStatus, TxWithStatus,
14+
};
15+
use bitcoin::{
16+
absolute, Address, Amount, BlockHash, FeeRate, OutPoint, Transaction, TxOut, Txid, Weight,
17+
};
1118
use miniscript::{
1219
plan::{Assets, Plan},
1320
Descriptor, DescriptorPublicKey, ForEachKey,
@@ -84,8 +91,6 @@ impl Wallet {
8491
Ok((tip_height, tip_time))
8592
}
8693

87-
// TODO: Maybe create an `AssetsBuilder` or `AssetsExt` that makes it easier to add
88-
// assets from descriptors, etc.
8994
pub fn assets(&self) -> Assets {
9095
let index = &self.graph.index;
9196
let tip = self.chain.tip().block_id();
@@ -201,4 +206,82 @@ impl Wallet {
201206
rbf_set.selector_rbf_params(),
202207
))
203208
}
209+
210+
pub fn create_cpfp_tx(
211+
&mut self,
212+
parent_txids: impl IntoIterator<Item = Txid>,
213+
target_package_feerate: FeeRate,
214+
) -> anyhow::Result<Selection> {
215+
let parent_txids: Vec<Txid> = parent_txids.into_iter().collect();
216+
217+
// Check for empty parent_txids
218+
if parent_txids.is_empty() {
219+
return Err(anyhow::anyhow!("No parent transactions provided"));
220+
}
221+
222+
let assets = self.assets();
223+
let canon_utxos = CanonicalUnspents::new(self.canonical_txs());
224+
let graph = self.graph.graph();
225+
226+
let ownership_check =
227+
|outpoint: OutPoint| -> bool { self.graph.index.txout(outpoint).is_some() };
228+
229+
// Collect inputs and calculate package fee and weight
230+
let mut inputs = Vec::new();
231+
let mut package_fee = Amount::ZERO;
232+
let mut package_weight = Weight::ZERO;
233+
234+
for txid in parent_txids {
235+
let tx = canon_utxos
236+
.get_tx(&txid)
237+
.ok_or_else(|| anyhow::anyhow!("parent transaction {} not found", txid))?;
238+
239+
if canon_utxos.get_status(&txid).is_none() {
240+
package_fee += graph.calculate_fee(tx)?;
241+
package_weight += tx.weight();
242+
}
243+
244+
let mut found = false;
245+
246+
for (vout, _) in tx.output.iter().enumerate() {
247+
let outpoint = OutPoint::new(txid, vout as u32);
248+
249+
if canon_utxos.is_unspent(outpoint) && ownership_check(outpoint) {
250+
let plan = self
251+
.plan_of_output(outpoint, &assets)
252+
.ok_or_else(|| anyhow::anyhow!("no plan for outpoint {}", outpoint))?;
253+
let input = canon_utxos.try_get_unspent(outpoint, plan).ok_or_else(|| {
254+
anyhow::anyhow!("failed to get input for outpoint {}", outpoint)
255+
})?;
256+
inputs.push(input);
257+
found = true;
258+
break;
259+
}
260+
}
261+
262+
if !found {
263+
return Err(anyhow::anyhow!(
264+
"no owned unspent output found for txid {}",
265+
txid
266+
));
267+
}
268+
}
269+
270+
let script_pubkey = self
271+
.next_address()
272+
.ok_or_else(|| anyhow::anyhow!("failed to get next address"))?
273+
.script_pubkey();
274+
let output_script = ScriptSource::from_script(script_pubkey);
275+
276+
let cpfp_params = CpfpParams {
277+
package_fee,
278+
package_weight,
279+
inputs,
280+
target_package_feerate,
281+
output_script,
282+
};
283+
284+
let selection = cpfp_params.into_selection()?;
285+
Ok(selection)
286+
}
204287
}

examples/cpfp.rs

Lines changed: 167 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,167 @@
1+
#![allow(dead_code)]
2+
3+
use bdk_testenv::{bitcoincore_rpc::RpcApi, TestEnv};
4+
use bdk_tx::{
5+
filter_unspendable_now, group_by_spk, selection_algorithm_lowest_fee_bnb, ChangePolicyType,
6+
Output, PsbtParams, ScriptSource, SelectorParams, Signer,
7+
};
8+
use bitcoin::{absolute::LockTime, key::Secp256k1, Amount, FeeRate, Sequence, Transaction};
9+
use miniscript::Descriptor;
10+
11+
mod common;
12+
use common::Wallet;
13+
14+
fn main() -> anyhow::Result<()> {
15+
let secp = Secp256k1::new();
16+
let (external, external_keymap) =
17+
Descriptor::parse_descriptor(&secp, bdk_testenv::utils::DESCRIPTORS[3])?;
18+
let (internal, internal_keymap) =
19+
Descriptor::parse_descriptor(&secp, bdk_testenv::utils::DESCRIPTORS[4])?;
20+
21+
let signer = Signer(external_keymap.into_iter().chain(internal_keymap).collect());
22+
23+
let env = TestEnv::new()?;
24+
let genesis_hash = env.genesis_hash()?;
25+
env.mine_blocks(101, None)?;
26+
27+
let mut wallet = Wallet::new(genesis_hash, external, internal.clone())?;
28+
wallet.sync(&env)?;
29+
30+
let addr = wallet.next_address().expect("must derive address");
31+
println!("Wallet address: {addr}");
32+
33+
// Fund the wallet with two transactions
34+
env.send(&addr, Amount::from_sat(100_000_000))?;
35+
env.send(&addr, Amount::from_sat(100_000_000))?;
36+
env.mine_blocks(1, None)?;
37+
wallet.sync(&env)?;
38+
println!("Balance: {}", wallet.balance());
39+
40+
// Create two low-fee parent transactions
41+
let (tip_height, tip_time) = wallet.tip_info(env.rpc_client())?;
42+
let mut parent_txids = vec![];
43+
for i in 0..4 {
44+
let low_fee_selection = wallet
45+
.all_candidates()
46+
.regroup(group_by_spk())
47+
.filter(filter_unspendable_now(tip_height, tip_time))
48+
.into_selection(
49+
selection_algorithm_lowest_fee_bnb(FeeRate::from_sat_per_vb_unchecked(1), 100_000),
50+
SelectorParams::new(
51+
FeeRate::from_sat_per_vb_unchecked(1),
52+
vec![Output::with_script(
53+
addr.script_pubkey(),
54+
Amount::from_sat(49_000_000),
55+
)],
56+
ScriptSource::Descriptor(Box::new(internal.at_derivation_index(i)?)),
57+
ChangePolicyType::NoDustAndLeastWaste {
58+
longterm_feerate: FeeRate::from_sat_per_vb_unchecked(1),
59+
},
60+
wallet.change_weight(),
61+
),
62+
)?;
63+
let mut parent_psbt = low_fee_selection.create_psbt(PsbtParams {
64+
fallback_sequence: Sequence::MAX,
65+
..Default::default()
66+
})?;
67+
let parent_finalizer = low_fee_selection.into_finalizer();
68+
parent_psbt.sign(&signer, &secp).expect("failed to sign");
69+
assert!(parent_finalizer.finalize(&mut parent_psbt).is_finalized());
70+
let parent_tx = parent_psbt.extract_tx()?;
71+
let parent_txid = env.rpc_client().send_raw_transaction(&parent_tx)?;
72+
println!("Parent tx {} broadcasted: {}", i + 1, parent_txid);
73+
parent_txids.push(parent_txid);
74+
wallet.sync(&env)?;
75+
}
76+
println!("Balance after parent txs: {}", wallet.balance());
77+
78+
// Verify parent transactions are in mempool
79+
let mempool = env.rpc_client().get_raw_mempool()?;
80+
for (i, txid) in parent_txids.iter().enumerate() {
81+
if mempool.contains(txid) {
82+
println!("Parent TX {} {} is in mempool", i + 1, txid);
83+
} else {
84+
println!("Parent TX {} {} is NOT in mempool", i + 1, txid);
85+
}
86+
}
87+
88+
// Create CPFP transaction to boost both parents
89+
let cpfp_selection = wallet.create_cpfp_tx(
90+
parent_txids.clone(),
91+
FeeRate::from_sat_per_vb_unchecked(10), // user specified
92+
)?;
93+
94+
let mut cpfp_psbt = cpfp_selection.create_psbt(PsbtParams {
95+
fallback_sequence: Sequence::MAX,
96+
fallback_locktime: LockTime::ZERO,
97+
..Default::default()
98+
})?;
99+
let cpfp_finalizer = cpfp_selection.into_finalizer();
100+
cpfp_psbt.sign(&signer, &secp).expect("failed to sign");
101+
assert!(cpfp_finalizer.finalize(&mut cpfp_psbt).is_finalized());
102+
let cpfp_tx = cpfp_psbt.extract_tx()?;
103+
let cpfp_txid = env.rpc_client().send_raw_transaction(&cpfp_tx)?;
104+
105+
wallet.sync(&env)?;
106+
println!("Balance after CPFP: {}", wallet.balance());
107+
108+
// Verify all transactions are in mempool
109+
let mempool = env.rpc_client().get_raw_mempool()?;
110+
println!("\nChecking transactions in mempool:");
111+
for (i, txid) in parent_txids.iter().enumerate() {
112+
if mempool.contains(txid) {
113+
println!("Parent TX {} {} is in mempool", i + 1, txid);
114+
} else {
115+
println!("Parent TX {} {} is NOT in mempool", i + 1, txid);
116+
}
117+
}
118+
if mempool.contains(&cpfp_txid) {
119+
println!("CPFP TX {cpfp_txid} is in mempool");
120+
} else {
121+
println!("CPFP TX {cpfp_txid} is NOT in mempool");
122+
}
123+
124+
// Verify child spends parents
125+
for (i, parent_txid) in parent_txids.iter().enumerate() {
126+
let parent_tx = env.rpc_client().get_raw_transaction(parent_txid, None)?;
127+
if child_spends_parent(&parent_tx, &cpfp_tx) {
128+
println!("CPFP transaction spends an output of parent {}.", i + 1);
129+
} else {
130+
println!(
131+
"CPFP transaction does NOT spend outputs of parent {}.",
132+
i + 1
133+
);
134+
}
135+
}
136+
137+
println!("\n=== MINING BLOCK TO CONFIRM TRANSACTIONS ===");
138+
let block_hashes = env.mine_blocks(1, None)?; // Revert to None, rely on mempool
139+
println!("Mined block: {}", block_hashes[0]);
140+
wallet.sync(&env)?;
141+
142+
println!("Final wallet balance: {}", wallet.balance());
143+
144+
println!("\nChecking transactions in mempool again:");
145+
let mempool = env.rpc_client().get_raw_mempool()?;
146+
for (i, txid) in parent_txids.iter().enumerate() {
147+
if mempool.contains(txid) {
148+
println!("Parent TX {} {} is in mempool", i + 1, txid);
149+
} else {
150+
println!("Parent TX {} {} is NOT in mempool", i + 1, txid);
151+
}
152+
}
153+
if mempool.contains(&cpfp_txid) {
154+
println!("CPFP TX {cpfp_txid} is in mempool");
155+
} else {
156+
println!("CPFP TX {cpfp_txid} is NOT in mempool");
157+
}
158+
Ok(())
159+
}
160+
161+
fn child_spends_parent(parent_tx: &Transaction, child_tx: &Transaction) -> bool {
162+
let parent_txid = parent_tx.compute_txid();
163+
child_tx
164+
.input
165+
.iter()
166+
.any(|input| input.previous_output.txid == parent_txid)
167+
}

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 a status by its transaction ID.
234+
pub fn get_status(&self, txid: &Txid) -> Option<&TxStatus> {
235+
self.statuses.get(txid)
236+
}
227237
}
228238

229239
/// Canonical unspents error

0 commit comments

Comments
 (0)