Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
305a54d
Bump BDK wallet dependencies
tnull Jun 12, 2026
bba9652
Use BDK mempool wallet events
tnull Jun 12, 2026
61d2b19
Track reorged on-chain payments as pending
tnull Jun 11, 2026
bc2b070
Group pending payment storage constants
tnull Jun 11, 2026
af7dc4c
Keep pending payment details internal
tnull Jun 11, 2026
9acb2ad
Co-locate pending payment indexes
tnull Jun 11, 2026
5668ca9
Store compact pending payment index records
tnull Jun 11, 2026
008b937
Pin BDK 3.1 splice fee accounting
tnull Jun 15, 2026
da75376
Preserve anchor reserve during RBF
tnull Jun 15, 2026
66711db
Classify on-chain payments with a durable transaction type
jkczyz Jun 23, 2026
06a83cf
Track channel-open and splice payments through wallet sync
jkczyz Jun 23, 2026
dc6b89f
f - sum contribution net values to classify the funding direction
jkczyz Jun 24, 2026
4da7335
f - clarify the wallet-involvement skip comment
jkczyz Jun 24, 2026
737b4b2
f - log that we skip the payment record, not the broadcast
jkczyz Jun 24, 2026
02575ed
f - make BroadcastPackage a struct built from a single broadcast call
jkczyz Jun 24, 2026
5ed2d43
Add bump_channel_funding_fee to fee-bump a pending splice
jkczyz Jun 23, 2026
b6f32aa
f - select the bump feerate from the current estimate and RBF minimum
jkczyz Jun 24, 2026
243c2be
Test funding-payment tracking through wallet sync
jkczyz Jun 23, 2026
95b3720
Report the confirmed splice candidate's fee, not the last broadcast's
jkczyz Jun 23, 2026
5b1571d
Cover splice-out classification and funding-payment reorg
jkczyz Jun 23, 2026
8d0b58b
Honor the funding template's RBF minimum feerate when splicing
jkczyz Jun 24, 2026
f2990d5
Deduplicate the funding feerate ceiling in splice_in and splice_out
jkczyz Jun 24, 2026
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
6 changes: 3 additions & 3 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -54,10 +54,10 @@ lightning-liquidity = { git = "https://github.com/lightningdevkit/rust-lightning
lightning-macros = { git = "https://github.com/lightningdevkit/rust-lightning", rev = "3dfcc4cca1866c5e5d4d4eaf3b82e09584e2ce5c" }
lightning-dns-resolver = { git = "https://github.com/lightningdevkit/rust-lightning", rev = "3dfcc4cca1866c5e5d4d4eaf3b82e09584e2ce5c" }

bdk_chain = { version = "0.23.0", default-features = false, features = ["std"] }
bdk_esplora = { version = "0.22.0", default-features = false, features = ["async-https-rustls", "tokio"]}
bdk_chain = { version = "0.23.3", default-features = false, features = ["std"] }
bdk_esplora = { version = "0.22.2", default-features = false, features = ["async-https-rustls", "tokio"]}
bdk_electrum = { version = "0.24.0", default-features = false, features = ["use-rustls-ring"]}
bdk_wallet = { version = "2.3.0", default-features = false, features = ["std", "keys-bip39"]}
bdk_wallet = { version = "3.1.0", default-features = false, features = ["std", "keys-bip39"]}

bitreq = { version = "0.3", default-features = false, features = ["async-https", "json-using-serde"] }
rustls = { version = "0.23", default-features = false }
Expand Down
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;
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
8 changes: 4 additions & 4 deletions src/io/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,10 @@ pub(crate) const PEER_INFO_PERSISTENCE_KEY: &str = "peers";
pub(crate) const PAYMENT_INFO_PERSISTENCE_PRIMARY_NAMESPACE: &str = "payments";
pub(crate) const PAYMENT_INFO_PERSISTENCE_SECONDARY_NAMESPACE: &str = "";

/// The pending payment information will be persisted under this prefix.
pub(crate) const PENDING_PAYMENT_INFO_PERSISTENCE_PRIMARY_NAMESPACE: &str = "pending_payments";
pub(crate) const PENDING_PAYMENT_INFO_PERSISTENCE_SECONDARY_NAMESPACE: &str = "";

/// The node metrics will be persisted under this key.
pub(crate) const NODE_METRICS_PRIMARY_NAMESPACE: &str = "";
pub(crate) const NODE_METRICS_SECONDARY_NAMESPACE: &str = "";
Expand Down Expand Up @@ -80,7 +84,3 @@ pub(crate) const BDK_WALLET_INDEXER_KEY: &str = "indexer";
///
/// [`StaticInvoice`]: lightning::offers::static_invoice::StaticInvoice
pub(crate) const STATIC_INVOICE_STORE_PRIMARY_NAMESPACE: &str = "static_invoices";

/// The pending payment information will be persisted under this prefix.
pub(crate) const PENDING_PAYMENT_INFO_PERSISTENCE_PRIMARY_NAMESPACE: &str = "pending_payments";
pub(crate) const PENDING_PAYMENT_INFO_PERSISTENCE_SECONDARY_NAMESPACE: &str = "";
135 changes: 129 additions & 6 deletions src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1578,7 +1578,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 @@ -1647,16 +1647,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 @@ -1757,7 +1767,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 @@ -1770,17 +1780,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 @@ -1807,6 +1827,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.

&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 Expand Up @@ -2316,12 +2407,44 @@ pub(crate) fn new_channel_anchor_reserve_sats(
})
}

