Skip to content

Add splice RBF support#888

Open
jkczyz wants to merge 22 commits into
lightningdevkit:mainfrom
jkczyz:2026-04-splicing-payment-using-tx-type
Open

Add splice RBF support#888
jkczyz wants to merge 22 commits into
lightningdevkit:mainfrom
jkczyz:2026-04-splicing-payment-using-tx-type

Conversation

@jkczyz

@jkczyz jkczyz commented Apr 24, 2026

Copy link
Copy Markdown
Contributor

Adds a public Node::rbf_channel API that replaces an in-flight splice transaction with a higher-feerate version.

An RBF leaves multiple candidate splice transactions in flight; only one of them will confirm on chain, and the payment record needs to reflect whichever one did — its amount and this node's share of the fee. The transition from Pending to Succeeded should also match when the Lightning protocol actually considers the new channel state usable — the moment ChannelReady fires — rather than after ANTI_REORG_DELAY confirmations, which doesn't fit the zero-conf extreme on one end or higher-confirmation-depth peer configurations on the other.

To support those properties, this PR:

  • ties channel-funding and splice payment status to channel lifecycle instead of confirmation depth;
  • retains each candidate's contribution on the pending record until one of them confirms;
  • updates the record's amount and fee to match the confirmed candidate's contribution;
  • discards a funding payment when its channel closes without the funding ever confirming.

Every broadcast from LDK now triggers a store write. For a remote KV store, running that write on LDK's thread would block message handling for hundreds of milliseconds per call, so persistence happens asynchronously while still guaranteeing that the record is committed before the transaction reaches the chain client.

Based on #878.

@ldk-reviews-bot

ldk-reviews-bot commented Apr 24, 2026

Copy link
Copy Markdown

👋 I see @joostjager was un-assigned.
If you'd like another reviewer assignment, please click here.

@jkczyz

jkczyz commented Apr 24, 2026

Copy link
Copy Markdown
Contributor Author

@jkczyz jkczyz force-pushed the 2026-04-splicing-payment-using-tx-type branch 2 times, most recently from 198bfbf to d5b6bda Compare April 28, 2026 16:14
@tnull

tnull commented Apr 29, 2026

Copy link
Copy Markdown
Collaborator

Btw, we just landed #839 - maybe it would be nice to add splicing interop tests with CLN and/or Eclair (see #880 though), in this PR or in a follow-up?

@tnull tnull left a comment

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.

Excuse the delay. Took a first look, generally makes sense, though it's kind off odd to block the broadcast queue on persistence. Hopefully we'd never get a lot of backpressure from remote persistence, for example.

I have yet to review the (rather verbose) new code for updating/persisting the pending payments in wallet.rs in more detail.

Comment thread src/lib.rs Outdated
@tnull

tnull commented May 21, 2026

Copy link
Copy Markdown
Collaborator

@jkczyz Is this ready for review or are we waiting for any upstream changes still?

@jkczyz

jkczyz commented May 21, 2026

Copy link
Copy Markdown
Contributor Author

@jkczyz Is this ready for review or are we waiting for any upstream changes still?

Yeah, upstream has landed but this is now based on the splicing portion of #882. We can take those here if you prefer.

@jkczyz jkczyz marked this pull request as ready for review May 21, 2026 14:39
@ldk-reviews-bot ldk-reviews-bot requested a review from tankyleo May 21, 2026 14:40
@tnull

tnull commented May 21, 2026

Copy link
Copy Markdown
Collaborator

@jkczyz Is this ready for review or are we waiting for any upstream changes still?

Yeah, upstream has landed but this is now based on the splicing portion of #882. We can take those here if you prefer.

Ah, was unaware of that cross dependency, will see to review #882 next to keep things moving then.

@ldk-reviews-bot

Copy link
Copy Markdown

🔔 1st Reminder

Hey @tankyleo! This PR has been waiting for your review.
Please take a look when you have a chance. If you're unable to review, please let us know so we can find another reviewer.

@ldk-reviews-bot

Copy link
Copy Markdown

🔔 2nd Reminder

Hey @tankyleo! This PR has been waiting for your review.
Please take a look when you have a chance. If you're unable to review, please let us know so we can find another reviewer.

@ldk-reviews-bot

Copy link
Copy Markdown

🔔 3rd Reminder

Hey @tankyleo! This PR has been waiting for your review.
Please take a look when you have a chance. If you're unable to review, please let us know so we can find another reviewer.

