diff --git a/bindings/ldk_node.udl b/bindings/ldk_node.udl index c881dbe09..65c41761c 100644 --- a/bindings/ldk_node.udl +++ b/bindings/ldk_node.udl @@ -266,6 +266,8 @@ interface OnchainPayment { Txid send_to_address([ByRef]Address address, u64 amount_sats, FeeRate? fee_rate); [Throws=NodeError] Txid send_all_to_address([ByRef]Address address, boolean retain_reserve, FeeRate? fee_rate); + [Throws=NodeError] + Txid bump_fee_cpfp(PaymentId payment_id); }; interface FeeRate { diff --git a/src/payment/onchain.rs b/src/payment/onchain.rs index 695f96d43..b0649a8d0 100644 --- a/src/payment/onchain.rs +++ b/src/payment/onchain.rs @@ -17,6 +17,8 @@ use crate::logger::{log_info, LdkLogger, Logger}; use crate::types::{ChannelManager, Wallet}; use crate::wallet::OnchainSendAmount; +use lightning::ln::channelmanager::PaymentId; + #[cfg(not(feature = "uniffi"))] type FeeRate = bitcoin::FeeRate; #[cfg(feature = "uniffi")] @@ -120,4 +122,31 @@ impl OnchainPayment { let fee_rate_opt = maybe_map_fee_rate_opt!(fee_rate); self.wallet.send_to_address(address, send_amount, fee_rate_opt) } + + /// Bumps the fee of a given UTXO using Child-Pays-For-Parent (CPFP) by creating a new transaction. + /// + /// This method creates a new transaction that spends the specified UTXO with a higher fee rate, + /// effectively increasing the priority of both the new transaction and the parent transaction + /// it depends on. This is useful when a transaction is stuck in the mempool due to insufficient + /// fees and you want to accelerate its confirmation. + /// + /// CPFP works by creating a child transaction that spends one or more outputs from the parent + /// transaction. Miners will consider the combined fees of both transactions when deciding + /// which transactions to include in a block. + /// + /// # Parameters + /// * `payment_id` - The identifier of the payment whose UTXO should be fee-bumped + /// + /// # Returns + /// * `Ok(Txid)` - The transaction ID of the newly created CPFP transaction on success + /// * `Err(Error)` - If the payment cannot be found, the UTXO is not suitable for CPFP, + /// or if there's an error creating the transaction + /// + /// # Note + /// CPFP is specifically designed to work with unconfirmed UTXOs. The child transaction + /// can spend outputs from unconfirmed parent transactions, allowing miners to consider + /// the combined fees of both transactions when building a block. + pub fn bump_fee_cpfp(&self, payment_id: PaymentId) -> Result { + self.wallet.bump_fee_cpfp(payment_id) + } } diff --git a/src/wallet/mod.rs b/src/wallet/mod.rs index 05c743bd9..d5575310f 100644 --- a/src/wallet/mod.rs +++ b/src/wallet/mod.rs @@ -9,6 +9,7 @@ use std::future::Future; use std::ops::Deref; use std::str::FromStr; use std::sync::{Arc, Mutex}; +use std::time::{Duration, SystemTime, UNIX_EPOCH}; use bdk_chain::spk_client::{FullScanRequest, SyncRequest}; use bdk_wallet::descriptor::ExtendedDescriptor; @@ -978,6 +979,131 @@ impl Wallet { None } + + #[allow(deprecated)] + pub(crate) fn bump_fee_cpfp(&self, payment_id: PaymentId) -> Result { + let txid = Txid::from_slice(&payment_id.0).expect("32 bytes"); + + let payment = self.pending_payment_store.get(&payment_id).ok_or(Error::InvalidPaymentId)?; + + if let PaymentKind::Onchain { status, .. } = &payment.details.kind { + match status { + ConfirmationStatus::Confirmed { .. } => { + log_error!(self.logger, "Transaction {} is already confirmed", txid); + return Err(Error::InvalidPaymentId); + }, + ConfirmationStatus::Unconfirmed => {}, + } + } + + let mut locked_wallet = self.inner.lock().unwrap(); + + let wallet_tx = locked_wallet.get_tx(txid).ok_or(Error::InvalidPaymentId)?; + let transaction = &wallet_tx.tx_node.tx; + + // Create the CPFP transaction using a high fee rate to get it confirmed quickly. + let mut our_vout: Option = None; + let mut our_value: Amount = Amount::ZERO; + + for (vout_index, output) in transaction.output.iter().enumerate() { + let script = output.script_pubkey.clone(); + + if locked_wallet.is_mine(script) { + our_vout = Some(vout_index as u32); + our_value = output.value.into(); + break; + } + } + + let our_vout = our_vout.ok_or_else(|| { + log_error!( + self.logger, + "Could not find an output owned by this wallet in transaction {}", + txid + ); + Error::InvalidPaymentId + })?; + + let cpfp_outpoint = OutPoint::new(txid, our_vout); + + let confirmation_target = ConfirmationTarget::OnchainPayment; + let estimated_fee_rate = self.fee_estimator.estimate_fee_rate(confirmation_target); + + const CPFP_MULTIPLIER: f64 = 1.5; + let boosted_fee_rate = FeeRate::from_sat_per_kwu( + ((estimated_fee_rate.to_sat_per_kwu() as f64) * CPFP_MULTIPLIER) as u64, + ); + + let mut psbt = { + let mut tx_builder = locked_wallet.build_tx(); + tx_builder + .add_utxo(cpfp_outpoint) + .map_err(|e| { + log_error!(self.logger, "Failed to add CPFP UTXO {}: {}", cpfp_outpoint, e); + Error::InvalidPaymentId + })? + .drain_to(transaction.output[our_vout as usize].script_pubkey.clone()) + .fee_rate(boosted_fee_rate); + + match tx_builder.finish() { + Ok(psbt) => { + log_trace!(self.logger, "Created CPFP PSBT: {:?}", psbt); + psbt + }, + Err(err) => { + log_error!(self.logger, "Failed to create CPFP transaction: {}", err); + return Err(err.into()); + }, + } + }; + + match locked_wallet.sign(&mut psbt, SignOptions::default()) { + Ok(finalized) => { + if !finalized { + return Err(Error::OnchainTxCreationFailed); + } + }, + Err(err) => { + log_error!(self.logger, "Failed to create transaction: {}", err); + return Err(err.into()); + }, + } + + let mut locked_persister = self.persister.lock().unwrap(); + locked_wallet.persist(&mut locked_persister).map_err(|e| { + log_error!(self.logger, "Failed to persist wallet: {}", e); + Error::PersistenceFailed + })?; + + let cpfp_tx = psbt.extract_tx().map_err(|e| { + log_error!(self.logger, "Failed to extract CPFP transaction: {}", e); + e + })?; + + let cpfp_txid = cpfp_tx.compute_txid(); + + self.broadcaster.broadcast_transactions(&[&cpfp_tx]); + + let new_fee = locked_wallet.calculate_fee(&cpfp_tx).unwrap_or(Amount::ZERO); + let new_fee_sats = new_fee.to_sat(); + + let payment_details = PaymentDetails { + id: PaymentId(cpfp_txid.to_byte_array()), + kind: PaymentKind::Onchain { txid: cpfp_txid, status: ConfirmationStatus::Unconfirmed }, + amount_msat: Some(our_value.to_sat() * 1000), + fee_paid_msat: Some(new_fee_sats * 1000), + direction: PaymentDirection::Outbound, + status: PaymentStatus::Pending, + latest_update_timestamp: SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or(Duration::from_secs(0)) + .as_secs(), + }; + self.payment_store.insert_or_update(payment_details)?; + + log_info!(self.logger, "Created CPFP transaction {} to bump fee of {}", cpfp_txid, txid); + Ok(cpfp_txid) + } } impl Listen for Wallet { diff --git a/tests/integration_tests_rust.rs b/tests/integration_tests_rust.rs index 4e94dd044..27bda1017 100644 --- a/tests/integration_tests_rust.rs +++ b/tests/integration_tests_rust.rs @@ -14,7 +14,7 @@ use std::sync::Arc; use bitcoin::address::NetworkUnchecked; use bitcoin::hashes::sha256::Hash as Sha256Hash; use bitcoin::hashes::Hash; -use bitcoin::{Address, Amount, ScriptBuf}; +use bitcoin::{Address, Amount, ScriptBuf, Txid}; use common::logging::{init_log_logger, validate_log_entry, MultiNodeLogger, TestLogWriter}; use common::{ bump_fee_and_broadcast, distribute_funds_unconfirmed, do_channel_full_cycle, @@ -39,6 +39,7 @@ use lightning::routing::gossip::{NodeAlias, NodeId}; use lightning::routing::router::RouteParametersConfig; use lightning_invoice::{Bolt11InvoiceDescription, Description}; use lightning_types::payment::{PaymentHash, PaymentPreimage}; + use log::LevelFilter; #[tokio::test(flavor = "multi_thread", worker_threads = 1)] @@ -2501,3 +2502,106 @@ async fn persistence_backwards_compatibility() { node_new.stop().unwrap(); } + +#[tokio::test(flavor = "multi_thread", worker_threads = 1)] +async fn test_fee_bump_cpfp() { + let (bitcoind, electrsd) = setup_bitcoind_and_electrsd(); + let chain_source = TestChainSource::Esplora(&electrsd); + let (node_a, node_b) = setup_two_nodes(&chain_source, false, true, false); + + // Fund both nodes + let addr_a = node_a.onchain_payment().new_address().unwrap(); + let addr_b = node_b.onchain_payment().new_address().unwrap(); + + let premine_amount_sat = 500_000; + premine_and_distribute_funds( + &bitcoind.client, + &electrsd.client, + vec![addr_a.clone(), addr_b.clone()], + Amount::from_sat(premine_amount_sat), + ) + .await; + + node_a.sync_wallets().unwrap(); + node_b.sync_wallets().unwrap(); + + // Send a transaction from node_b to node_a that we'll later bump + let amount_to_send_sats = 100_000; + let txid = + node_b.onchain_payment().send_to_address(&addr_a, amount_to_send_sats, None).unwrap(); + wait_for_tx(&electrsd.client, txid); + node_a.sync_wallets().unwrap(); + node_b.sync_wallets().unwrap(); + + let payment_id = PaymentId(txid.to_byte_array()); + let original_payment = node_b.payment(&payment_id).unwrap(); + let original_fee = original_payment.fee_paid_msat.unwrap(); + + // Non-existent payment id + let fake_txid = + Txid::from_str("0000000000000000000000000000000000000000000000000000000000000000").unwrap(); + let invalid_payment_id = PaymentId(fake_txid.to_byte_array()); + assert_eq!( + Err(NodeError::InvalidPaymentId), + node_b.onchain_payment().bump_fee_cpfp(invalid_payment_id) + ); + + // Bump an outbound payment + assert_eq!( + Err(NodeError::InvalidPaymentId), + node_b.onchain_payment().bump_fee_cpfp(payment_id) + ); + + // Successful fee bump via CPFP + let new_txid = node_a.onchain_payment().bump_fee_cpfp(payment_id).unwrap(); + wait_for_tx(&electrsd.client, new_txid); + + // Sleep to allow for transaction propagation + std::thread::sleep(std::time::Duration::from_secs(5)); + + node_a.sync_wallets().unwrap(); + node_b.sync_wallets().unwrap(); + + let new_payment_id = PaymentId(new_txid.to_byte_array()); + let new_payment = node_a.payment(&new_payment_id).unwrap(); + + // Verify payment properties + assert_eq!(new_payment.amount_msat, Some(amount_to_send_sats * 1000)); + assert_eq!(new_payment.direction, PaymentDirection::Outbound); + assert_eq!(new_payment.status, PaymentStatus::Pending); + + // // Verify fee increased + assert!( + new_payment.fee_paid_msat > Some(original_fee), + "Fee should increase after RBF bump. Original: {}, New: {}", + original_fee, + new_payment.fee_paid_msat.unwrap() + ); + + // Confirm the transaction and try to bump again (should fail) + generate_blocks_and_wait(&bitcoind.client, &electrsd.client, 6).await; + node_a.sync_wallets().unwrap(); + node_b.sync_wallets().unwrap(); + + assert_eq!( + Err(NodeError::InvalidPaymentId), + node_a.onchain_payment().bump_fee_cpfp(payment_id) + ); + + // Verify final payment is confirmed + let final_payment = node_b.payment(&payment_id).unwrap(); + assert_eq!(final_payment.status, PaymentStatus::Succeeded); + match final_payment.kind { + PaymentKind::Onchain { status, .. } => { + assert!(matches!(status, ConfirmationStatus::Confirmed { .. })); + }, + _ => panic!("Unexpected payment kind"), + } + + // Verify node A received the funds correctly + let node_a_received_payment = + node_a.list_payments_with_filter(|p| matches!(p.kind, PaymentKind::Onchain { txid, .. })); + assert_eq!(node_a_received_payment.len(), 1); + assert_eq!(node_a_received_payment[0].amount_msat, Some(amount_to_send_sats * 1000)); + assert_eq!(node_a_received_payment[0].status, PaymentStatus::Succeeded); +}