/// 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.
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.
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 lightning::util::ser::{Readable, Writeable};

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);
}

#[test]
fn node_metrics_reads_legacy_rgs_snapshot_timestamp() {
// Pre-#615, `NodeMetrics` persisted `latest_rgs_snapshot_timestamp` as an optional
Expand Down
7 changes: 3 additions & 4 deletions src/payment/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,6 @@ pub(crate) mod asynchronous;
mod bolt11;
mod bolt12;
mod onchain;
pub(crate) mod pending_payment_store;
mod spontaneous;
pub(crate) mod store;
mod unified;
Expand All @@ -20,10 +19,10 @@ pub use bolt11::Bolt11Payment;
pub(crate) use bolt11::PaymentMetadata;
pub use bolt12::Bolt12Payment;
pub use onchain::OnchainPayment;
pub use pending_payment_store::PendingPaymentDetails;
pub use spontaneous::SpontaneousPayment;
pub(crate) use store::PendingPaymentDetails;
pub use store::{
ConfirmationStatus, LSPS2Parameters, PaymentDetails, PaymentDirection, PaymentKind,
PaymentStatus,
Channel, ConfirmationStatus, LSPS2Parameters, PaymentDetails, PaymentDirection, PaymentKind,
PaymentStatus, TransactionType,
};
pub use unified::{UnifiedPayment, UnifiedPaymentResult};
9 changes: 8 additions & 1 deletion src/payment/onchain.rs
Original file line number Diff line number Diff line change
Expand Up @@ -134,11 +134,18 @@ impl OnchainPayment {
/// The new transaction will have the same outputs as the original but with a
/// higher fee, resulting in faster confirmation potential.
///
/// This will respect any on-chain reserve we need to keep, i.e., won't allow to cut into
/// [`BalanceDetails::total_anchor_channels_reserve_sats`].
///
/// Returns the [`Txid`] of the new replacement transaction if successful.
///
/// [`BalanceDetails::total_anchor_channels_reserve_sats`]: crate::BalanceDetails::total_anchor_channels_reserve_sats
pub fn bump_fee_rbf(
&self, payment_id: PaymentId, fee_rate: Option<FeeRate>,
) -> Result<Txid, Error> {
let cur_anchor_reserve_sats =
crate::total_anchor_channels_reserve_sats(&self.channel_manager, &self.config);
let fee_rate_opt = maybe_map_fee_rate_opt!(fee_rate);
self.wallet.bump_fee_rbf(payment_id, fee_rate_opt)
self.wallet.bump_fee_rbf(payment_id, fee_rate_opt, cur_anchor_reserve_sats)
}
}
Loading
Loading