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
23 changes: 23 additions & 0 deletions src/payment/onchain.rs
Original file line numberDiff line numberDiff line change
Expand Up@@ -141,4 +141,27 @@ impl OnchainPayment {
let fee_rate_opt = maybe_map_fee_rate_opt!(fee_rate);
self.wallet.bump_fee_rbf(payment_id, 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
/// * `fee_rate` - The fee rate to use for the CPFP transaction, if not provided, a reasonable fee rate is used
///
/// Returns the [`Txid`] of the newly created CPFP transaction if successful.
pub fn bump_fee_cpfp(
&self, payment_id: PaymentId, fee_rate: Option<FeeRate>,
) -> Result<Txid, Error> {
let fee_rate_opt = maybe_map_fee_rate_opt!(fee_rate);
self.wallet.bump_fee_cpfp(payment_id, fee_rate_opt)
}
}
113 changes: 113 additions & 0 deletions src/wallet/mod.rs
Original file line numberDiff line numberDiff line change
Expand Up@@ -1403,6 +1403,119 @@ impl Wallet {

Ok(new_txid)
}

#[allow(deprecated)]
pub(crate) fn bump_fee_cpfp(
&self, payment_id: PaymentId, fee_rate: Option<FeeRate>,
) -> Result<Txid, Error> {
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<u32> = None;

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);
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 = fee_rate.unwrap_or_else(|| {
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,
lightning::chain::chaininterface::TransactionType::Sweep { channels: vec![] },
)]);

log_info!(self.logger, "Created CPFP transaction {} to bump fee of {}", cpfp_txid, txid);
Ok(cpfp_txid)
}
}

impl Listen for Wallet {
Expand Down
101 changes: 101 additions & 0 deletions tests/integration_tests_rust.rs
Original file line numberDiff line numberDiff line change
Expand Up@@ -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)]
Expand DownExpand Up@@ -2809,3 +2810,103 @@ async fn splice_in_with_all_balance() {
node_a.stop().unwrap();
node_b.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 = random_chain_source(&bitcoind, &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).await;
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, None)
);

// Successful fee bump via CPFP
let new_txid = node_a.onchain_payment().bump_fee_cpfp(payment_id, None).unwrap();
wait_for_tx(&electrsd.client, new_txid).await;

// 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.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 CPFP 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, None)
);

// 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 the inbound payment (parent tx) is confirmed with the original amount.
let inbound_payment = node_a.payment(&payment_id).unwrap();
assert_eq!(inbound_payment.amount_msat, Some(amount_to_send_sats * 1000));
assert_eq!(inbound_payment.direction, PaymentDirection::Inbound);
assert_eq!(inbound_payment.status, PaymentStatus::Succeeded);

// Verify the CPFP child tx (self-spend) is also confirmed.
let cpfp_payment = node_a.payment(&new_payment_id).unwrap();
assert_eq!(cpfp_payment.direction, PaymentDirection::Outbound);
assert_eq!(cpfp_payment.status, PaymentStatus::Succeeded);
}
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Add copy buttons to all
 blocks
