Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,9 @@ std = ["miniscript/std"]
[[example]]
name = "synopsis"

[[example]]
name = "cpfp"

[[example]]
name = "common"
crate-type = ["lib"]
91 changes: 87 additions & 4 deletions examples/common.rs
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
#![allow(dead_code)]

use std::sync::Arc;

use bdk_bitcoind_rpc::{Emitter, NO_EXPECTED_MEMPOOL_TXIDS};
Expand All @@ -6,8 +8,13 @@ use bdk_chain::{
};
use bdk_coin_select::DrainWeights;
use bdk_testenv::{bitcoincore_rpc::RpcApi, TestEnv};
use bdk_tx::{CanonicalUnspents, Input, InputCandidates, RbfParams, TxStatus, TxWithStatus};
use bitcoin::{absolute, Address, Amount, BlockHash, OutPoint, Transaction, TxOut, Txid};
use bdk_tx::{
CanonicalUnspents, CpfpParams, Input, InputCandidates, RbfParams, ScriptSource, Selection,
TxStatus, TxWithStatus,
};
use bitcoin::{
absolute, Address, Amount, BlockHash, FeeRate, OutPoint, Transaction, TxOut, Txid, Weight,
};
use miniscript::{
plan::{Assets, Plan},
Descriptor, DescriptorPublicKey, ForEachKey,
Expand Down Expand Up @@ -84,8 +91,6 @@ impl Wallet {
Ok((tip_height, tip_time))
}

// TODO: Maybe create an `AssetsBuilder` or `AssetsExt` that makes it easier to add
// assets from descriptors, etc.
pub fn assets(&self) -> Assets {
let index = &self.graph.index;
let tip = self.chain.tip().block_id();
Expand Down Expand Up @@ -201,4 +206,82 @@ impl Wallet {
rbf_set.selector_rbf_params(),
))
}

pub fn create_cpfp_tx(
&mut self,
parent_txids: impl IntoIterator<Item = Txid>,
target_package_feerate: FeeRate,
) -> anyhow::Result<Selection> {
let parent_txids: Vec<Txid> = parent_txids.into_iter().collect();

// Check for empty parent_txids
if parent_txids.is_empty() {
return Err(anyhow::anyhow!("No parent transactions provided"));
}

let assets = self.assets();
let canon_utxos = CanonicalUnspents::new(self.canonical_txs());
let graph = self.graph.graph();

let ownership_check =
|outpoint: OutPoint| -> bool { self.graph.index.txout(outpoint).is_some() };

// Collect inputs and calculate package fee and weight
let mut inputs = Vec::new();
let mut package_fee = Amount::ZERO;
let mut package_weight = Weight::ZERO;

for txid in parent_txids {
let tx = canon_utxos
.get_tx(&txid)
.ok_or_else(|| anyhow::anyhow!("parent transaction {} not found", txid))?;

if canon_utxos.get_status(&txid).is_none() {
package_fee += graph.calculate_fee(tx)?;
package_weight += tx.weight();
}

let mut found = false;

for (vout, _) in tx.output.iter().enumerate() {
let outpoint = OutPoint::new(txid, vout as u32);

if canon_utxos.is_unspent(outpoint) && ownership_check(outpoint) {
let plan = self
.plan_of_output(outpoint, &assets)
.ok_or_else(|| anyhow::anyhow!("no plan for outpoint {}", outpoint))?;
let input = canon_utxos.try_get_unspent(outpoint, plan).ok_or_else(|| {
anyhow::anyhow!("failed to get input for outpoint {}", outpoint)
})?;
inputs.push(input);
found = true;
break;
}
}

if !found {
return Err(anyhow::anyhow!(
"no owned unspent output found for txid {}",
txid
));
}
}

let script_pubkey = self
.next_address()
.ok_or_else(|| anyhow::anyhow!("failed to get next address"))?
.script_pubkey();
let output_script = ScriptSource::from_script(script_pubkey);

let cpfp_params = CpfpParams {
package_fee,
package_weight,
inputs,
target_package_feerate,
output_script,
};

let selection = cpfp_params.into_selection()?;
Ok(selection)
}
}
167 changes: 167 additions & 0 deletions examples/cpfp.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,167 @@
#![allow(dead_code)]

use bdk_testenv::{bitcoincore_rpc::RpcApi, TestEnv};
use bdk_tx::{
filter_unspendable_now, group_by_spk, selection_algorithm_lowest_fee_bnb, ChangePolicyType,
Output, PsbtParams, ScriptSource, SelectorParams, Signer,
};
use bitcoin::{absolute::LockTime, key::Secp256k1, Amount, FeeRate, Sequence, Transaction};
use miniscript::Descriptor;

mod common;
use common::Wallet;

