diff --git a/bindings/ldk_node.udl b/bindings/ldk_node.udl index 076d7fc9b..7758ff721 100644 --- a/bindings/ldk_node.udl +++ b/bindings/ldk_node.udl @@ -231,6 +231,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 2614e55ce..f6f15e67d 100644 --- a/src/payment/onchain.rs +++ b/src/payment/onchain.rs @@ -14,6 +14,7 @@ use crate::types::{ChannelManager, Wallet}; use crate::wallet::OnchainSendAmount; use bitcoin::{Address, Txid}; +use lightning::ln::channelmanager::PaymentId; use std::sync::{Arc, RwLock}; @@ -120,4 +121,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 fbac1d1b6..d9793075a 100644 --- a/src/wallet/mod.rs +++ b/src/wallet/mod.rs @@ -12,7 +12,7 @@ use crate::logger::{log_debug, log_error, log_info, log_trace, LdkLogger}; use crate::fee_estimator::{ConfirmationTarget, FeeEstimator}; use crate::payment::store::ConfirmationStatus; -use crate::payment::{PaymentDetails, PaymentDirection, PaymentStatus}; +use crate::payment::{PaymentDetails, PaymentDirection, PaymentKind, PaymentStatus}; use crate::types::PaymentStore; use crate::Error; @@ -46,13 +46,14 @@ use bitcoin::secp256k1::ecdh::SharedSecret; use bitcoin::secp256k1::ecdsa::{RecoverableSignature, Signature}; use bitcoin::secp256k1::{PublicKey, Scalar, Secp256k1, SecretKey, Signing}; use bitcoin::{ - Address, Amount, FeeRate, Network, ScriptBuf, Transaction, TxOut, Txid, WPubkeyHash, + Address, Amount, FeeRate, Network, OutPoint, ScriptBuf, Transaction, TxOut, Txid, WPubkeyHash, WitnessProgram, WitnessVersion, }; use std::ops::Deref; use std::str::FromStr; use std::sync::{Arc, Mutex}; +use std::time::{Duration, SystemTime, UNIX_EPOCH}; pub(crate) enum OnchainSendAmount { ExactRetainingReserve { amount_sats: u64, cur_anchor_reserve_sats: u64 }, @@ -568,6 +569,146 @@ where Ok(txid) } + + 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.payment_store.get(&payment_id).ok_or(Error::InvalidPaymentId)?; + if payment.direction != PaymentDirection::Inbound { + log_error!(self.logger, "Transaction {} is not an inbound payment", txid); + return Err(Error::InvalidPaymentId); + } + + if let PaymentKind::Onchain { status, .. } = &payment.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; + let (sent, received) = locked_wallet.sent_and_received(transaction); + + if sent > received { + log_error!( + self.logger, + "Transaction {} is not an inbound payment (sent: {}, received: {})", + txid, + sent, + received + ); + return Err(Error::InvalidPaymentId); + } + + // 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 0932116ef..cccfe9023 100644 --- a/tests/integration_tests_rust.rs +++ b/tests/integration_tests_rust.rs @@ -33,9 +33,9 @@ use lightning::util::persist::KVStore; use lightning_invoice::{Bolt11InvoiceDescription, Description}; use lightning_types::payment::{PaymentHash, PaymentPreimage}; -use bitcoin::address::NetworkUnchecked; use bitcoin::hashes::sha256::Hash as Sha256Hash; use bitcoin::hashes::Hash; +use bitcoin::{address::NetworkUnchecked, Txid}; use bitcoin::{Address, Amount, ScriptBuf}; use log::LevelFilter; @@ -1699,3 +1699,105 @@ async fn drop_in_async_context() { let node = setup_node(&chain_source, config, Some(seed_bytes)); node.stop().unwrap(); } + +#[test] +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), + ); + + 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); + 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); +}