(function() {
function addCopyButtons() {
document.querySelectorAll('pre code').forEach(function(codeBlock) {
if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;
codeBlock.parentElement.setAttribute('data-copy-added', 'true');
var btn = document.createElement('button');
btn.textContent = 'Copy';
btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';
btn.onmouseover = function() { this.style.opacity = '1'; };
btn.onmouseout = function() { this.style.opacity = '0.7'; };
btn.onclick = function() {
navigator.clipboard.writeText(codeBlock.textContent).then(function() {
btn.textContent = 'Copied!';
setTimeout(function() { btn.textContent = 'Copy'; }, 1500);
});
};
codeBlock.parentElement.style.position = 'relative';
codeBlock.parentElement.appendChild(btn);
});
}
addCopyButtons();
// Re-run on dynamic content
var observer = new MutationObserver(addCopyButtons);
observer.observe(document.body, { childList: true, subtree: true });
})();
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
Implement CPFP fee bumping for unconfirmed transactions by Camillarhi · Pull Request #638 · lightningdevkit/ldk-node · GitHub
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
23 changes: 23 additions & 0 deletions src/payment/onchain.rs
Original file line numberDiff line numberDiff line change
Expand Up@@ -141,4 +141,27 @@ impl OnchainPayment {
let fee_rate_opt = maybe_map_fee_rate_opt!(fee_rate);
self.wallet.bump_fee_rbf(payment_id, 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
/// * `fee_rate` - The fee rate to use for the CPFP transaction, if not provided, a reasonable fee rate is used
///
/// Returns the [`Txid`] of the newly created CPFP transaction if successful.
pub fn bump_fee_cpfp(
&self, payment_id: PaymentId, fee_rate: Option<FeeRate>,
) -> Result<Txid, Error> {
let fee_rate_opt = maybe_map_fee_rate_opt!(fee_rate);
self.wallet.bump_fee_cpfp(payment_id, fee_rate_opt)
}
}
113 changes: 113 additions & 0 deletions src/wallet/mod.rs
Original file line numberDiff line numberDiff line change
Expand Up@@ -1403,6 +1403,119 @@ impl Wallet {

Ok(new_txid)
}

#[allow(deprecated)]
pub(crate) fn bump_fee_cpfp(
&self, payment_id: PaymentId, fee_rate: Option<FeeRate>,
) -> Result<Txid, Error> {
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<u32> = None;

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);
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 = fee_rate.unwrap_or_else(|| {
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,
lightning::chain::chaininterface::TransactionType::Sweep { channels: vec![] },
)]);

log_info!(self.logger, "Created CPFP transaction {} to bump fee of {}", cpfp_txid, txid);
Ok(cpfp_txid)
}
}

impl Listen for Wallet {
Expand Down
101 changes: 101 additions & 0 deletions tests/integration_tests_rust.rs
Original file line numberDiff line numberDiff line change
Expand Up@@ -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)]
Expand DownExpand Up@@ -2809,3 +2810,103 @@ async fn splice_in_with_all_balance() {
node_a.stop().unwrap();
node_b.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 = random_chain_source(&bitcoind, &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).await;
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, None)
);

// Successful fee bump via CPFP
let new_txid = node_a.onchain_payment().bump_fee_cpfp(payment_id, None).unwrap();
wait_for_tx(&electrsd.client, new_txid).await;

// 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.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 CPFP 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, None)
);

// 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 the inbound payment (parent tx) is confirmed with the original amount.
let inbound_payment = node_a.payment(&payment_id).unwrap();
assert_eq!(inbound_payment.amount_msat, Some(amount_to_send_sats * 1000));
assert_eq!(inbound_payment.direction, PaymentDirection::Inbound);
assert_eq!(inbound_payment.status, PaymentStatus::Succeeded);

