Skip to content
Draft
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
7 changes: 6 additions & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,9 @@ unexpected_cfgs = { level = "warn", check-cfg = ['cfg(coverage_nightly)'] }

[dependencies]
miniscript = { version = "12.3.5", default-features = false }
bdk_coin_select = "0.4.0"
# Temporary spike: pinned to temporary branch for ancestor-aware
# CPFP selection. Replace with a released bdk_coin_select version before merging.
bdk_coin_select = { git = "https://github.com/noahjoeris/coin-select", rev = "02aec86db8d2cdd16cb7a22b1354c0ba8442386a" }
rand_core = { version = "0.6.4", default-features = false }
rand = { version = "0.8", optional = true }

Expand All @@ -40,3 +42,6 @@ crate-type = ["lib"]

[[example]]
name = "anti_fee_sniping"

[[example]]
name = "cpfp"
215 changes: 215 additions & 0 deletions examples/cpfp.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,215 @@
#![allow(dead_code)]

use bdk_testenv::{bitcoincore_rpc::RpcApi, TestEnv};
use bdk_tx::{
filter_unspendable, group_by_spk, selection_algorithm_lowest_fee_bnb, CanonicalUnspents,
InputCandidates, Output, PsbtParams, Selection, SelectorParams, Signer,
};
use bitcoin::{key::Secp256k1, Amount, FeeRate, OutPoint, ScriptBuf, Transaction, Txid};
use miniscript::Descriptor;

mod common;

use common::Wallet;

fn feerate_sat_vb(fee: u64, weight: bitcoin::Weight) -> f32 {
fee as f32 / weight.to_vbytes_ceil() as f32
}

fn sign_and_extract_tx(
selection: Selection,
signer: &Signer,
secp: &Secp256k1<bitcoin::secp256k1::All>,
) -> anyhow::Result<Transaction> {
let mut psbt = selection.create_psbt(PsbtParams::default())?;
let finalizer = selection.into_finalizer();
psbt.sign(signer, secp).expect("failed to sign");
assert!(
finalizer.finalize(&mut psbt).is_finalized(),
"must finalize"
);
Ok(psbt.extract_tx()?)
}

struct TxStats {
fee: u64,
weight: bitcoin::Weight,
}

fn finalize_child_tx_stats(
candidates: InputCandidates,
child_recipient: ScriptBuf,
change_script: bdk_tx::ChangeScript,
target_feerate: FeeRate,
signer: &Signer,
secp: &Secp256k1<bitcoin::secp256k1::All>,
) -> anyhow::Result<TxStats> {
let selection = candidates.into_selection(
selection_algorithm_lowest_fee_bnb(
FeeRate::from_sat_per_vb(1).expect("valid fee rate"),
100_000,
),
SelectorParams::new(
target_feerate,
vec![Output::with_script(
child_recipient,
Amount::from_sat(25_000_000),
)],
change_script,
),
)?;
let inputs: u64 = selection
.inputs()
.iter()
.map(|input| input.prev_txout().value.to_sat())
.sum();
let outputs: u64 = selection
.outputs()
.iter()
.map(|output| output.value.to_sat())
.sum();
let fee = inputs - outputs;
let tx = sign_and_extract_tx(selection, signer, secp)?;
Ok(TxStats {
fee,
weight: tx.weight(),
})
}

fn wallet_output_from_parent_tx(wallet: &Wallet, txid: Txid) -> Option<OutPoint> {
wallet
.graph
.index
.outpoints()
.iter()
.map(|(_, outpoint)| *outpoint)
.find(|outpoint| outpoint.txid == txid)
}

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 funding_addr = wallet.next_address().expect("must derive address");
let funding_txid = env.send(&funding_addr, Amount::ONE_BTC)?;
env.mine_blocks(1, None)?;
wallet.sync(&env)?;
println!("Received confirmed funding tx: {funding_txid}");

let (tip_height, tip_mtp) = wallet.tip_info(env.rpc_client())?;
let longterm_feerate = FeeRate::from_sat_per_vb(1).expect("valid fee rate");