fn main() -> anyhow::Result<()> {
let secp = Secp256k1::new();
let (external, external_keymap) =
Descriptor::parse_descriptor(&secp, bdk_testenv::utils::DESCRIPTORS[3])?;
let (internal, internal_keymap) =
Descriptor::parse_descriptor(&secp, bdk_testenv::utils::DESCRIPTORS[4])?;

let signer = Signer(external_keymap.into_iter().chain(internal_keymap).collect());

let env = TestEnv::new()?;
let genesis_hash = env.genesis_hash()?;
env.mine_blocks(101, None)?;

let mut wallet = Wallet::new(genesis_hash, external, internal.clone())?;
wallet.sync(&env)?;

let addr = wallet.next_address().expect("must derive address");
println!("Wallet address: {addr}");

// Fund the wallet with two transactions
env.send(&addr, Amount::from_sat(100_000_000))?;
env.send(&addr, Amount::from_sat(100_000_000))?;
env.mine_blocks(1, None)?;
wallet.sync(&env)?;
println!("Balance: {}", wallet.balance());

// Create two low-fee parent transactions
let (tip_height, tip_time) = wallet.tip_info(env.rpc_client())?;
let mut parent_txids = vec![];
for i in 0..4 {
let low_fee_selection = wallet
.all_candidates()
.regroup(group_by_spk())
.filter(filter_unspendable_now(tip_height, tip_time))
.into_selection(
selection_algorithm_lowest_fee_bnb(FeeRate::from_sat_per_vb_unchecked(1), 100_000),
SelectorParams::new(
FeeRate::from_sat_per_vb_unchecked(1),
vec![Output::with_script(
addr.script_pubkey(),
Amount::from_sat(49_000_000),
)],
ScriptSource::Descriptor(Box::new(internal.at_derivation_index(i)?)),
ChangePolicyType::NoDustAndLeastWaste {
longterm_feerate: FeeRate::from_sat_per_vb_unchecked(1),
},
wallet.change_weight(),
),
)?;
let mut parent_psbt = low_fee_selection.create_psbt(PsbtParams {
fallback_sequence: Sequence::MAX,
..Default::default()
})?;
let parent_finalizer = low_fee_selection.into_finalizer();
parent_psbt.sign(&signer, &secp).expect("failed to sign");
assert!(parent_finalizer.finalize(&mut parent_psbt).is_finalized());
let parent_tx = parent_psbt.extract_tx()?;
let parent_txid = env.rpc_client().send_raw_transaction(&parent_tx)?;
println!("Parent tx {} broadcasted: {}", i + 1, parent_txid);
parent_txids.push(parent_txid);
wallet.sync(&env)?;
}
println!("Balance after parent txs: {}", wallet.balance());

// Verify parent transactions are in mempool
let mempool = env.rpc_client().get_raw_mempool()?;
for (i, txid) in parent_txids.iter().enumerate() {
if mempool.contains(txid) {
println!("Parent TX {} {} is in mempool", i + 1, txid);
} else {
println!("Parent TX {} {} is NOT in mempool", i + 1, txid);
}
}

// Create CPFP transaction to boost both parents
let cpfp_selection = wallet.create_cpfp_tx(
parent_txids.clone(),
FeeRate::from_sat_per_vb_unchecked(10), // user specified
)?;

let mut cpfp_psbt = cpfp_selection.create_psbt(PsbtParams {
fallback_sequence: Sequence::MAX,
fallback_locktime: LockTime::ZERO,
..Default::default()
})?;
let cpfp_finalizer = cpfp_selection.into_finalizer();
cpfp_psbt.sign(&signer, &secp).expect("failed to sign");
assert!(cpfp_finalizer.finalize(&mut cpfp_psbt).is_finalized());
let cpfp_tx = cpfp_psbt.extract_tx()?;
let cpfp_txid = env.rpc_client().send_raw_transaction(&cpfp_tx)?;

wallet.sync(&env)?;
println!("Balance after CPFP: {}", wallet.balance());

// Verify all transactions are in mempool
let mempool = env.rpc_client().get_raw_mempool()?;
println!("\nChecking transactions in mempool:");
for (i, txid) in parent_txids.iter().enumerate() {
if mempool.contains(txid) {
println!("Parent TX {} {} is in mempool", i + 1, txid);
} else {
println!("Parent TX {} {} is NOT in mempool", i + 1, txid);
}
}
if mempool.contains(&cpfp_txid) {
println!("CPFP TX {cpfp_txid} is in mempool");
} else {
println!("CPFP TX {cpfp_txid} is NOT in mempool");
}

// Verify child spends parents
for (i, parent_txid) in parent_txids.iter().enumerate() {
let parent_tx = env.rpc_client().get_raw_transaction(parent_txid, None)?;
if child_spends_parent(&parent_tx, &cpfp_tx) {
println!("CPFP transaction spends an output of parent {}.", i + 1);
} else {
println!(
"CPFP transaction does NOT spend outputs of parent {}.",
i + 1
);
}
}

println!("\n=== MINING BLOCK TO CONFIRM TRANSACTIONS ===");
let block_hashes = env.mine_blocks(1, None)?; // Revert to None, rely on mempool
println!("Mined block: {}", block_hashes[0]);
wallet.sync(&env)?;

println!("Final wallet balance: {}", wallet.balance());

println!("\nChecking transactions in mempool again:");
let mempool = env.rpc_client().get_raw_mempool()?;
for (i, txid) in parent_txids.iter().enumerate() {
if mempool.contains(txid) {
println!("Parent TX {} {} is in mempool", i + 1, txid);
} else {
println!("Parent TX {} {} is NOT in mempool", i + 1, txid);
}
}
if mempool.contains(&cpfp_txid) {
println!("CPFP TX {cpfp_txid} is in mempool");
} else {
println!("CPFP TX {cpfp_txid} is NOT in mempool");
}
Ok(())
}

fn child_spends_parent(parent_tx: &Transaction, child_tx: &Transaction) -> bool {
let parent_txid = parent_tx.compute_txid();
child_tx
.input
.iter()
.any(|input| input.previous_output.txid == parent_txid)
}
10 changes: 10 additions & 0 deletions src/canonical_unspents.rs
Original file line number Diff line number Diff line change
Expand Up @@ -224,6 +224,16 @@ impl CanonicalUnspents {
self.try_get_foreign_unspent(op, seq, input, sat_wu, is_coinbase)
})
}

/// Retrieves a transaction by its transaction ID.
pub fn get_tx(&self, txid: &Txid) -> Option<&Arc<Transaction>> {
self.txs.get(txid)
}

/// Retrieves a status by its transaction ID.
pub fn get_status(&self, txid: &Txid) -> Option<&TxStatus> {
self.statuses.get(txid)
}
}

/// Canonical unspents error
Expand Down
Loading