// Verify the CPFP child tx (self-spend) is also confirmed.
let cpfp_payment = node_a.payment(&new_payment_id).unwrap();
assert_eq!(cpfp_payment.direction, PaymentDirection::Outbound);
assert_eq!(cpfp_payment.status, PaymentStatus::Succeeded);
}
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Force GitHub README to respect dark mode (function() { var style = document.createElement('style'); style.textContent = ' .markdown-body { color-scheme: dark light; } .markdown-body pre { background: #161b22 !important; } .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; } .markdown-body table th, .markdown-body table td { border-color: #30363d !important; } .markdown-body img { background: #0d1117; } .markdown-body blockquote { border-left-color: #8b949e; } .markdown-body hr { border-color: #30363d; } '; document.head.appendChild(style); })(); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' Implement CPFP fee bumping for unconfirmed transactions by Camillarhi · Pull Request #638 · lightningdevkit/ldk-node · GitHub
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
23 changes: 23 additions & 0 deletions src/payment/onchain.rs
Original file line numberDiff line numberDiff line change
Expand Up@@ -141,4 +141,27 @@ impl OnchainPayment {
let fee_rate_opt = maybe_map_fee_rate_opt!(fee_rate);
self.wallet.bump_fee_rbf(payment_id, 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
/// * `fee_rate` - The fee rate to use for the CPFP transaction, if not provided, a reasonable fee rate is used
///
/// Returns the [`Txid`] of the newly created CPFP transaction if successful.
pub fn bump_fee_cpfp(
&self, payment_id: PaymentId, fee_rate: Option<FeeRate>,
) -> Result<Txid, Error> {
let fee_rate_opt = maybe_map_fee_rate_opt!(fee_rate);
self.wallet.bump_fee_cpfp(payment_id, fee_rate_opt)
}
}
113 changes: 113 additions & 0 deletions src/wallet/mod.rs
Original file line numberDiff line numberDiff line change
Expand Up@@ -1403,6 +1403,119 @@ impl Wallet {

Ok(new_txid)
}

#[allow(deprecated)]
pub(crate) fn bump_fee_cpfp(
&self, payment_id: PaymentId, fee_rate: Option<FeeRate>,
) -> Result<Txid, Error> {
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<u32> = None;

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);
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 = fee_rate.unwrap_or_else(|| {
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,
lightning::chain::chaininterface::TransactionType::Sweep { channels: vec![] },
)]);

log_info!(self.logger, "Created CPFP transaction {} to bump fee of {}", cpfp_txid, txid);
Ok(cpfp_txid)
}
}

impl Listen for Wallet {
Expand Down
101 changes: 101 additions & 0 deletions tests/integration_tests_rust.rs
Original file line numberDiff line numberDiff line change
Expand Up@@ -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)]
Expand DownExpand Up@@ -2809,3 +2810,103 @@ async fn splice_in_with_all_balance() {
node_a.stop().unwrap();
node_b.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 = random_chain_source(&bitcoind, &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).await;
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, None)
);

// Successful fee bump via CPFP
let new_txid = node_a.onchain_payment().bump_fee_cpfp(payment_id, None).unwrap();
wait_for_tx(&electrsd.client, new_txid).await;

// 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.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 CPFP 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, None)
);

// 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 the inbound payment (parent tx) is confirmed with the original amount.
let inbound_payment = node_a.payment(&payment_id).unwrap();
assert_eq!(inbound_payment.amount_msat, Some(amount_to_send_sats * 1000));
assert_eq!(inbound_payment.direction, PaymentDirection::Inbound);
assert_eq!(inbound_payment.status, PaymentStatus::Succeeded);

