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 @@ -124,6 +124,8 @@ interface Node {
[Throws=NodeError]
void splice_out([ByRef]UserChannelId user_channel_id, PublicKey counterparty_node_id, [ByRef]Address address, u64 splice_amount_sats);
[Throws=NodeError]
void bump_channel_funding_fee([ByRef]UserChannelId user_channel_id, PublicKey counterparty_node_id);
[Throws=NodeError]
void close_channel([ByRef]UserChannelId user_channel_id, PublicKey counterparty_node_id);
[Throws=NodeError]
void force_close_channel([ByRef]UserChannelId user_channel_id, PublicKey counterparty_node_id, string? reason);
Expand Down
2 changes: 2 additions & 0 deletions src/builder.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1618,6 +1618,8 @@ fn build_with_store_internal(
Arc::clone(&pending_payment_store),
));

tx_broadcaster.set_wallet(Arc::downgrade(&wallet));

// Initialize the KeysManager
let cur_time = SystemTime::now().duration_since(SystemTime::UNIX_EPOCH).map_err(|e| {
log_error!(logger, "Failed to get current time: {}", e);
Expand Down
25 changes: 20 additions & 5 deletions src/chain/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ use std::collections::{HashMap, HashSet};
use std::sync::{Arc, Mutex};
use std::time::Duration;

use bitcoin::{Script, Txid};
use bitcoin::{Script, Transaction, Txid};
use lightning::chain::{BlockLocator, Filter};

use crate::chain::bitcoind::{BitcoindChainSource, UtxoSourceClient};
Expand All @@ -24,7 +24,7 @@ use crate::config::{
WALLET_SYNC_INTERVAL_MINIMUM_SECS,
};
use crate::fee_estimator::OnchainFeeEstimator;
use crate::logger::{log_debug, log_info, log_trace, LdkLogger, Logger};
use crate::logger::{log_debug, log_error, log_info, log_trace, LdkLogger, Logger};
use crate::runtime::Runtime;
use crate::types::{Broadcaster, ChainMonitor, ChannelManager, DynStore, Sweeper, Wallet};
use crate::{Error, PersistedNodeMetrics};
Expand Down Expand Up @@ -453,15 +453,30 @@ impl ChainSource {
return;
}
Some(next_package) = receiver.recv() => {
// Classify funding broadcasts into payment records before sending. If
// classification fails we skip the broadcast, since broadcasting a tx we
// failed to record would leave it on-chain without a payment.
let package = match self.tx_broadcaster.classify_package(next_package).await {
Ok(package) => package,
Err(e) => {
log_error!(
tx_bcast_logger,
"Skipping broadcast: failed to persist payment records: {:?}",
e,
);
continue;
},
};
let txs: Vec<Transaction> = package.into_transactions();
match &self.kind {
ChainSourceKind::Esplora(esplora_chain_source) => {
esplora_chain_source.process_broadcast_package(next_package).await
esplora_chain_source.process_broadcast_package(txs).await
},
ChainSourceKind::Electrum(electrum_chain_source) => {
electrum_chain_source.process_broadcast_package(next_package).await
electrum_chain_source.process_broadcast_package(txs).await
},
ChainSourceKind::Bitcoind(bitcoind_chain_source) => {
bitcoind_chain_source.process_broadcast_package(next_package).await
bitcoind_chain_source.process_broadcast_package(txs).await
},
}
}
Expand Down
39 changes: 39 additions & 0 deletions src/fee_estimator.rs
Original file line number Diff line number Diff line change
Expand Up @@ -164,3 +164,42 @@ pub(crate) fn apply_post_estimation_adjustments(
_ => estimated_rate,
}
}

/// The most we are willing to pay for a channel funding transaction: `1.5x` our funding feerate
/// estimate. Used as the `max_feerate` ceiling for splices and their RBF fee bumps.
pub(crate) fn max_funding_feerate(estimate: FeeRate) -> FeeRate {
FeeRate::from_sat_per_kwu(estimate.to_sat_per_kwu() * 3 / 2)
}

/// Picks the `(target, max)` feerates for replacing a pending splice's in-flight funding
/// transaction via RBF, or `None` if the RBF can't be done within our fee ceiling.
///
/// `max` is the most we are willing to pay (see [`max_funding_feerate`]), which tracks our current
/// estimate and so may have risen or fallen since the original splice; it is never inflated to meet
/// the RBF minimum. `target` is what we actually pay — our current estimate, or the template's RBF
/// minimum if that is higher (required to replace the transaction). If that minimum exceeds `max`,
/// we can't RBF.
pub(crate) fn rbf_splice_feerates(
estimate: FeeRate, min_rbf_feerate: FeeRate,
) -> Option<(FeeRate, FeeRate)> {
let max = max_funding_feerate(estimate);
let target = estimate.max(min_rbf_feerate);
(target <= max).then_some((target, max))
}

#[cfg(test)]
mod tests {
use super::*;

#[test]
fn rbf_splice_feerates_target_and_max() {
let kwu = FeeRate::from_sat_per_kwu;
// Estimate below the RBF minimum but within our ceiling: pay the minimum to replace the
// transaction; the max stays 1.5x the estimate (never inflated) and already clears it.
assert_eq!(rbf_splice_feerates(kwu(253), kwu(278)), Some((kwu(278), kwu(253 * 3 / 2))));
// Estimate risen above the RBF minimum: pay the higher estimate, not the stale minimum.
assert_eq!(rbf_splice_feerates(kwu(500), kwu(278)), Some((kwu(500), kwu(500 * 3 / 2))));
// RBF minimum above our max (1.5x a fallen estimate): we can't RBF within our ceiling.
assert_eq!(rbf_splice_feerates(kwu(100), kwu(278)), None);
}
}
109 changes: 100 additions & 9 deletions src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -119,8 +119,6 @@ pub use bitcoin;
use bitcoin::secp256k1::PublicKey;
#[cfg(feature = "uniffi")]
pub use bitcoin::FeeRate;
#[cfg(not(feature = "uniffi"))]
use bitcoin::FeeRate;
use bitcoin::{Address, Amount, BlockHash, Network};
#[cfg(feature = "uniffi")]
pub use builder::ArcedNodeBuilder as Builder;
Expand All @@ -138,7 +136,9 @@ pub use error::Error as NodeError;
use error::Error;
pub use event::Event;
use event::{EventHandler, EventQueue};
use fee_estimator::{ConfirmationTarget, FeeEstimator, OnchainFeeEstimator};
use fee_estimator::{
max_funding_feerate, rbf_splice_feerates, ConfirmationTarget, FeeEstimator, OnchainFeeEstimator,
};
#[cfg(feature = "uniffi")]
use ffi::*;
use gossip::GossipSource;
Expand Down Expand Up @@ -1584,7 +1584,7 @@ impl Node {
{
let min_feerate =
self.fee_estimator.estimate_fee_rate(ConfirmationTarget::ChannelFunding);
let max_feerate = FeeRate::from_sat_per_kwu(min_feerate.to_sat_per_kwu() * 3 / 2);
let max_feerate = max_funding_feerate(min_feerate);

let splice_amount_sats = match splice_amount_sats {
FundingAmount::Exact { amount_sats } => amount_sats,
Expand Down Expand Up @@ -1653,16 +1653,26 @@ impl Node {
if funding_template.prior_contribution().is_some() {
log_error!(
self.logger,
"Failed to splice channel: a prior splice contribution is pending"
"Failed to splice channel: a prior splice contribution is pending; use bump_channel_funding_fee to bump its fee"
);
return Err(Error::ChannelSplicingFailed);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It says use rbf_channel instead but that function doesn't let us change the amount in/out. Some like use rbf_channel to bump fee would be more accurate. Also would be nice if this had a separate error.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed the message, but I'm not sure it justifies a new error type. Callers will be able to check SpliceDetails once lightningdevkit/rust-lightning#4687 is included. @tnull Any preference?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should that be added to the 0.3 milestone?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, added.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed the message, but I'm not sure it justifies a new error type. Callers will be able to check SpliceDetails once lightningdevkit/rust-lightning#4687 is included. @tnull Any preference?

No preference, fine to leave it as-is.

}

// When contributing to a pending splice, the funding template requires at least the RBF
// minimum feerate to replace the in-flight transaction. Use it in place of our funding
// feerate estimate when it's higher, as long as it stays within our max.
let feerate = match funding_template.min_rbf_feerate() {
Some(min_rbf_feerate) if min_rbf_feerate <= max_feerate => {
min_feerate.max(min_rbf_feerate)
},
_ => min_feerate,
};

let contribution = self
.runtime
.block_on(funding_template.splice_in(
Amount::from_sat(splice_amount_sats),
min_feerate,
feerate,
max_feerate,
Arc::clone(&self.wallet),
))
Expand Down Expand Up @@ -1763,7 +1773,7 @@ impl Node {

let min_feerate =
self.fee_estimator.estimate_fee_rate(ConfirmationTarget::ChannelFunding);
let max_feerate = FeeRate::from_sat_per_kwu(min_feerate.to_sat_per_kwu() * 3 / 2);
let max_feerate = max_funding_feerate(min_feerate);

let funding_template = self
.channel_manager
Expand All @@ -1776,17 +1786,27 @@ impl Node {
if funding_template.prior_contribution().is_some() {
log_error!(
self.logger,
"Failed to splice channel: a prior splice contribution is pending"
"Failed to splice channel: a prior splice contribution is pending; use bump_channel_funding_fee to bump its fee"
);
return Err(Error::ChannelSplicingFailed);
Comment thread
jkczyz marked this conversation as resolved.
}

// When contributing to a pending splice, the funding template requires at least the RBF
// minimum feerate to replace the in-flight transaction. Use it in place of our funding
// feerate estimate when it's higher, as long as it stays within our max.
let feerate = match funding_template.min_rbf_feerate() {
Some(min_rbf_feerate) if min_rbf_feerate <= max_feerate => {
min_feerate.max(min_rbf_feerate)
},
_ => min_feerate,
};

let outputs = vec![bitcoin::TxOut {
value: Amount::from_sat(splice_amount_sats),
script_pubkey: address.script_pubkey(),
}];
let contribution =
funding_template.splice_out(outputs, min_feerate, max_feerate).map_err(|e| {
funding_template.splice_out(outputs, feerate, max_feerate).map_err(|e| {
log_error!(self.logger, "Failed to splice channel: {}", e);
Error::ChannelSplicingFailed
})?;
Expand All @@ -1813,6 +1833,77 @@ impl Node {
}
}

/// Fee-bumps the pending splice on a channel by replacing its in-flight funding transaction
/// (RBF). The splice's amount and destination are preserved; only the fee rate is raised.
/// Errors if the channel has no pending splice to bump.
pub fn bump_channel_funding_fee(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is the plan for ldk-node to eventually monitor pending splice candidates and automatically bump them? It seems a bit awkward to make users/applications notice that a pending splice is stuck and manually call this API.

@joostjager joostjager Jun 19, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Wonder how this method can be used to bump the fee much higher and not only to the next step. Invoke repeatedly while they monitor what the current pending splice fee rate is?

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is the plan for ldk-node to eventually monitor pending splice candidates and automatically bump them? It seems a bit awkward to make users/applications notice that a pending splice is stuck and manually call this API.

Hmm, maybe. We previously considered that for regular onchain payments but for now opted to keep it manual. But indeed some auto-bumping could make sense, though we'd of course need to work out a sane API so the user can clearly limit how much they are willing to spend on fees..

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Manual is a good first step of course, even though not being able to provide a fee rate to this call might feel uncontrolled.

Eventually I don't think you can ask an end-user to make any kind of decision about how and when to RBF.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Note that we currently limit how much we'll pay as an acceptor (i.e., if we lose quiescence) to 1.5x the channel funding fee rate. This is max_feerate below. The minimum rate we use here is the least the spec requires us to bump. And we need to make sure to use our min_feereate if it is above the spec-compliant fee rate. But should we fail if this is above max_feerate?

I'd imagine yes as anything above that means we'd be overpaying based on our fee estimator. Unless of course we want to allow users to target something quicker than 12 blocks (ChannelFunding) like OnchainPayment's 6 blocks, if they are using splice-out to make a payment, for instance.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I am mainly questioning whether this blind "push the button" api is optimal. Applying the max seems like a good safe-guard for that button, and indeed, also not if you need your splice quickly.

Might be useful to think through what the end-user UI looks like for splicing, and make sure the API allows that. What does the end-user UI look like?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Depends on the timeframe, I suppose. Eventually, splicing is more of an implementation detail. Need to make an on-chain payment but your balance is in a channel? LDK Node should just splice out using OnchainPayment for the target, for instance.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, for broad end-user adoption, it's probably not an option to surface splicing. Ofc not in this PR, but it would be interesting to already think through how this can automated.

&self, user_channel_id: &UserChannelId, counterparty_node_id: PublicKey,
) -> Result<(), Error> {
let open_channels =
self.channel_manager.list_channels_with_counterparty(&counterparty_node_id);
if let Some(channel_details) =
open_channels.iter().find(|c| c.user_channel_id == user_channel_id.0)
{
let min_feerate =

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Codex:

  • Medium: /home/tnull/workspace/ldk-node-pr-888/src/lib.rs:1826 computes the current ChannelFunding fee estimate, but /home/tnull/workspace/ldk-node-pr-888/
    src/lib.rs:1845 to rbf_prior_contribution. In LDK that means “use the minimum RBF feerate”, so this API ignores the current fee estimate when fees rise. It
    can also fail if the current estimate fell enough that max_feerate = estimate * 1.5 is below the required RBF minimum. This should choose an explicit
    target like max(current_estimate, min_rbf_feerate) and set the max from that target.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, good catch. Done.

self.fee_estimator.estimate_fee_rate(ConfirmationTarget::ChannelFunding);

let funding_template = self
.channel_manager
.splice_channel(&channel_details.channel_id, &counterparty_node_id)
.map_err(|e| {
log_error!(self.logger, "Failed to RBF channel: {:?}", e);
Error::ChannelSplicingFailed
})?;

let Some(min_rbf_feerate) = funding_template.min_rbf_feerate() else {
log_error!(self.logger, "Failed to RBF channel: no pending splice to replace");
return Err(Error::ChannelSplicingFailed);
};

let Some((target_feerate, max_feerate)) =
rbf_splice_feerates(min_feerate, min_rbf_feerate)
else {
log_error!(
self.logger,
"Failed to RBF channel: the RBF minimum feerate exceeds our maximum"
);
return Err(Error::ChannelSplicingFailed);
};

let contribution = self
.runtime
.block_on(funding_template.rbf_prior_contribution(
Some(target_feerate),
max_feerate,
Arc::clone(&self.wallet),
))
.map_err(|e| {
log_error!(self.logger, "Failed to RBF channel: {}", e);
Error::ChannelSplicingFailed
})?;

self.channel_manager
.funding_contributed(
&channel_details.channel_id,
&counterparty_node_id,
contribution,
None,
)
.map_err(|e| {
log_error!(self.logger, "Failed to RBF channel: {:?}", e);
Error::ChannelSplicingFailed
})
} else {
log_error!(
self.logger,
"Channel not found for user_channel_id {} and counterparty {}",
user_channel_id,
counterparty_node_id
);
Err(Error::ChannelSplicingFailed)
}
}

/// Manually sync the LDK and BDK wallets with the current chain state and update the fee rate
/// cache.
///
Expand Down
5 changes: 3 additions & 2 deletions src/payment/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -20,10 +20,11 @@ pub use bolt11::Bolt11Payment;
pub(crate) use bolt11::PaymentMetadata;
pub use bolt12::Bolt12Payment;
pub use onchain::OnchainPayment;
pub(crate) use pending_payment_store::FundingTxCandidate;
pub(crate) use pending_payment_store::PendingPaymentDetails;
pub use spontaneous::SpontaneousPayment;
pub use store::{
ConfirmationStatus, LSPS2Parameters, PaymentDetails, PaymentDirection, PaymentKind,
PaymentStatus,
Channel, ConfirmationStatus, LSPS2Parameters, PaymentDetails, PaymentDirection, PaymentKind,
PaymentStatus, TransactionType,
};
pub use unified::{UnifiedPayment, UnifiedPaymentResult};
Loading
Loading