@Camillarhi Camillarhi left a comment

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.

Thanks for this. I took a look at this, and I have a few questions. I have taken a little look at splicing in general.
For this PR, if a user calls bump_fee_rbf on a record with funding_details, the replacement is picked up as a WalletEvent::TxReplaced event on the next sync, and this will currently rewrite the funding_details as NONE. Is bumping a funding/splice payment through bump_fee_rbf meant to be possible at all, or should those records be excluded here in favor of rbf_channel? AND is overwriting an existing funding_details with None intended in the TxReplaced path?

Comment thread src/wallet/mod.rs Outdated
@ldk-reviews-bot

Copy link
Copy Markdown

🔔 4th Reminder

Hey @tankyleo! This PR has been waiting for your review.
Please take a look when you have a chance. If you're unable to review, please let us know so we can find another reviewer.

@ldk-reviews-bot

Copy link
Copy Markdown

🔔 5th Reminder

Hey @tankyleo! This PR has been waiting for your review.
Please take a look when you have a chance. If you're unable to review, please let us know so we can find another reviewer.

@ldk-reviews-bot

Copy link
Copy Markdown

🔔 6th Reminder

Hey @tankyleo! This PR has been waiting for your review.
Please take a look when you have a chance. If you're unable to review, please let us know so we can find another reviewer.

@jkczyz jkczyz force-pushed the 2026-04-splicing-payment-using-tx-type branch from c4d3282 to bec7723 Compare June 5, 2026 19:46
@jkczyz

jkczyz commented Jun 5, 2026

Copy link
Copy Markdown
Contributor Author

Rebased and dropped commits added in #912.

Ah, was unaware of that cross dependency, will see to review #882 next to keep things moving then.

Sorry, that should have said #872. Relevant commits were moved to #912, which has been merged.

Thanks for this. I took a look at this, and I have a few questions. I have taken a little look at splicing in general. For this PR, if a user calls bump_fee_rbf on a record with funding_details, the replacement is picked up as a WalletEvent::TxReplaced event on the next sync, and this will currently rewrite the funding_details as NONE. Is bumping a funding/splice payment through bump_fee_rbf meant to be possible at all, or should those records be excluded here in favor of rbf_channel? AND is overwriting an existing funding_details with None intended in the TxReplaced path?

Good catch @Camillarhi! Updated to disallow using bump_fee_rbf since that won't work for interactively constructed transactions. We need use the interactive-tx construction protocol given some signatures come from the counterparty.

Overwriting the existing funding_details is also unintended. Added a guard to prevent that.

@ldk-reviews-bot

Copy link
Copy Markdown

🔔 7th Reminder

Hey @tankyleo! This PR has been waiting for your review.
Please take a look when you have a chance. If you're unable to review, please let us know so we can find another reviewer.

@ldk-reviews-bot

Copy link
Copy Markdown

🔔 8th Reminder

Hey @tankyleo! This PR has been waiting for your review.
Please take a look when you have a chance. If you're unable to review, please let us know so we can find another reviewer.

@jkczyz jkczyz requested a review from tnull June 8, 2026 14:49
@ldk-reviews-bot

Copy link
Copy Markdown

🔔 2nd Reminder

Hey @benthecarman! This PR has been waiting for your review.
Please take a look when you have a chance. If you're unable to review, please let us know so we can find another reviewer.

@ldk-reviews-bot

Copy link
Copy Markdown

🔔 3rd Reminder

Hey @benthecarman! This PR has been waiting for your review.
Please take a look when you have a chance. If you're unable to review, please let us know so we can find another reviewer.

Comment thread src/wallet/mod.rs Outdated
Some((PaymentDirection::Inbound, amt)) => {
amount_inbound = amount_inbound.saturating_add(amt);
},
None => {},

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 we debug_assert here? Seems like a state we shouldn't be hitting. If not at least a comment would be good

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.

Currently, I don't think we can hit this since we don't allow mixed mode. But once LDK supports batched splices, we could run into this. For now, simplified this quite a bit by looking at FundingContribution::net_value and removing this case.