// Verify the CPFP child tx (self-spend) is also confirmed.
let cpfp_payment = node_a.payment(&new_payment_id).unwrap();
assert_eq!(cpfp_payment.direction, PaymentDirection::Outbound);
assert_eq!(cpfp_payment.status, PaymentStatus::Succeeded);
}
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Highlight search terms from Google/DuckDuckGo/Bing referrer (function() { var ref = document.referrer; var terms = []; if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) { var url = new URL(ref); var q = url.searchParams.get('q') || url.searchParams.get('p'); if (q) { terms = q.split(/\s+/).filter(function(t) { return t.length > 2; }); } } if (terms.length === 0) return; var style = document.createElement('style'); style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }'; document.head.appendChild(style); function highlight(node) { if (node.nodeType === 3) { // text node var text = node.textContent; var found = false; terms.forEach(function(term) { var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\]\\]/g, '\\') + ')', 'gi'); if (regex.test(text)) { found = true; var frag = document.createDocumentFragment(); var parts = text.split(regex); parts.forEach(function(part, i) { if (i % 2 === 0) { frag.appendChild(document.createTextNode(part)); } else { var span = document.createElement('span'); span.className = 'userscript-highlight'; span.textContent = part; frag.appendChild(span); } }); node.parentNode.replaceChild(frag, node); } }); } else if (node.nodeType === 1 && node.childNodes) { // element var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT']; if (!skipTags.includes(node.tagName)) { Array.from(node.childNodes).forEach(highlight); } } } highlight(document.body); // Re-highlight on dynamic content var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1 || node.nodeType === 3) highlight(node); }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' Implement CPFP fee bumping for unconfirmed transactions by Camillarhi · Pull Request #638 · lightningdevkit/ldk-node · GitHub
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
23 changes: 23 additions & 0 deletions src/payment/onchain.rs
Original file line numberDiff line numberDiff line change
Expand Up@@ -141,4 +141,27 @@ impl OnchainPayment {
let fee_rate_opt = maybe_map_fee_rate_opt!(fee_rate);
self.wallet.bump_fee_rbf(payment_id, 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
/// * `fee_rate` - The fee rate to use for the CPFP transaction, if not provided, a reasonable fee rate is used
///
/// Returns the [`Txid`] of the newly created CPFP transaction if successful.
pub fn bump_fee_cpfp(
&self, payment_id: PaymentId, fee_rate: Option<FeeRate>,
) -> Result<Txid, Error> {
let fee_rate_opt = maybe_map_fee_rate_opt!(fee_rate);
self.wallet.bump_fee_cpfp(payment_id, fee_rate_opt)
}
}
113 changes: 113 additions & 0 deletions src/wallet/mod.rs
Original file line numberDiff line numberDiff line change
Expand Up@@ -1403,6 +1403,119 @@ impl Wallet {

Ok(new_txid)
}

#[allow(deprecated)]
pub(crate) fn bump_fee_cpfp(
&self, payment_id: PaymentId, fee_rate: Option<FeeRate>,
) -> Result<Txid, Error> {
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<u32> = None;

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);
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 = fee_rate.unwrap_or_else(|| {
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,
lightning::chain::chaininterface::TransactionType::Sweep { channels: vec![] },
)]);

log_info!(self.logger, "Created CPFP transaction {} to bump fee of {}", cpfp_txid, txid);
Ok(cpfp_txid)
}
}

impl Listen for Wallet {
Expand Down
101 changes: 101 additions & 0 deletions tests/integration_tests_rust.rs
Original file line numberDiff line numberDiff line change
Expand Up@@ -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)]
Expand DownExpand Up@@ -2809,3 +2810,103 @@ async fn splice_in_with_all_balance() {
node_a.stop().unwrap();
node_b.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 = random_chain_source(&bitcoind, &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).await;
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, None)
);

// Successful fee bump via CPFP
let new_txid = node_a.onchain_payment().bump_fee_cpfp(payment_id, None).unwrap();
wait_for_tx(&electrsd.client, new_txid).await;

// 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.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 CPFP 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, None)
);

// 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 the inbound payment (parent tx) is confirmed with the original amount.
let inbound_payment = node_a.payment(&payment_id).unwrap();
assert_eq!(inbound_payment.amount_msat, Some(amount_to_send_sats * 1000));
assert_eq!(inbound_payment.direction, PaymentDirection::Inbound);
assert_eq!(inbound_payment.status, PaymentStatus::Succeeded);