let parent_recipient = wallet.next_address().expect("must derive address");
let low_fee_parent = wallet
.all_candidates()
.regroup(group_by_spk())
.filter(filter_unspendable(tip_height, Some(tip_mtp)))
.into_selection(
selection_algorithm_lowest_fee_bnb(longterm_feerate, 100_000),
SelectorParams {
change_longterm_feerate: Some(longterm_feerate),
..SelectorParams::new(
FeeRate::from_sat_per_vb(1).expect("valid fee rate"),
vec![Output::with_script(
parent_recipient.script_pubkey(),
Amount::from_sat(50_000_000),
)],
bdk_tx::ChangeScript::from_descriptor(internal.at_derivation_index(0)?),
)
},
)?;
let low_fee_parent = sign_and_extract_tx(low_fee_parent, &signer, &secp)?;
let parent_fee = wallet
.graph
.graph()
.calculate_fee(&low_fee_parent)?
.to_sat();
let parent_weight = low_fee_parent.weight();
let parent_txid = env.rpc_client().send_raw_transaction(&low_fee_parent)?;
wallet.sync(&env)?;
println!("Broadcast low-fee unconfirmed parent: {parent_txid}");

let child_outpoint = wallet_output_from_parent_tx(&wallet, parent_txid)
.expect("wallet must track an output from the parent");
let assets = wallet.assets();
let child_plan = wallet
.plan_of_output(child_outpoint, &assets)
.expect("wallet must plan child input");
let canonical_unspents = CanonicalUnspents::new(wallet.canonical_txs());

let child_recipient = env
.rpc_client()
.get_new_address(None, None)?
.assume_checked()
.script_pubkey();
let target_feerate = FeeRate::from_sat_per_vb(50).expect("valid fee rate");

let child_input = || {
canonical_unspents
.try_get_unspent(child_outpoint, child_plan.clone())
.expect("wallet output must be spendable")
};
let without_ancestors = finalize_child_tx_stats(
InputCandidates::new([child_input()], []),
child_recipient.clone(),
bdk_tx::ChangeScript::from_descriptor(internal.at_derivation_index(1)?),
target_feerate,
&signer,
&secp,
)?;
let with_ancestors = finalize_child_tx_stats(
InputCandidates::new([child_input()], [])
.with_unconfirmed_ancestors(&canonical_unspents)?,
child_recipient,
bdk_tx::ChangeScript::from_descriptor(internal.at_derivation_index(2)?),
target_feerate,
&signer,
&secp,
)?;

let child_bump = with_ancestors.fee - without_ancestors.fee;
let parent_target_fee = target_feerate
.fee_wu(parent_weight)
.expect("fee fits")
.to_sat();
let expected_bump = parent_target_fee.saturating_sub(parent_fee);
let package_fee = parent_fee + with_ancestors.fee;
let package_weight = parent_weight + with_ancestors.weight;

println!("parent fee: {parent_fee} sat");
println!("parent target fee: {parent_target_fee} sat");
println!(
"parent feerate: {:.2} sat/vB",
feerate_sat_vb(parent_fee, parent_weight)
);
println!(
"package feerate: {:.2} sat/vB",
feerate_sat_vb(package_fee, package_weight)
);
println!(
"target feerate: {} sat/vB",
target_feerate.to_sat_per_vb_ceil()
);
println!("child fee without CPFP: {} sat", without_ancestors.fee);
println!("child fee with CPFP: {} sat", with_ancestors.fee);
println!("child bump: {child_bump} sat");
println!("expected CPFP bump: {expected_bump} sat");

assert_eq!(
child_bump, expected_bump,
"child should pay exactly the missing parent fee"
);

Ok(())
}
35 changes: 35 additions & 0 deletions src/ancestor.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
use core::fmt;

use miniscript::bitcoin::{OutPoint, Txid};

/// Intrinsic fee data for an unconfirmed ancestor transaction.
#[derive(Debug, Clone, Copy)]
pub(crate) struct AncestorFee {
pub(crate) weight: u64,
pub(crate) fee_paid: u64,
}

/// Error computing the unconfirmed-ancestor package used for CPFP bump-fee calculation.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum AncestorFeeError {
/// An unconfirmed ancestor transaction is absent from the canonical view.
MissingTx(Txid),
/// A previous output required to compute an ancestor's fee is absent from the canonical view.
MissingPrevout(OutPoint),
}

impl fmt::Display for AncestorFeeError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::MissingTx(txid) => {
write!(f, "unconfirmed ancestor transaction not found: {txid}")
}
Self::MissingPrevout(op) => {
write!(f, "previous output not found for ancestor fee: {op}")
}
}
}
}

#[cfg(feature = "std")]
impl std::error::Error for AncestorFeeError {}
Loading