use std::borrow::BorrowMut;
use std::collections::{HashMap, HashSet};
use std::sync::Arc;
use bdk_wallet::{AddressInfo, TxBuilder, Wallet, WeightedUtxo};
use bdk_wallet::chain::{BlockId, CanonicalizationParams, ChainPosition, ConfirmationBlockTime};
use bdk_wallet::coin_selection::{
decide_change, CoinSelectionAlgorithm, CoinSelectionResult, DefaultCoinSelectionAlgorithm,
InsufficientFunds,
};
use bdk_wallet::error::CreateTxError;
use bitcoin::consensus::encode::{serialize, serialize_hex};
use bitcoin::{
Amount, BlockHash, FeeRate, OutPoint, Script, Transaction, TxOut, Txid, Weight, Witness,
};
use bitcoin::psbt::{ExtractTxError, Input};
use log::{debug, trace};
use rand_core::RngCore;
use crate::TransactionExt;
use crate::cpfp::MakeCpfpFees;
use crate::fee::FEE_ANCHOR_SPEND_WEIGHT;
#[derive(Debug, Clone)]
pub struct LocalTransaction {
pub tx: Arc<Transaction>,
pub chain_position: ChainPosition<ConfirmationBlockTime>,
pub is_trusted: bool,
}
pub struct TrustedUtxo<'a> {
pub outpoint: OutPoint,
pub txout: &'a TxOut,
pub chain_position: &'a ChainPosition<ConfirmationBlockTime>,
pub is_trusted: bool,
}
pub struct TrustedCanonicalization {
txs: HashMap<Txid, LocalTransaction>,
unspent: Vec<OutPoint>,
}
impl TrustedCanonicalization {
pub fn from_wallet(w: &Wallet, min_confs: u32) -> Self {
let tip = w.latest_checkpoint().height();
let chain = w.local_chain();
let chain_tip = w.latest_checkpoint().block_id();
let mut txs: HashMap<Txid, LocalTransaction> = HashMap::new();
let mut spent: HashSet<OutPoint> = HashSet::new();
for ctx in w.tx_graph().list_ordered_canonical_txs(
chain, chain_tip, CanonicalizationParams::default(),
) {
let txid = ctx.tx_node.txid;
let tx = ctx.tx_node.tx.clone();
let chain_position = ctx.chain_position.clone();
for input in tx.input.iter() {
spent.insert(input.previous_output);
}
let nb_confs = match chain_position.confirmation_height_upper_bound() {
Some(h) => tip.saturating_sub(h) + 1,
None => 0,
};
let is_trusted = nb_confs >= min_confs || tx.input.iter().all(|input| {
let prev = input.previous_output;
let Some(prev_entry) = txs.get(&prev.txid) else { return false };
let Some(prev_out) = prev_entry.tx.output.get(prev.vout as usize) else { return false };
w.is_mine(prev_out.script_pubkey.clone()) && prev_entry.is_trusted
});
txs.insert(txid, LocalTransaction { tx, chain_position, is_trusted });
}
let unspent = w.spk_index().outpoints().iter()
.map(|(_, op)| *op)
.filter(|op| !spent.contains(op))
.filter(|op| txs.contains_key(&op.txid))
.collect();
Self { txs, unspent }
}
pub fn is_trusted(&self, txid: Txid) -> bool {
self.txs.get(&txid).map(|e| e.is_trusted).unwrap_or(false)
}
pub fn list_unspent(&self) -> impl Iterator<Item = TrustedUtxo<'_>> + '_ {
self.unspent.iter().map(move |op| {
let lt = &self.txs[&op.txid];
TrustedUtxo {
outpoint: *op,
txout: <.tx.output[op.vout as usize],
chain_position: <.chain_position,
is_trusted: lt.is_trusted,
}
})
}
}
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub struct TrustedBalance {
pub trusted: Amount,
pub untrusted: Amount,
}
impl TrustedBalance {
pub fn total(&self) -> Amount {
self.trusted + self.untrusted
}
}
pub const KEYCHAIN: bdk_wallet::KeychainKind = bdk_wallet::KeychainKind::External;
#[derive(Debug, Clone, Copy, Default)]
pub struct NonDustDrainCoinSelection;
impl CoinSelectionAlgorithm for NonDustDrainCoinSelection {
fn coin_select<R: RngCore>(
&self,
required_utxos: Vec<WeightedUtxo>,
optional_utxos: Vec<WeightedUtxo>,
fee_rate: FeeRate,
target_amount: Amount,
drain_script: &Script,
rand: &mut R,
) -> Result<CoinSelectionResult, InsufficientFunds> {
let drain_output_len = serialize(drain_script).len() + 8;
let drain_output_fee = fee_rate
* Weight::from_vb(drain_output_len as u64).expect("script length fits in Weight");
let raise = drain_script.minimal_non_dust() + drain_output_fee;
let mut result = DefaultCoinSelectionAlgorithm::default().coin_select(
required_utxos, optional_utxos, fee_rate, target_amount + raise, drain_script, rand,
)?;
let remaining = result.selected_amount()
.checked_sub(target_amount + result.fee_amount)
.expect("selection covers the raised target");
result.excess = decide_change(remaining, fee_rate, drain_script);
Ok(result)
}
}
pub trait TxBuilderExt<'a, A>: BorrowMut<TxBuilder<'a, A>> {
fn add_fee_anchor_spend(&mut self, anchor: OutPoint, output: &TxOut)
where
A: bdk_wallet::coin_selection::CoinSelectionAlgorithm,
{
let psbt_in = Input {
witness_utxo: Some(output.clone()),
final_script_witness: Some(Witness::new()),
..Default::default()
};
self.borrow_mut().add_foreign_utxo(anchor, psbt_in, FEE_ANCHOR_SPEND_WEIGHT)
.expect("adding foreign utxo");
}
}
impl<'a, A> TxBuilderExt<'a, A> for TxBuilder<'a, A> {}
#[derive(Debug, thiserror::Error)]
pub enum CpfpInternalError {
#[error("{0}")]
General(String),
#[error("Unable to construct transaction: {0}")]
Create(CreateTxError),
#[error("Unable to extract the final transaction after signing the PSBT: {0}")]
Extract(ExtractTxError),
#[error("Failed to determine the weight/fee when creating a P2A CPFP")]
Fee(),
#[error("Unable to finalize CPFP transaction: {0}")]
FinalizeError(String),
#[error("You need more confirmations on your on-chain funds: {0}")]
InsufficientConfirmedFunds(InsufficientFunds),
#[error("Transaction has no fee anchor: {0}")]
NoFeeAnchor(Txid),
#[allow(deprecated)]
#[error("Unable to sign transaction: {0}")]
Signer(bdk_wallet::signer::SignerError),
}
pub trait WalletExt: BorrowMut<Wallet> {
fn peek_next_address(&self) -> AddressInfo {
self.borrow().peek_address(KEYCHAIN, self.borrow().next_derivation_index(KEYCHAIN))
}
fn unconfirmed_txids(&self) -> impl Iterator<Item = Txid> {
self.borrow().transactions().filter_map(|tx| {
if tx.chain_position.is_unconfirmed() {
Some(tx.tx_node.txid)
} else {
None
}
})
}
fn unconfirmed_txs(&self) -> impl Iterator<Item = Arc<Transaction>> {
self.borrow().transactions().filter_map(|tx| {
if tx.chain_position.is_unconfirmed() {
Some(tx.tx_node.tx.clone())
} else {
None
}
})
}
fn trusted_balance(&self, min_confs: u32) -> TrustedBalance {
let canon = TrustedCanonicalization::from_wallet(self.borrow(), min_confs);
let mut trusted = Amount::ZERO;
let mut untrusted = Amount::ZERO;
for utxo in canon.list_unspent() {
if utxo.is_trusted {
trusted += utxo.txout.value;
} else {
untrusted += utxo.txout.value;
}
}
TrustedBalance { trusted, untrusted }
}
fn untrusted_utxos(&self, min_confs: u32) -> Vec<OutPoint> {
TrustedCanonicalization::from_wallet(self.borrow(), min_confs)
.list_unspent()
.filter(|u| !u.is_trusted)
.map(|u| u.outpoint)
.collect()
}
fn is_fully_owned_tx(&self, txid: Txid) -> bool {
let wallet = self.borrow();
let graph = wallet.tx_graph();
match graph.get_tx(txid) {
Some(tx) => {
tx.input.iter().all(|input| {
let prev = input.previous_output;
graph.get_tx(prev.txid)
.and_then(|prev_tx| prev_tx.output.get(prev.vout as usize).cloned())
.map(|out| wallet.is_mine(out.script_pubkey))
.unwrap_or(false)
})
}, None => false
}
}
fn set_checkpoint(&mut self, height: u32, hash: BlockHash) {
let checkpoint = BlockId { height, hash };
let wallet = self.borrow_mut();
wallet.apply_update(bdk_wallet::Update {
chain: Some(wallet.latest_checkpoint().insert(checkpoint)),
..Default::default()
}).expect("should work, might fail if tip is genesis");
}
fn mark_output_keys_unused(&mut self, tx: &Transaction) {
let wallet = self.borrow_mut();
for txout in &tx.output {
if let Some((keychain, index)) = wallet.spk_index().index_of_spk(txout.script_pubkey.clone()) {
wallet.unmark_used(*keychain, *index);
}
}
}
fn make_signed_p2a_cpfp(
&mut self,
tx: &Transaction,
fees: MakeCpfpFees,
) -> Result<Transaction, CpfpInternalError> {
let wallet = self.borrow_mut();
let (fee_anchor_point, fee_anchor_txout) = tx.fee_anchor()
.ok_or_else(|| CpfpInternalError::NoFeeAnchor(tx.compute_txid()))?;
let parent_weight = tx.weight();
let extra_fee_needed = parent_weight * fees.effective();
let change_addr = wallet.next_unused_address(KEYCHAIN);
let mut final_child_weight = Weight::ZERO;
let mut fee_needed = extra_fee_needed;
for i in 0..100 {
let mut b = wallet.build_tx().coin_selection(NonDustDrainCoinSelection);
b.only_witness_utxo();
b.exclude_unconfirmed();
b.version(3); b.add_fee_anchor_spend(fee_anchor_point, fee_anchor_txout);
b.drain_to(change_addr.address.script_pubkey());
b.fee_absolute(fee_needed);
let mut psbt = b.finish().map_err(|e| match e {
CreateTxError::CoinSelection(e) => CpfpInternalError::InsufficientConfirmedFunds(e),
_ => CpfpInternalError::Create(e),
})?;
#[allow(deprecated)]
let opts = bdk_wallet::SignOptions {
trust_witness_utxo: true,
..Default::default()
};
let finalized = wallet.sign(&mut psbt, opts)
.map_err(|e| CpfpInternalError::Signer(e))?;
if !finalized {
return Err(CpfpInternalError::FinalizeError("finalization failed".into()));
}
let tx = psbt.extract_tx()
.map_err(|e| CpfpInternalError::Extract(e))?;
assert!(tx.input.iter().any(|i| i.previous_output == fee_anchor_point),
"Missing anchor spend, tx is {}", serialize_hex(&tx),
);
let tx_weight = tx.weight();
let total_weight = tx_weight + parent_weight;
if tx_weight != final_child_weight {
wallet.mark_output_keys_unused(&tx);
final_child_weight = tx_weight;
fee_needed = match fees {
MakeCpfpFees::Effective(fr) => total_weight * fr,
MakeCpfpFees::Rbf { min_effective_fee_rate, current_package_fee } => {
let min_tx_relay_fee = FeeRate::from_sat_per_vb(1).unwrap();
let min_package_fee = current_package_fee +
parent_weight * min_tx_relay_fee +
tx_weight * min_tx_relay_fee;
let desired_fee = total_weight * min_effective_fee_rate;
if desired_fee < min_package_fee {
debug!("Using a minimum fee of {} instead of the desired fee of {} for RBF",
min_package_fee, desired_fee,
);
min_package_fee
} else {
trace!("Attempting to use the desired fee of {} for CPFP RBF",
desired_fee,
);
desired_fee
}
}
}
} else {
debug!("Created P2A CPFP with weight {} and fee {} in {} iterations",
total_weight, fee_needed, i,
);
return Ok(tx);
}
}
Err(CpfpInternalError::General("Reached max iterations".into()))
}
}
#[cfg(test)]
mod test {
use super::*;
use bdk_wallet::KeychainKind;
use bdk_wallet::chain::BlockId;
use bdk_wallet::test_utils::{get_test_wpkh, insert_checkpoint, receive_output_in_latest_block};
use bitcoin::Network;
use bitcoin::hashes::Hash;
fn two_utxo_wallet() -> (Wallet, OutPoint) {
let mut wallet = Wallet::create_single(get_test_wpkh())
.network(Network::Regtest)
.create_wallet_no_persist()
.unwrap();
insert_checkpoint(&mut wallet, BlockId { height: 1_000, hash: BlockHash::all_zeros() });
let op1 = receive_output_in_latest_block(&mut wallet, Amount::from_sat(1_000));
receive_output_in_latest_block(&mut wallet, Amount::from_sat(1_001));
(wallet, op1)
}
#[test]
fn non_dust_drain_selection_rescues_sub_dust_change() {
let (mut wallet, op1) = two_utxo_wallet();
let change_spk = wallet.reveal_next_address(KeychainKind::External)
.address.script_pubkey();
let fee = Amount::from_sat(900);
assert!(Amount::from_sat(100) < change_spk.minimal_non_dust(), "premise");
let mut b = wallet.build_tx().coin_selection(NonDustDrainCoinSelection);
b.add_utxo(op1).unwrap();
b.only_witness_utxo();
b.drain_to(change_spk.clone());
b.fee_absolute(fee);
let psbt = b.finish().expect("both UTXOs cover fee + dust");
let tx = &psbt.unsigned_tx;
assert_eq!(tx.input.len(), 2, "must pull in the second UTXO");
assert_eq!(tx.output.len(), 1);
let change = tx.output[0].value;
assert!(change >= change_spk.minimal_non_dust(), "change {} is dust", change);
assert_eq!(change, Amount::from_sat(2_001) - fee);
assert_eq!(psbt.fee().unwrap(), fee);
}
#[test]
fn non_dust_drain_selection_fails_when_change_can_only_be_dust() {
let (mut wallet, op1) = two_utxo_wallet();
let change_spk = wallet.reveal_next_address(KeychainKind::External)
.address.script_pubkey();
let fee = Amount::from_sat(1_900);
let dust = change_spk.minimal_non_dust();
assert!(Amount::from_sat(101) < dust, "premise");
let mut b = wallet.build_tx().coin_selection(NonDustDrainCoinSelection);
b.add_utxo(op1).unwrap();
b.only_witness_utxo();
b.drain_to(change_spk);
b.fee_absolute(fee);
match b.finish() {
Err(CreateTxError::CoinSelection(e)) => {
assert_eq!(e.needed, fee + dust, "needed must cover fee plus a non-dust drain");
assert_eq!(e.available, Amount::from_sat(2_001), "available must be the whole wallet");
},
other => panic!("expected InsufficientFunds, got {:?}", other),
}
}
#[test]
fn non_dust_drain_selection_no_extra_input_when_change_is_fine() {
let (mut wallet, op1) = two_utxo_wallet();
let change_spk = wallet.reveal_next_address(KeychainKind::External)
.address.script_pubkey();
let fee = Amount::from_sat(500);
let mut b = wallet.build_tx().coin_selection(NonDustDrainCoinSelection);
b.add_utxo(op1).unwrap();
b.only_witness_utxo();
b.drain_to(change_spk);
b.fee_absolute(fee);
let psbt = b.finish().unwrap();
let tx = &psbt.unsigned_tx;
assert_eq!(tx.input.len(), 1, "1000-sat input alone leaves non-dust change");
assert_eq!(tx.output[0].value, Amount::from_sat(500));
assert_eq!(psbt.fee().unwrap(), fee);
}
}
impl WalletExt for Wallet {}