// Verify the CPFP child tx (self-spend) is also confirmed.
let cpfp_payment = node_a.payment(&new_payment_id).unwrap();
assert_eq!(cpfp_payment.direction, PaymentDirection::Outbound);
assert_eq!(cpfp_payment.status, PaymentStatus::Succeeded);
}
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Strip utm_, fbclid, gclid, etc. from all links on page (function() { var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content', 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid', 'ref', 'ref_src', 'source', 'medium', 'campaign']; function cleanUrl(url) { try { var u = new URL(url, window.location.origin); var changed = false; trackingParams.forEach(function(p) { if (u.searchParams.has(p)) { u.searchParams.delete(p); changed = true; } }); return changed ? u.toString() : url; } catch (e) { return url; } } function cleanLinks() { document.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } cleanLinks(); var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1) { if (node.tagName === 'A') cleanLinks(); node.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + ' Implement CPFP fee bumping for unconfirmed transactions by Camillarhi · Pull Request #638 · lightningdevkit/ldk-node · GitHub
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
23 changes: 23 additions & 0 deletions src/payment/onchain.rs
Original file line numberDiff line numberDiff line change
Expand Up@@ -141,4 +141,27 @@ impl OnchainPayment {
let fee_rate_opt = maybe_map_fee_rate_opt!(fee_rate);
self.wallet.bump_fee_rbf(payment_id, 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
/// * `fee_rate` - The fee rate to use for the CPFP transaction, if not provided, a reasonable fee rate is used
///
/// Returns the [`Txid`] of the newly created CPFP transaction if successful.
pub fn bump_fee_cpfp(
&self, payment_id: PaymentId, fee_rate: Option<FeeRate>,
) -> Result<Txid, Error> {
let fee_rate_opt = maybe_map_fee_rate_opt!(fee_rate);
self.wallet.bump_fee_cpfp(payment_id, fee_rate_opt)
}
}
113 changes: 113 additions & 0 deletions src/wallet/mod.rs
Original file line numberDiff line numberDiff line change
Expand Up@@ -1403,6 +1403,119 @@ impl Wallet {

Ok(new_txid)
}

#[allow(deprecated)]
pub(crate) fn bump_fee_cpfp(
&self, payment_id: PaymentId, fee_rate: Option<FeeRate>,
) -> Result<Txid, Error> {
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<u32> = None;

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);
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 = fee_rate.unwrap_or_else(|| {
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,
lightning::chain::chaininterface::TransactionType::Sweep { channels: vec![] },
)]);

log_info!(self.logger, "Created CPFP transaction {} to bump fee of {}", cpfp_txid, txid);
Ok(cpfp_txid)
}
}

impl Listen for Wallet {
Expand Down
101 changes: 101 additions & 0 deletions tests/integration_tests_rust.rs
Original file line numberDiff line numberDiff line change
Expand Up@@ -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)]
Expand DownExpand Up@@ -2809,3 +2810,103 @@ async fn splice_in_with_all_balance() {
node_a.stop().unwrap();
node_b.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 = random_chain_source(&bitcoind, &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).await;
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, None)
);

// Successful fee bump via CPFP
let new_txid = node_a.onchain_payment().bump_fee_cpfp(payment_id, None).unwrap();
wait_for_tx(&electrsd.client, new_txid).await;

// 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.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 CPFP 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, None)
);

// 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 the inbound payment (parent tx) is confirmed with the original amount.
let inbound_payment = node_a.payment(&payment_id).unwrap();
assert_eq!(inbound_payment.amount_msat, Some(amount_to_send_sats * 1000));
assert_eq!(inbound_payment.direction, PaymentDirection::Inbound);
assert_eq!(inbound_payment.status, PaymentStatus::Succeeded);

