diff --git a/Cargo.toml b/Cargo.toml index 8693aaf..b546bfc 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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 } @@ -40,3 +42,6 @@ crate-type = ["lib"] [[example]] name = "anti_fee_sniping" + +[[example]] +name = "cpfp" diff --git a/examples/cpfp.rs b/examples/cpfp.rs new file mode 100644 index 0000000..b647f03 --- /dev/null +++ b/examples/cpfp.rs @@ -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, +) -> anyhow::Result { + 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, +) -> anyhow::Result { + 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 { + 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(()) +} diff --git a/src/ancestor.rs b/src/ancestor.rs new file mode 100644 index 0000000..a31b83a --- /dev/null +++ b/src/ancestor.rs @@ -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 {} diff --git a/src/canonical_unspents.rs b/src/canonical_unspents.rs index f6b96fc..1bb85bd 100644 --- a/src/canonical_unspents.rs +++ b/src/canonical_unspents.rs @@ -6,6 +6,7 @@ use bitcoin::{absolute, psbt, Amount, OutPoint, Sequence, Transaction, TxOut, Tx use miniscript::{bitcoin, plan::Plan}; use crate::{ + ancestor::{AncestorFee, AncestorFeeError}, collections::{HashMap, HashSet}, input::CoinbaseMismatch, ConfirmationStatus, FromPsbtInputError, Input, RbfSet, @@ -158,6 +159,78 @@ impl CanonicalUnspents { ) } + /// Compute unconfirmed ancestor fee data for `txid`. + /// + /// Walks backward through unconfirmed parents until confirmed transactions, whose output + /// values are still used to compute child fees. + /// + /// # Errors + /// + /// - [`AncestorFeeError::MissingTx`] if an unconfirmed ancestor's transaction is absent. + /// - [`AncestorFeeError::MissingPrevout`] if a prevout needed to compute an ancestor's fee is + /// absent. + /// + /// # Panics + /// + /// If input/output sums overflow, or if outputs exceed inputs. This indicates an + /// inconsistent canonical view. + pub(crate) fn unconfirmed_ancestors( + &self, + txid: Txid, + ) -> Result, AncestorFeeError> { + let mut ancestors = Vec::new(); + let mut visited = HashSet::::new(); + let mut to_visit = Vec::new(); + to_visit.push(txid); + + while let Some(txid) = to_visit.pop() { + if !visited.insert(txid) { + continue; + } + + // Check confirmation. + if self.statuses.contains_key(&txid) { + continue; + } + // Check ancestor tx availability. + let tx = self + .txs + .get(&txid) + .ok_or(AncestorFeeError::MissingTx(txid))?; + + let input_sum = tx.input.iter().try_fold(0u64, |sum, txin| { + let op = txin.previous_output; + let prevout = self + .txs + .get(&op.txid) + .and_then(|prev_tx| prev_tx.output.get(op.vout as usize)) + .ok_or(AncestorFeeError::MissingPrevout(op))?; + to_visit.push(op.txid); + Ok(sum + .checked_add(prevout.value.to_sat()) + .expect("ancestor fee invariant: input sum overflow")) + })?; + let output_sum = tx.output.iter().fold(0u64, |sum, txout| { + sum.checked_add(txout.value.to_sat()) + .expect("ancestor fee invariant: output sum overflow") + }); + let fee_paid = input_sum.checked_sub(output_sum).unwrap_or_else(|| { + panic!( + "ancestor fee invariant violated for {txid}: canonical graph inconsistent \ + with transaction (outputs exceed inputs)" + ); + }); + ancestors.push(( + txid, + AncestorFee { + weight: tx.weight().to_wu(), + fee_paid, + }, + )); + } + Ok(ancestors) + } + /// Whether outpoint is a leaf (unspent). pub fn is_unspent(&self, outpoint: OutPoint) -> bool { if self.spends.contains_key(&outpoint) { @@ -418,4 +491,109 @@ mod tests { assert!(!txids.contains(&child_txid)); assert_eq!(rbf_set.selector_rbf_params().to_cs_replace().fee, 3_000); } + + fn confirmed(height: u32) -> ConfirmationStatus { + ConfirmationStatus::new(height, None).expect("valid height") + } + + #[test] + fn test_unconfirmed_ancestors_single_unconfirmed_parent() { + // confirmed grandparent (value source) -> unconfirmed parent paying 10_000 sats fee. + let grandparent = funding_tx(&[100_000]); + let parent = tx_spending(&[prevout(&grandparent, 0)], &[90_000]); + let parent_txid = parent.compute_txid(); + let parent_weight = parent.weight().to_wu(); + let graph = + CanonicalUnspents::new(vec![(grandparent, Some(confirmed(100))), (parent, None)]); + + let ancestors = graph + .unconfirmed_ancestors(parent_txid) + .expect("ancestors should resolve"); + assert_eq!( + ancestors + .into_iter() + .map(|(txid, ancestor)| (txid, ancestor.weight, ancestor.fee_paid)) + .collect::>(), + vec![(parent_txid, parent_weight, 10_000)] + ); + } + + #[test] + /// scenario: confirmed great-grandparent -> unconfirmed grandparent -> unconfirmed parent. + fn test_unconfirmed_ancestors_walks_transitively() { + let great_grandparent = funding_tx(&[100_000]); + let grandparent = tx_spending(&[prevout(&great_grandparent, 0)], &[95_000]); // fee 5_000 + let parent = tx_spending(&[prevout(&grandparent, 0)], &[90_000]); // fee 5_000 + let grandparent_txid = grandparent.compute_txid(); + let parent_txid = parent.compute_txid(); + let grandparent_weight = grandparent.weight().to_wu(); + let parent_weight = parent.weight().to_wu(); + let graph = CanonicalUnspents::new(vec![ + (great_grandparent, Some(confirmed(100))), + (grandparent, None), + (parent, None), + ]); + + let mut ancestors = graph + .unconfirmed_ancestors(parent_txid) + .unwrap() + .into_iter() + .map(|(txid, ancestor)| (txid, ancestor.weight, ancestor.fee_paid)) + .collect::>(); + ancestors.sort(); + let mut expected = vec![ + (parent_txid, parent_weight, 5_000), + (grandparent_txid, grandparent_weight, 5_000), + ]; + expected.sort(); + assert_eq!(ancestors, expected); + } + + #[test] + fn test_unconfirmed_ancestors_returns_empty_for_confirmed_tx() { + let funding = funding_tx(&[100_000]); + let funding_txid = funding.compute_txid(); + let graph = CanonicalUnspents::new(vec![(funding, Some(confirmed(100)))]); + assert!(graph + .unconfirmed_ancestors(funding_txid) + .unwrap() + .is_empty()); + } + + #[test] + fn test_unconfirmed_ancestors_missing_prevout_errors() { + // The grandparent (whose value is needed to compute the parent's fee) is absent. + let grandparent = funding_tx(&[100_000]); + let parent = tx_spending(&[prevout(&grandparent, 0)], &[90_000]); + let parent_txid = parent.compute_txid(); + let missing = OutPoint::new(grandparent.compute_txid(), 0); + let graph = CanonicalUnspents::new(vec![(parent, None)]); + assert_eq!( + graph.unconfirmed_ancestors(parent_txid).unwrap_err(), + AncestorFeeError::MissingPrevout(missing), + ); + } + + #[test] + #[should_panic(expected = "ancestor fee invariant violated")] + fn test_unconfirmed_ancestors_inconsistent_graph_panics() { + // The parent's outputs exceed its inputs, indicating an inconsistent canonical view. + let grandparent = funding_tx(&[50_000]); + let parent = tx_spending(&[prevout(&grandparent, 0)], &[60_000]); + let parent_txid = parent.compute_txid(); + let graph = + CanonicalUnspents::new(vec![(grandparent, Some(confirmed(100))), (parent, None)]); + let _ = graph.unconfirmed_ancestors(parent_txid); + } + + #[test] + fn test_unconfirmed_ancestors_missing_tx_errors() { + let present = funding_tx(&[1_000]); + let graph = CanonicalUnspents::new(vec![(present, Some(confirmed(1)))]); + let phantom_txid = funding_tx(&[999]).compute_txid(); + assert_eq!( + graph.unconfirmed_ancestors(phantom_txid).unwrap_err(), + AncestorFeeError::MissingTx(phantom_txid), + ); + } } diff --git a/src/input_candidates.rs b/src/input_candidates.rs index 131f6af..f686592 100644 --- a/src/input_candidates.rs +++ b/src/input_candidates.rs @@ -1,16 +1,34 @@ use alloc::{vec, vec::Vec}; use core::fmt; -use bdk_coin_select::{metrics::LowestFee, Candidate, NoBnbSolution}; -use bitcoin::{absolute, FeeRate, OutPoint}; +use bdk_coin_select::{metrics::LowestFee, Candidate, NoBnbSolution, UnconfirmedAncestor}; +use bitcoin::{absolute, FeeRate, OutPoint, Txid}; use miniscript::bitcoin; -use crate::collections::{BTreeMap, HashSet}; +use crate::ancestor::AncestorFee; +use crate::collections::{BTreeMap, BTreeSet, HashSet}; use crate::{ - CannotMeetTarget, FeeRateExt, Input, InputGroup, Selection, Selector, SelectorError, - SelectorParams, + AncestorFeeError, CannotMeetTarget, CanonicalUnspents, FeeRateExt, Input, InputGroup, + Selection, Selector, SelectorError, SelectorParams, }; +#[derive(Debug, Clone)] +struct InputCandidateAncestor { + weight: u64, + fee_paid: u64, + dependent_outpoints: BTreeSet, +} + +impl From for InputCandidateAncestor { + fn from(fee: AncestorFee) -> Self { + Self { + weight: fee.weight, + fee_paid: fee.fee_paid, + dependent_outpoints: BTreeSet::new(), + } + } +} + /// Input candidates. #[must_use] #[derive(Debug, Clone)] @@ -23,6 +41,10 @@ pub struct InputCandidates { cs_candidates: Vec, /// Cached outpoints used for deduplication and O(1) membership checks. contains: HashSet, + /// Source-of-truth CPFP ancestor data keyed by dependent [`OutPoint`]s. + ancestors: Vec, + /// Cached coin-select ancestor metadata with indices into [`Self::cs_candidates`]. + cs_ancestors: Vec, } impl InputCandidates { @@ -51,6 +73,8 @@ impl InputCandidates { can_select, cs_candidates, contains, + ancestors: Vec::new(), + cs_ancestors: Vec::new(), } } @@ -70,6 +94,45 @@ impl InputCandidates { .collect() } + fn build_cs_ancestors( + ancestors: &[InputCandidateAncestor], + must_select: &Option, + can_select: &[InputGroup], + ) -> Vec { + ancestors + .iter() + .filter_map(|ancestor| { + let mut dependent_candidates = Vec::new(); + for (candidate_index, group) in must_select.iter().chain(can_select).enumerate() { + let group_depends_on_ancestor = group.inputs().iter().any(|input| { + ancestor + .dependent_outpoints + .contains(&input.prev_outpoint()) + }); + if group_depends_on_ancestor { + dependent_candidates.push(candidate_index); + } + } + + if dependent_candidates.is_empty() { + None + } else { + Some(UnconfirmedAncestor { + weight: ancestor.weight, + fee_paid: ancestor.fee_paid, + dependent_candidates, + }) + } + }) + .collect() + } + + fn rebuild_coin_select_cache(&mut self) { + self.cs_candidates = Self::build_cs_candidates(&self.must_select, &self.can_select); + self.cs_ancestors = + Self::build_cs_ancestors(&self.ancestors, &self.must_select, &self.can_select); + } + /// Iterate over all contained inputs of all groups. pub fn inputs(&self) -> impl Iterator + '_ { self.groups().flat_map(InputGroup::inputs) @@ -105,6 +168,69 @@ impl InputCandidates { &self.cs_candidates } + /// Shared CPFP ancestor table for `bdk_coin_select`. + pub(crate) fn coin_select_ancestors(&self) -> &[UnconfirmedAncestor] { + &self.cs_ancestors + } + + /// Attach CPFP ancestor data for these inputs. + /// + /// Use this only when unconfirmed inputs should bump fees for their ancestors. + /// Otherwise, selection targets only the child transaction fee. + /// + /// Confirmed inputs are skipped. Repeated calls replace prior ancestor metadata. + /// + /// # Errors + /// + /// Returns [`AncestorFeeError`] if `graph` lacks required unconfirmed-ancestor data. + /// + /// # Panics + /// + /// If `graph` is inconsistent with an unconfirmed ancestor transaction. + pub fn with_unconfirmed_ancestors( + self, + graph: &CanonicalUnspents, + ) -> Result { + self.attach_ancestor_data(|outpoint| graph.unconfirmed_ancestors(outpoint.txid)) + } + + /// Record which candidate inputs depend on each ancestor. + fn attach_ancestor_data( + mut self, + mut ancestors_for_input: F, + ) -> Result + where + F: FnMut(OutPoint) -> Result, AncestorFeeError>, + { + let mut ancestors = Vec::::new(); + let mut ancestor_index_by_txid = BTreeMap::::new(); + + for input in self.groups().flat_map(InputGroup::inputs) { + // Confirmed inputs need no CPFP bump. + if input.status().is_some() { + continue; + } + let prev_outpoint = input.prev_outpoint(); + for (ancestor_txid, ancestor_fee) in ancestors_for_input(prev_outpoint)? { + // Deduplicate ancestors into the stable bdk_tx table. + let next_ancestor_index = ancestors.len(); + let ancestor_index = *ancestor_index_by_txid + .entry(ancestor_txid) + .or_insert(next_ancestor_index); + if ancestor_index == next_ancestor_index { + ancestors.push(ancestor_fee.into()); + } + ancestors[ancestor_index] + .dependent_outpoints + .insert(prev_outpoint); + } + } + + self.ancestors = ancestors; + self.rebuild_coin_select_cache(); + Ok(self) + } + /// Whether the outpoint is an input candidate. pub fn contains(&self, outpoint: OutPoint) -> bool { self.contains.contains(&outpoint) @@ -155,15 +281,18 @@ impl InputCandidates { } } - let cs_candidates = Self::build_cs_candidates(&must_select, &can_select); let no_dup = self.contains; - Self { + let mut candidates = Self { must_select, can_select, - cs_candidates, + cs_candidates: Vec::new(), contains: no_dup, - } + ancestors: self.ancestors, + cs_ancestors: Vec::new(), + }; + candidates.rebuild_coin_select_cache(); + candidates } /// Filters out inputs. @@ -187,7 +316,7 @@ impl InputCandidates { for op in to_rm { self.contains.remove(&op); } - self.cs_candidates = Self::build_cs_candidates(&self.must_select, &self.can_select); + self.rebuild_coin_select_cache(); self } @@ -282,3 +411,152 @@ pub fn filter_unspendable( pub fn no_filtering() -> impl Fn(&InputGroup) -> bool { |_| true } + +#[cfg_attr(coverage_nightly, coverage(off))] +#[cfg(test)] +mod tests { + use super::*; + use crate::{CanonicalUnspents, ConfirmationStatus}; + use bitcoin::{ + key::Secp256k1, secp256k1::SecretKey, transaction, Amount, Network, PrivateKey, ScriptBuf, + Transaction, TxIn, TxOut, + }; + use miniscript::{plan::Assets, plan::Plan, Descriptor, DescriptorPublicKey}; + use std::string::ToString; + + /// A single-key `wpkh` descriptor we can both pay to and build a spending [`Plan`] for. + fn spk_and_plan() -> (ScriptBuf, Plan) { + let secp = Secp256k1::new(); + let sk = SecretKey::from_slice(&[2u8; 32]).expect("valid key"); + let pk = PrivateKey::new(sk, Network::Regtest).public_key(&secp); + let desc_pk: DescriptorPublicKey = pk.to_string().parse().expect("valid pk"); + let (descriptor, _) = + Descriptor::parse_descriptor(&secp, &format!("wpkh({pk})")).expect("valid descriptor"); + let definite = descriptor + .at_derivation_index(0) + .expect("definite descriptor"); + let plan = definite + .clone() + .plan(&Assets::new().add(desc_pk)) + .expect("plan"); + (definite.script_pubkey(), plan) + } + + fn tx_with(prev: Option, spk: &ScriptBuf, output_values: &[u64]) -> Transaction { + Transaction { + version: transaction::Version::TWO, + lock_time: absolute::LockTime::ZERO, + input: vec![TxIn { + previous_output: prev.unwrap_or_default(), + ..Default::default() + }], + output: output_values + .iter() + .map(|v| TxOut { + value: Amount::from_sat(*v), + script_pubkey: spk.clone(), + }) + .collect(), + } + } + + fn confirmed(height: u32) -> ConfirmationStatus { + ConfirmationStatus::new(height, None).expect("valid height") + } + + #[test] + fn test_candidates_have_no_ancestors_by_default() { + let (spk, plan) = spk_and_plan(); + let grandparent = tx_with(None, &spk, &[100_000]); + let parent = tx_with( + Some(OutPoint::new(grandparent.compute_txid(), 0)), + &spk, + &[90_000], + ); + let parent_txid = parent.compute_txid(); + let graph = + CanonicalUnspents::new(vec![(grandparent, Some(confirmed(100))), (parent, None)]); + let input = graph + .try_get_unspent(OutPoint::new(parent_txid, 0), plan) + .expect("unspent input"); + + // Without `with_unconfirmed_ancestors`, nothing changes versus pre-CPFP behaviour. + let candidates = InputCandidates::new([], [input]); + assert!(candidates.coin_select_ancestors().is_empty()); + } + + #[test] + fn test_shared_unconfirmed_ancestor_is_deduplicated() { + let (spk, plan) = spk_and_plan(); + let grandparent = tx_with(None, &spk, &[200_000]); + // One unconfirmed parent with two spendable outputs. + let parent = tx_with( + Some(OutPoint::new(grandparent.compute_txid(), 0)), + &spk, + &[90_000, 90_000], + ); + let parent_txid = parent.compute_txid(); + let graph = + CanonicalUnspents::new(vec![(grandparent, Some(confirmed(100))), (parent, None)]); + + let in0 = graph + .try_get_unspent(OutPoint::new(parent_txid, 0), plan.clone()) + .unwrap(); + let in1 = graph + .try_get_unspent(OutPoint::new(parent_txid, 1), plan) + .unwrap(); + let candidates = InputCandidates::new([], [in0, in1]) + .with_unconfirmed_ancestors(&graph) + .expect("ancestors resolve"); + + // Both inputs descend from the same unconfirmed parent: a single shared ancestor entry, + // depended on by both candidate indices. + assert_eq!(candidates.coin_select_ancestors().len(), 1); + assert_eq!( + candidates.coin_select_ancestors()[0].dependent_candidates, + vec![0, 1] + ); + } + + #[test] + fn test_regroup_rebuilds_dependent_candidates_and_skips_confirmed_inputs() { + let (spk, plan) = spk_and_plan(); + // grandparent[0] funds the unconfirmed parent; grandparent[1] is a confirmed UTXO. + let grandparent = tx_with(None, &spk, &[200_000, 50_000]); + let grandparent_txid = grandparent.compute_txid(); + let parent = tx_with( + Some(OutPoint::new(grandparent_txid, 0)), + &spk, + &[90_000, 90_000], + ); + let parent_txid = parent.compute_txid(); + let graph = + CanonicalUnspents::new(vec![(grandparent, Some(confirmed(100))), (parent, None)]); + + let in0 = graph + .try_get_unspent(OutPoint::new(parent_txid, 0), plan.clone()) + .unwrap(); + let in1 = graph + .try_get_unspent(OutPoint::new(parent_txid, 1), plan.clone()) + .unwrap(); + let in_confirmed = graph + .try_get_unspent(OutPoint::new(grandparent_txid, 1), plan) + .unwrap(); + assert!( + in_confirmed.status().is_some(), + "the grandparent output is confirmed" + ); + + // Regrouping must union ancestor indices and skip confirmed inputs. + let candidates = InputCandidates::new([], [in0, in1, in_confirmed]) + .with_unconfirmed_ancestors(&graph) + .expect("ancestors resolve") + .regroup(group_by_spk()); + + assert_eq!(candidates.coin_select_ancestors().len(), 1); + assert_eq!( + candidates.coin_select_ancestors()[0].dependent_candidates, + vec![0] + ); + } +} diff --git a/src/lib.rs b/src/lib.rs index eea52ed..57757ab 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -10,6 +10,7 @@ extern crate alloc; extern crate std; mod afs; +mod ancestor; mod canonical_unspents; mod finalizer; mod input; @@ -22,6 +23,7 @@ mod selector; mod signer; pub use afs::*; +pub use ancestor::*; pub use canonical_unspents::*; pub use finalizer::*; pub use input::*; diff --git a/src/selector.rs b/src/selector.rs index 4c98ec8..24a79a5 100644 --- a/src/selector.rs +++ b/src/selector.rs @@ -273,6 +273,7 @@ impl SelectorParams { Target { fee: TargetFee { rate: self.target_feerate.max(feerate_lb).into_cs_feerate(), + absolute: 0, replace: self.replace.as_ref().map(|r| r.to_cs_replace()), }, outputs: TargetOutputs::fund_outputs( @@ -417,7 +418,8 @@ impl<'c> Selector<'c> { } } - let mut inner = bdk_coin_select::CoinSelector::new(candidates.coin_select_candidates()); + let mut inner = bdk_coin_select::CoinSelector::new(candidates.coin_select_candidates()) + .with_ancestors(candidates.coin_select_ancestors()); if candidates.must_select().is_some() { inner.select_next(); } diff --git a/tests/cpfp.rs b/tests/cpfp.rs new file mode 100644 index 0000000..a72c73a --- /dev/null +++ b/tests/cpfp.rs @@ -0,0 +1,159 @@ +use bdk_tx::bitcoin::{ + absolute, key::Secp256k1, secp256k1::SecretKey, transaction, Amount, FeeRate, Network, + OutPoint, PrivateKey, ScriptBuf, Transaction, TxIn, TxOut, Weight, +}; +use bdk_tx::miniscript::{plan::Assets, plan::Plan, Descriptor, DescriptorPublicKey}; +use bdk_tx::{ + CanonicalUnspents, ChangeScript, ConfirmationStatus, DefiniteDescriptor, InputCandidates, + Output, Selection, Selector, SelectorParams, +}; + +/// A single-key `wpkh` descriptor we can pay to, plus a [`Plan`] to spend it. +fn spk_plan_descriptor() -> (ScriptBuf, Plan, DefiniteDescriptor) { + let secp = Secp256k1::new(); + let sk = SecretKey::from_slice(&[7u8; 32]).expect("valid key"); + let pk = PrivateKey::new(sk, Network::Regtest).public_key(&secp); + let desc_pk: DescriptorPublicKey = pk.to_string().parse().expect("valid pk"); + let (descriptor, _) = + Descriptor::parse_descriptor(&secp, &format!("wpkh({pk})")).expect("valid descriptor"); + let definite = descriptor + .at_derivation_index(0) + .expect("definite descriptor"); + let plan = definite + .clone() + .plan(&Assets::new().add(desc_pk)) + .expect("plan"); + (definite.script_pubkey(), plan, definite) +} + +fn tx_spending(prev: Option, spk: &ScriptBuf, output_values: &[u64]) -> Transaction { + Transaction { + version: transaction::Version::TWO, + lock_time: absolute::LockTime::ZERO, + input: vec![TxIn { + previous_output: prev.unwrap_or_default(), + ..Default::default() + }], + output: output_values + .iter() + .map(|v| TxOut { + value: Amount::from_sat(*v), + script_pubkey: spk.clone(), + }) + .collect(), + } +} + +fn confirmed(height: u32) -> ConfirmationStatus { + ConfirmationStatus::new(height, None).expect("valid height") +} + +fn total_fee(selection: &Selection) -> u64 { + let inputs: u64 = selection + .inputs() + .iter() + .map(|i| i.prev_txout().value.to_sat()) + .sum(); + let outputs: u64 = selection.outputs().iter().map(|o| o.value.to_sat()).sum(); + inputs - outputs +} + +struct LowFeeParent { + /// Canonical view holding the confirmed funding tx and the unconfirmed parent. + graph: CanonicalUnspents, + /// The parent's output that a child will spend. + spendable_outpoint: OutPoint, + /// Fee (sats) the parent already paid, deliberately too low for the target feerate. + fee: u64, + /// Weight of the parent transaction. + weight: Weight, +} + +/// Set up a confirmed funding tx feeding a single low-fee unconfirmed parent. +fn setup_low_fee_parent(spk: &ScriptBuf, spendable: u64, fee: u64) -> LowFeeParent { + let funding = tx_spending(None, spk, &[spendable + fee]); + let parent = tx_spending( + Some(OutPoint::new(funding.compute_txid(), 0)), + spk, + &[spendable], + ); + let weight = parent.weight(); + let spendable_outpoint = OutPoint::new(parent.compute_txid(), 0); + let graph = CanonicalUnspents::new(vec![(funding, Some(confirmed(100))), (parent, None)]); + LowFeeParent { + graph, + spendable_outpoint, + fee, + weight, + } +} + +/// Select the given child `candidates` to meet `target_feerate`, finalize, and return the tx fee. +fn finalize_child_fee( + candidates: InputCandidates, + spk: &ScriptBuf, + change: &DefiniteDescriptor, + target_feerate: FeeRate, +) -> u64 { + let params = SelectorParams::new( + target_feerate, + vec![Output::with_script(spk.clone(), Amount::from_sat(100_000))], + ChangeScript::from_descriptor(change.clone()), + ); + let mut selector = Selector::new(&candidates, params).expect("selector builds"); + selector + .select_until_target_met() + .expect("single 1M input covers the 100k target"); + let selection = selector.try_finalize().expect("target met"); + total_fee(&selection) +} + +#[test] +fn test_cpfp_child_pays_ancestor_bump_fee() { + let (spk, plan, definite) = spk_plan_descriptor(); + let target_feerate = FeeRate::from_sat_per_vb(50).expect("valid feerate"); + + // An unconfirmed parent stuck at a 2_000 sat fee, far below the target feerate. + let parent = setup_low_fee_parent(&spk, 1_000_000, 2_000); + let child_input = || { + parent + .graph + .try_get_unspent(parent.spendable_outpoint, plan.clone()) + .expect("child input") + }; + + // Spend the parent's output twice: once oblivious to ancestors, once ancestor-aware. + let fee_without_ancestors = finalize_child_fee( + InputCandidates::new([child_input()], []), + &spk, + &definite, + target_feerate, + ); + let fee_with_ancestors = finalize_child_fee( + InputCandidates::new([child_input()], []) + .with_unconfirmed_ancestors(&parent.graph) + .expect("ancestors resolve"), + &spk, + &definite, + target_feerate, + ); + + // The ancestor-aware child overpays by exactly the CPFP bump needed to lift the parent to the + // target feerate. + let child_bump = fee_with_ancestors - fee_without_ancestors; + 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); + assert!(expected_bump > 0, "fixture parent must be below target"); + + assert_eq!( + child_bump, + expected_bump, + "child bump should cover the CPFP deficit \ + (parent fee {}, weight {} wu)", + parent.fee, + parent.weight.to_wu(), + ); +}