Comment thread src/wallet/mod.rs
self.persist_pending_payment(pending_payment)?;
},
WalletEvent::TxReplaced { txid, conflicts, .. } => {
let Some(payment_id) = self.find_payment_by_txid(txid) else {

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.

claude:

a graduated funding payment can be resurrected into the pending store, tripping the
ChainTipChanged debug_assert (PLAUSIBLE)
find_payment_by_txid now also matches the main store by Onchain txid. After
graduation, the record is gone from the pending store, so the TxReplaced guard
pending_payment_store.get(id).map_or(false, |p| p.funding_details.is_some()) is
false. A TxReplaced for the confirmed funding txid (a reorg evicting it while a
conflict exists) therefore falls through to create_pending_payment_from_tx, which
copies status = Succeeded, funding_details = None, and re-inserts it into the pending
store. The next ChainTipChanged sweep then hits
debug_assert!(status == Pending, "Non-pending payment ... found in pending store") and,
in release, the Succeeded record lingers forever. The TxReplaced arm is the only
wallet-event arm that doesn't call apply_funding_details_status_update first — mirroring
the other three arms would close the gap.

Comment thread src/wallet/mod.rs Outdated
Comment on lines +1298 to +1299
self.runtime.block_on(self.payment_store.insert_or_update(pending.details.clone()))?;
self.runtime.block_on(self.pending_payment_store.insert_or_update(pending))?;

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.

would it be better to do a join on these futures and then block on?

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.

Hmmm... if the payment_store update fails, are we ok with the pending_payment_store completing? Given this called in places where we simply log an error and continue, I wonder what the downstream consequences of that are?

Also, IIUC, if persistence fails we'll have an in-memory representation that is out-of-sync with the persisted representation, whether or not these are done sequentially.

Further, a failure here would prevent us from broadcasting the transaction, but our counterparty may still do so.

Comment thread src/tx_broadcaster.rs Outdated
On-chain payment records don't capture what a transaction was for -- a
channel open, splice, close, sweep, or a plain send. Record that
classification on each on-chain payment, derived from the type LDK
reports when broadcasting the transaction, so it survives restarts
alongside the payment.

The tag keeps only which channels a transaction relates to; amounts and
fees stay on the payment. Existing records keep decoding unchanged.

Compatible with the on-chain transaction classification proposed in lightningdevkit#791.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
jkczyz and others added 12 commits June 24, 2026 18:10
Record channel-open and splice funding transactions as on-chain payments
at broadcast, and carry them to Succeeded through ANTI_REORG_DELAY
confirmations like any other on-chain payment, instead of tying their
status to the Lightning channel lifecycle. A splice's recorded amount and
fee are this node's share of the funding contribution, which wallet sync
preserves rather than overwriting with its own view of the (possibly
multi-party) transaction.

On-chain RBF of these payments is rejected: LDK drives funding and splice
transactions, so replacing one would broadcast a transaction it isn't
tracking and, for a splice, can't re-sign.

Addresses review feedback to keep on-chain payment status confirmation-
driven rather than gated on ChannelReady.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
A splice's funding transaction can be stuck at too low a fee rate with no
way to raise it: on-chain RBF is rejected for funding transactions, and
re-issuing splice_in / splice_out errors while a splice is already
pending. Add bump_channel_funding_fee, which replaces the pending splice's
funding transaction at a higher fee rate while preserving its amount and
destination, and point the "a prior splice contribution is pending" errors
at it.

Replacing the transaction also requires signing a funding input the wallet
already treats as spent by the splice being replaced, which it would
otherwise skip after syncing.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Cover the wallet-event-driven funding payment lifecycle end to end: a
channel-open funding payment reaches Succeeded from wallet sync alone,
asserted before any ChannelReady event is drained to show payment status no
longer depends on the channel-ready signal; and a splice fee-bumped via RBF
stays a single on-chain payment that follows the winning candidate while
keeping its interactive-funding classification across the replacement.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
A splice funding payment can be fee-bumped via RBF, producing several
candidate transactions with increasing fees. The payment recorded the
last-broadcast candidate's amount and fee and kept them on confirmation, but
the candidate that actually confirms need not be the last one broadcast — so
an earlier, lower-fee candidate confirming left the payment over-reporting
its fee.

Record each candidate's amount and fee, keyed by txid, so that on
confirmation the payment reflects the candidate that actually confirmed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
splice_channel only checked the splice-out fee; also assert it is recorded
as a confirmed interactive-funding payment. Add a test that a confirmed
splice payment returns to unconfirmed when its block is reorged out,
exercising the unconfirm path for funding payments.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Contributing to an already-pending splice — e.g. adding our funds to a
counterparty-initiated splice via splice_in or splice_out — replaces the
in-flight funding transaction, so the funding template requires at least the
RBF minimum feerate. We passed our plain ChannelFunding feerate estimate, which
can sit below that minimum (it does at the regtest floor), so the contribution
was rejected with FeeRateBelowRbfMinimum.

Raise the contribution feerate to the template's RBF minimum when one applies,
capped by our max, so it can replace the pending splice. A node can therefore
now contribute to a counterparty's pending splice; the rbf_splice_channel check
that expected splice_out to fail while a splice was pending relied on this very
bug and is dropped.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The 1.5x-of-estimate funding feerate ceiling was open-coded identically in
splice_in and splice_out. Route both through the max_funding_feerate helper so
the ceiling lives in one place.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@jkczyz jkczyz force-pushed the 2026-04-splicing-payment-using-tx-type branch from 328bdc3 to f2990d5 Compare June 25, 2026 01:38

@jkczyz jkczyz left a comment

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.

Now based on #937.

Comment thread src/lib.rs
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
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.

Comment thread src/lib.rs
/// # Experimental API
///
/// This API is experimental and may change in the future.
pub fn bump_channel_funding_fee(

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.

Comment thread src/lib.rs Outdated
///
/// # Experimental API
///
/// This API is experimental and may change in the future.

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, dropped. There's similar notes on the other splicing methods, but they mention payment direction.

Comment thread src/wallet/mod.rs Outdated
Some((PaymentDirection::Inbound, amt)) => {
amount_inbound = amount_inbound.saturating_add(amt);
},
None => {},

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.

Currently, I don't think we can hit this since we don't allow mixed mode. But once LDK supports batched splices, we could run into this. For now, simplified this quite a bit by looking at FundingContribution::net_value and removing this case.

Comment thread src/wallet/mod.rs Outdated
Comment on lines +1868 to +1869
// Skip broadcasts that don't move funds in or out of our on-chain wallet — e.g.
// a splice-out we initiated toward an external address.

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.

@tnull Do we skip because this wouldn't be considered "on-chain"? That is, we could be paying someone from our lightning balance?

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.

It seems that code you're referring to is no longer there? Not sure I'm understanding the context - could you reiterate the question?

Comment thread src/tx_broadcaster.rs Outdated
) -> Result<BroadcastPackage, Error> {
let wallet_opt = self.wallet.lock().expect("lock").as_ref().and_then(Weak::upgrade);
if let Some(wallet) = wallet_opt {
tokio::task::spawn_blocking(move || {

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, just had to make all the other methods it calls async.

Comment thread src/payment/pending_payment_store.rs Outdated
pub conflicting_txids: Vec<Txid>,
/// Set when the payment's transaction is an interactive-funding broadcast (channel
/// open or splice). The record transitions to [`PaymentStatus::Succeeded`] on
/// `ChannelReady` instead of after [`ANTI_REORG_DELAY`] confirmations.

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.

Discussed offline. Abandoned this approach to avoid tying payment status with channel lifecycle.

Comment thread src/wallet/mod.rs Outdated
Comment on lines +1298 to +1299
self.runtime.block_on(self.payment_store.insert_or_update(pending.details.clone()))?;
self.runtime.block_on(self.pending_payment_store.insert_or_update(pending))?;

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.

Hmmm... if the payment_store update fails, are we ok with the pending_payment_store completing? Given this called in places where we simply log an error and continue, I wonder what the downstream consequences of that are?

Also, IIUC, if persistence fails we'll have an in-memory representation that is out-of-sync with the persisted representation, whether or not these are done sequentially.

Further, a failure here would prevent us from broadcasting the transaction, but our counterparty may still do so.

Comment thread src/wallet/mod.rs Outdated
pending.details.fee_paid_msat = aggregate.fee_paid_msat;
}

// Preserve the confirmation status already on the record (set by wallet sync if

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.

I think this may be obsolete now.

@tnull

tnull commented Jun 25, 2026

Copy link
Copy Markdown
Collaborator

Now based on #937.

Note I now dropped two commits from #937 to keep PaymentDetails around for the time being (tbd whether we can still refactor it out post-#948), but shouldn't make too much of a difference approach-wise I hope?

@tnull tnull left a comment

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.

Kotlin tests are failing right now.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Status: No status

Development

Successfully merging this pull request may close these issues.

6 participants