// Verify the CPFP child tx (self-spend) is also confirmed.
let cpfp_payment = node_a.payment(&new_payment_id).unwrap();
assert_eq!(cpfp_payment.direction, PaymentDirection::Outbound);
assert_eq!(cpfp_payment.status, PaymentStatus::Succeeded);
}
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Auto-enable theater mode on YouTube (function() { function tryTheater() { var btn = document.querySelector('button[aria-label="Theater mode"], ytd-player #player button[title="Theater mode"]'); if (btn && !btn.classList.contains('activated')) { btn.click(); } } // Try immediately tryTheater(); // Try after navigation (SPA) var lastUrl = location.href; setInterval(function() { if (location.href !== lastUrl) { lastUrl = location.href; setTimeout(tryTheater, 500); } }, 1000); // Also try on player load var observer = new MutationObserver(tryTheater); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' Implement CPFP fee bumping for unconfirmed transactions by Camillarhi · Pull Request #638 · lightningdevkit/ldk-node · GitHub
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
23 changes: 23 additions & 0 deletions src/payment/onchain.rs
Original file line numberDiff line numberDiff line change
Expand Up@@ -141,4 +141,27 @@ impl OnchainPayment {
let fee_rate_opt = maybe_map_fee_rate_opt!(fee_rate);
self.wallet.bump_fee_rbf(payment_id, 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
/// * `fee_rate` - The fee rate to use for the CPFP transaction, if not provided, a reasonable fee rate is used
///
/// Returns the [`Txid`] of the newly created CPFP transaction if successful.
pub fn bump_fee_cpfp(
&self, payment_id: PaymentId, fee_rate: Option<FeeRate>,
) -> Result<Txid, Error> {
let fee_rate_opt = maybe_map_fee_rate_opt!(fee_rate);
self.wallet.bump_fee_cpfp(payment_id, fee_rate_opt)
}
}
113 changes: 113 additions & 0 deletions src/wallet/mod.rs
Original file line numberDiff line numberDiff line change
Expand Up@@ -1403,6 +1403,119 @@ impl Wallet {

Ok(new_txid)
}

#[allow(deprecated)]
pub(crate) fn bump_fee_cpfp(
&self, payment_id: PaymentId, fee_rate: Option<FeeRate>,
) -> Result<Txid, Error> {
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<u32> = None;

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);
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 = fee_rate.unwrap_or_else(|| {
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,
lightning::chain::chaininterface::TransactionType::Sweep { channels: vec![] },
)]);

log_info!(self.logger, "Created CPFP transaction {} to bump fee of {}", cpfp_txid, txid);
Ok(cpfp_txid)
}
}

impl Listen for Wallet {
Expand Down
101 changes: 101 additions & 0 deletions tests/integration_tests_rust.rs
Original file line numberDiff line numberDiff line change
Expand Up@@ -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)]
Expand DownExpand Up@@ -2809,3 +2810,103 @@ async fn splice_in_with_all_balance() {
node_a.stop().unwrap();
node_b.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 = random_chain_source(&bitcoind, &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).await;
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, None)
);

// Successful fee bump via CPFP
let new_txid = node_a.onchain_payment().bump_fee_cpfp(payment_id, None).unwrap();
wait_for_tx(&electrsd.client, new_txid).await;

// 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.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 CPFP 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, None)
);

// 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 the inbound payment (parent tx) is confirmed with the original amount.
let inbound_payment = node_a.payment(&payment_id).unwrap();
assert_eq!(inbound_payment.amount_msat, Some(amount_to_send_sats * 1000));
assert_eq!(inbound_payment.direction, PaymentDirection::Inbound);
assert_eq!(inbound_payment.status, PaymentStatus::Succeeded);

