Skip to content
Open
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
2 changes: 2 additions & 0 deletions bindings/ldk_node.udl
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
28 changes: 28 additions & 0 deletions src/payment/onchain.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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};

Expand Down Expand Up @@ -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<Txid, Error> {
self.wallet.bump_fee_cpfp(payment_id)
}
}
145 changes: 143 additions & 2 deletions src/wallet/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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 },
Expand Down Expand Up @@ -568,6 +569,146 @@ where

Ok(txid)
}

pub(crate) fn bump_fee_cpfp(&self, payment_id: PaymentId) -> Result<Txid, Error> {
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<u32> = 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<B: Deref, E: Deref, L: Deref> Listen for Wallet<B, E, L>
Expand Down
104 changes: 103 additions & 1 deletion tests/integration_tests_rust.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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);
}
Loading