// Verify the CPFP child tx (self-spend) is also confirmed.
let cpfp_payment = node_a.payment(&new_payment_id).unwrap();
assert_eq!(cpfp_payment.direction, PaymentDirection::Outbound);
assert_eq!(cpfp_payment.status, PaymentStatus::Succeeded);
}
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Remove or un-stick sticky/fixed headers that block content (function() { function unstick() { document.querySelectorAll('header, nav, [role="banner"], .header, .navbar, .sticky, .fixed-top, [style*="position: fixed"], [style*="position:sticky"]').forEach(function(el) { if (el.style.position === 'fixed' || el.style.position === 'sticky' || getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') { el.style.position = 'static'; el.style.top = 'auto'; el.style.zIndex = 'auto'; } }); } unstick(); var observer = new MutationObserver(unstick); observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] }); })(); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' Implement CPFP fee bumping for unconfirmed transactions by Camillarhi · Pull Request #638 · lightningdevkit/ldk-node · GitHub
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
23 changes: 23 additions & 0 deletions src/payment/onchain.rs
Original file line numberDiff line numberDiff line change
Expand Up@@ -141,4 +141,27 @@ impl OnchainPayment {
let fee_rate_opt = maybe_map_fee_rate_opt!(fee_rate);
self.wallet.bump_fee_rbf(payment_id, 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
/// * `fee_rate` - The fee rate to use for the CPFP transaction, if not provided, a reasonable fee rate is used
///
/// Returns the [`Txid`] of the newly created CPFP transaction if successful.
pub fn bump_fee_cpfp(
&self, payment_id: PaymentId, fee_rate: Option<FeeRate>,
) -> Result<Txid, Error> {
let fee_rate_opt = maybe_map_fee_rate_opt!(fee_rate);
self.wallet.bump_fee_cpfp(payment_id, fee_rate_opt)
}
}
113 changes: 113 additions & 0 deletions src/wallet/mod.rs
Original file line numberDiff line numberDiff line change
Expand Up@@ -1403,6 +1403,119 @@ impl Wallet {

Ok(new_txid)
}

#[allow(deprecated)]
pub(crate) fn bump_fee_cpfp(
&self, payment_id: PaymentId, fee_rate: Option<FeeRate>,
) -> Result<Txid, Error> {
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<u32> = None;

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);
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 = fee_rate.unwrap_or_else(|| {
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,
lightning::chain::chaininterface::TransactionType::Sweep { channels: vec![] },
)]);

log_info!(self.logger, "Created CPFP transaction {} to bump fee of {}", cpfp_txid, txid);
Ok(cpfp_txid)
}
}

impl Listen for Wallet {
Expand Down
101 changes: 101 additions & 0 deletions tests/integration_tests_rust.rs
Original file line numberDiff line numberDiff line change
Expand Up@@ -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)]
Expand DownExpand Up@@ -2809,3 +2810,103 @@ async fn splice_in_with_all_balance() {
node_a.stop().unwrap();
node_b.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 = random_chain_source(&bitcoind, &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).await;
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, None)
);

// Successful fee bump via CPFP
let new_txid = node_a.onchain_payment().bump_fee_cpfp(payment_id, None).unwrap();
wait_for_tx(&electrsd.client, new_txid).await;

// 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.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 CPFP 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, None)
);

// 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 the inbound payment (parent tx) is confirmed with the original amount.
let inbound_payment = node_a.payment(&payment_id).unwrap();
assert_eq!(inbound_payment.amount_msat, Some(amount_to_send_sats * 1000));
assert_eq!(inbound_payment.direction, PaymentDirection::Inbound);
assert_eq!(inbound_payment.status, PaymentStatus::Succeeded);

// Verify the CPFP child tx (self-spend) is also confirmed.
let cpfp_payment = node_a.payment(&new_payment_id).unwrap();
assert_eq!(cpfp_payment.direction, PaymentDirection::Outbound);
assert_eq!(cpfp_payment.status, PaymentStatus::Succeeded);
}
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Universal Dark Mode - works on any site (function() { var enabled = true; function applyDarkMode() { if (!enabled) return; // Create style element if it doesn't exist var style = document.getElementById('universal-dark-mode-style'); if (!style) { style = document.createElement('style'); style.id = 'universal-dark-mode-style'; document.head.appendChild(style); } // Dark mode CSS - inverts colors but preserves images/video style.textContent = ' /* Invert everything except media */ html { filter: invert(1) hue-rotate(180deg) !important; background: #1a1a2e !important; } /* Restore images, videos, iframes, canvas */ img, video, iframe, canvas, svg, picture, [style*="background-image"] { filter: invert(1) hue-rotate(180deg) !important; } /* Preserve specific elements that should not be inverted */ .no-dark-mode, .no-dark-mode *, [data-theme="light"], [data-theme="light"], .ace_editor, .ace_editor *, .CodeMirror, .CodeMirror *, .monaco-editor, .monaco-editor *, .markdown-body pre, .markdown-body pre *, .highlight, .highlight *, pre code, pre code * { filter: none !important; } /* Fix common UI elements */ .modal, .popup, .dropdown-menu, .tooltip, .popover { filter: invert(1) hue-rotate(180deg) !important; background: #2d2d44 !important; border-color: #444 !important; } /* Scrollbars */ ::-webkit-scrollbar { background: #1a1a2e !important; } ::-webkit-scrollbar-thumb { background: #444 !important; } ::-webkit-scrollbar-thumb:hover { background: #555 !important; } /* Selection */ ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; } ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; } '; } function removeDarkMode() { var style = document.getElementById('universal-dark-mode-style'); if (style) style.remove(); } // Toggle with Alt+Shift+D document.addEventListener('keydown', function(e) { if (e.altKey && e.shiftKey && e.key === 'D') { e.preventDefault(); enabled = !enabled; if (enabled) { applyDarkMode(); console.log('[Universal Dark Mode] Enabled'); } else { removeDarkMode(); console.log('[Universal Dark Mode] Disabled'); } } }); // Apply on load applyDarkMode(); // Re-apply on dynamic content var observer = new MutationObserver(function(mutations) { if (enabled && !document.getElementById('universal-dark-mode-style')) { applyDarkMode(); } }); observer.observe(document.head, { childList: true }); console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle'); })(); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })(); Implement CPFP fee bumping for unconfirmed transactions by Camillarhi · Pull Request #638 · lightningdevkit/ldk-node · GitHub
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
23 changes: 23 additions & 0 deletions src/payment/onchain.rs
Original file line numberDiff line numberDiff line change
Expand Up@@ -141,4 +141,27 @@ impl OnchainPayment {
let fee_rate_opt = maybe_map_fee_rate_opt!(fee_rate);
self.wallet.bump_fee_rbf(payment_id, 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
/// * `fee_rate` - The fee rate to use for the CPFP transaction, if not provided, a reasonable fee rate is used
///
/// Returns the [`Txid`] of the newly created CPFP transaction if successful.
pub fn bump_fee_cpfp(
&self, payment_id: PaymentId, fee_rate: Option<FeeRate>,
) -> Result<Txid, Error> {
let fee_rate_opt = maybe_map_fee_rate_opt!(fee_rate);
self.wallet.bump_fee_cpfp(payment_id, fee_rate_opt)
}
}
113 changes: 113 additions & 0 deletions src/wallet/mod.rs
Original file line numberDiff line numberDiff line change
Expand Up@@ -1403,6 +1403,119 @@ impl Wallet {

Ok(new_txid)
}

#[allow(deprecated)]
pub(crate) fn bump_fee_cpfp(
&self, payment_id: PaymentId, fee_rate: Option<FeeRate>,
) -> Result<Txid, Error> {
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<u32> = None;

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);
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 = fee_rate.unwrap_or_else(|| {
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,
lightning::chain::chaininterface::TransactionType::Sweep { channels: vec![] },
)]);

log_info!(self.logger, "Created CPFP transaction {} to bump fee of {}", cpfp_txid, txid);
Ok(cpfp_txid)
}
}

impl Listen for Wallet {
Expand Down
101 changes: 101 additions & 0 deletions tests/integration_tests_rust.rs
Original file line numberDiff line numberDiff line change
Expand Up@@ -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)]
Expand DownExpand Up@@ -2809,3 +2810,103 @@ async fn splice_in_with_all_balance() {
node_a.stop().unwrap();
node_b.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 = random_chain_source(&bitcoind, &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).await;
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, None)
);

// Successful fee bump via CPFP
let new_txid = node_a.onchain_payment().bump_fee_cpfp(payment_id, None).unwrap();
wait_for_tx(&electrsd.client, new_txid).await;

// 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.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 CPFP 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, None)
);

// 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 the inbound payment (parent tx) is confirmed with the original amount.
let inbound_payment = node_a.payment(&payment_id).unwrap();
assert_eq!(inbound_payment.amount_msat, Some(amount_to_send_sats * 1000));
assert_eq!(inbound_payment.direction, PaymentDirection::Inbound);
assert_eq!(inbound_payment.status, PaymentStatus::Succeeded);

// Verify the CPFP child tx (self-spend) is also confirmed.
let cpfp_payment = node_a.payment(&new_payment_id).unwrap();
assert_eq!(cpfp_payment.direction, PaymentDirection::Outbound);
assert_eq!(cpfp_payment.status, PaymentStatus::Succeeded);
}
Loading