Skip to content

Unify splice and RBF APIs#4486

Merged
wpaulino merged 7 commits intolightningdevkit:mainfrom
jkczyz:2026-03-splicing-rbf-merge
Mar 26, 2026
Merged

Unify splice and RBF APIs#4486
wpaulino merged 7 commits intolightningdevkit:mainfrom
jkczyz:2026-03-splicing-rbf-merge

Conversation

@jkczyz
Copy link
Contributor

@jkczyz jkczyz commented Mar 16, 2026

Users previously had to choose between splice_channel and rbf_channel upfront. This PR merges them into a single entry point and makes the supporting changes needed to enable that.

  • Merge rbf_channel into splice_channel. The returned FundingTemplate now carries PriorContribution (Adjusted/Unadjusted) so users can reuse their existing contribution for an RBF without new coin selection.
  • To support this, move feerate parameters from splice_channel/rbf_channel to FundingTemplate's splice methods, giving users control at coin-selection time and exposing the minimum RBF feerate via min_rbf_feerate().
  • Additionally, funding_contributed now automatically adjusts the contribution feerate upward to meet the 25/24 RBF requirement when a pending splice appears between splice_channel and funding_contributed calls, falling back to waiting for the pending splice to lock when adjustment isn't possible.

@ldk-reviews-bot
Copy link

ldk-reviews-bot commented Mar 16, 2026

👋 Thanks for assigning @wpaulino as a reviewer!
I'll wait for their review and will help manage the review process.
Once they submit their review, I'll check if a second reviewer would be helpful.

Comment on lines +13734 to +13738
if self.pending_splice.is_some() {
let tx_init_rbf = self.send_tx_init_rbf(context);
self.pending_splice.as_mut().unwrap()
.contributions.push(prior_contribution);
return Ok(Some(StfuResponse::TxInitRbf(tx_init_rbf)));
Copy link
Collaborator

Choose a reason for hiding this comment

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

This is the other half of the maybe_adjust_for_rbf bug: the RBF vs. fresh-splice decision here is purely self.pending_splice.is_some(), with no check on whether the contribution's feerate actually satisfies the 25/24 RBF minimum. When maybe_adjust_for_rbf couldn't adjust the feerate, we still land here and send tx_init_rbf with a feerate that will be rejected.

Consider checking self.can_initiate_rbf() and comparing against contribution.feerate() before choosing this path, falling back to send_splice_init when the feerate is insufficient (and the pending splice would need to lock first).

Copy link
Contributor Author

Choose a reason for hiding this comment

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

This is the other half of the maybe_adjust_for_rbf bug: the RBF vs. fresh-splice decision here is purely self.pending_splice.is_some(), with no check on whether the contribution's feerate actually satisfies the 25/24 RBF minimum. When maybe_adjust_for_rbf couldn't adjust the feerate, we still land here and send tx_init_rbf with a feerate that will be rejected.

Wrong for the same reason. We'll never send stfu in this case.

Consider checking self.can_initiate_rbf() and comparing against contribution.feerate() before choosing this path, falling back to send_splice_init when the feerate is insufficient (and the pending splice would need to lock first).

Like what we currently do by not sending stfu.

@ldk-claude-review-bot
Copy link
Collaborator

ldk-claude-review-bot commented Mar 16, 2026

Re-Review Summary

No new issues found beyond the prior review pass.

I performed a complete re-review of every changed file and hunk in the diff:

  • fuzz/src/chanmon_consistency.rs — Correct API migration, new DiscardFunding event handling appropriate for fuzzing
  • fuzz/src/full_stack.rs — Correct API migration
  • lightning/src/ln/channel.rs — All new functions (splice_funding_failed_for, build_prior_contribution, is_rbf_compatible, can_initiate_rbf, maybe_adjust_for_rbf, is_rbf_feerate_sufficient, FundingNegotiation::funding_feerate_sat_per_1000_weight, pop heuristic, stfu() re-validation, try_send_stfu feerate gate) reviewed; logic is consistent
  • lightning/src/ln/channelmanager.rshandle_quiescent_error correctly centralizes event emission; funding_contributed error handling properly factored; stfu() handler correctly separates ChannelError from QuiescentError
  • lightning/src/ln/funding.rsFundingContributionError, PriorContribution, FundingTemplate methods (rbf/rbf_sync, splice methods with feerate params), build_funding_contribution! macro changes all reviewed

Prior Comments Still Applicable (not repeated per instructions)

  • channel.rs:6824 — output filtering by script_pubkey only
  • channel.rs:2930-2931 — even TLV tags break downgrade compatibility
  • channel.rs:2964 — default feerate 0 from old serialized data
  • channel.rs:13787 — quiescent state left set on re-validation failure (cleared on disconnect)
  • channel.rs:11974,12025 — stale min_rbf_feerate from in-progress negotiation
  • channel.rs:6992 — pop heuristic relies on feerate round-tripping through u32
  • channelmanager.rs:4715FundingContribution doc link missing

@TheBlueMatt
Copy link
Collaborator

Can be rebased (and presumably undrafted) now 🎉

…plate

The user doesn't choose the feerate at splice_channel/rbf_channel time —
they choose it when performing coin selection. Moving feerate to the
FundingTemplate::splice_* methods gives users more control and lets
rbf_channel expose the minimum RBF feerate (25/24 of previous) on the
template so users can choose an appropriate feerate.

splice_channel and rbf_channel no longer take min_feerate/max_feerate.
Instead, FundingTemplate gains a min_rbf_feerate() accessor that returns
the RBF floor when applicable (from negotiated candidates or in-progress
funding negotiations). The feerate parameters move to the splice_in_sync,
splice_out_sync, and splice_in_and_out_sync methods (and their async
variants), which validate that min_feerate >= min_rbf_feerate before
coin selection.

Fee estimation documentation moves from splice_channel/rbf_channel to
funding_contributed, where the contribution (and its feerate range)
is actually provided and the splice process begins.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@jkczyz jkczyz force-pushed the 2026-03-splicing-rbf-merge branch from 1986278 to 7fb35ec Compare March 17, 2026 02:57
@jkczyz jkczyz marked this pull request as ready for review March 17, 2026 02:57
@jkczyz jkczyz requested review from TheBlueMatt and wpaulino March 17, 2026 02:57
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum PriorContribution {
/// The prior contribution's feerate meets or exceeds the minimum RBF feerate.
Adjusted(FundingContribution),
Copy link
Collaborator

Choose a reason for hiding this comment

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

I'm not convinced we need to expose this in a public API? Why shouldn't we do the adjustment when building the funding contribution instead?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

At the time, I was thinking that we should attempt it before returning the FundingTemplate since we need the channel balance to call net_value_for_initiator_at_feerate. But it seems we should just include the balance in the template instead. We can't really control that shifting whether we do the computation upfront or wait for the user to do it later.

///
/// `max_feerate` is the maximum feerate the caller is willing to accept as acceptor. It is
/// used as the returned contribution's `max_feerate` and also constrains coin selection when
/// re-running it for unadjusted prior contributions or fee-bump-only contributions.
Copy link
Collaborator

Choose a reason for hiding this comment

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

We should explicitly call out that you can RBF your counterparty's transaction, and in doing so will take over responsibility for all the fees, so to be sure to check if that's what you want.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Updated, though they would still pay for their own inputs/outputs.

.as_ref()
.and_then(|pending_splice| pending_splice.funding_negotiation.as_ref())
{
// A splice is currently being negotiated.
Copy link
Collaborator

Choose a reason for hiding this comment

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

There's some checks in can_initiate_rbf that might also apply here. eg if the channel is 0-conf we can't ever rbf.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Those will be checked when try_send_stfu is called. For the second case (i.e., negotiation fails), we'd have a min_rbf_feerate set when not needed, though. Refactored the RBF-compatibility check to be used first to avoid this.

@jkczyz jkczyz force-pushed the 2026-03-splicing-rbf-merge branch from 7fb35ec to 1189c8b Compare March 18, 2026 16:42
@jkczyz jkczyz requested a review from TheBlueMatt March 18, 2026 18:33
Some(PriorContribution { contribution, holder_balance }) => {
// The prior contribution's feerate is the negotiated feerate from the
// previous splice, which is always below the RBF minimum (negotiated + 25).
debug_assert!(contribution.feerate < rbf_feerate);
Copy link
Collaborator

Choose a reason for hiding this comment

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

Bug (debug builds): This debug_assert is incorrect and will fire after a counterparty-initiated RBF is aborted.

Scenario:

  1. Our splice completes at feerate X, contribution stored in contributions
  2. Counterparty initiates RBF at feerate Y ≥ X×25/24
  3. In tx_init_rbf handler, our prior contribution is pop()'d, adjusted to feerate Y via for_acceptor_at_feerate, and push()'d back
  4. RBF is aborted (tx_abort) — our contributions.last() now has feerate = Y
  5. User calls splice_channel()can_initiate_rbf() returns min_rbf = X×25/24
  6. build_prior_contribution() clones the contribution with feerate = Y
  7. User calls rbf_sync()rbf_feerate = X×25/24 ≤ Y → debug_assert!(Y < X×25/24) fires

In release builds the fallback path handles this correctly (the net_value_for_initiator_at_feerate call returns FeeRateTooLow and coin selection re-runs). But in debug/test builds this panics.

The comment "The prior contribution's feerate is the negotiated feerate from the previous splice, which is always below the RBF minimum" is not always true — it may be a feerate-adjusted version from a failed counterparty RBF.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Fixed and added a test.

Some(PriorContribution { contribution, holder_balance }) => {
// The prior contribution's feerate is the negotiated feerate from the
// previous splice, which is always below the RBF minimum (negotiated + 25).
debug_assert!(contribution.feerate < rbf_feerate);
Copy link
Collaborator

Choose a reason for hiding this comment

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

Same debug_assert issue as in rbf — will fire in debug builds after a counterparty-initiated RBF abort leaves a higher-feerate contribution in contributions.last(). See comment on the rbf method above for the full scenario.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Likewise.

Comment on lines +13806 to +13814
Ok(min_rbf_feerate) if contribution.feerate() < min_rbf_feerate => {
log_given_level!(
logger,
logger_level,
"Waiting for splice to lock: feerate {} below minimum RBF feerate {}",
contribution.feerate(),
min_rbf_feerate,
);
return None;
Copy link
Collaborator

Choose a reason for hiding this comment

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

Note: This gate resolves the concern from the prior review comment at line 12110 about maybe_adjust_for_rbf returning the original low-feerate contribution. When adjustment fails, the contribution is queued with feerate < min_rbf_feerate. This gate catches it: try_send_stfu returns None, preventing stfu from being sent. The splice waits until the pending splice locks (clearing pending_splice), at which point self.pending_splice.is_some() is false, the gate is skipped, and the splice proceeds as fresh via splice_init. The "wait for lock" behavior is correctly implemented here.

Similarly, the concern at line 13749 (decision purely based on pending_splice.is_some()) is safe because this gate ensures we never reach the stfu() handler at line 13745 with an insufficient feerate.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Comment added.

@jkczyz jkczyz force-pushed the 2026-03-splicing-rbf-merge branch from 1a38ece to bdfca27 Compare March 23, 2026 22:25
Comment on lines +13785 to +13793
let (contributed_inputs, contributed_outputs) =
contribution.into_contributed_inputs_and_outputs();
self.context.channel_state.clear_quiescent();
return Ok(Some(StfuResponse::SpliceFailed(SpliceFundingFailed {
funding_txo: None,
channel_type: None,
contributed_inputs,
contributed_outputs,
})));
Copy link
Collaborator

Choose a reason for hiding this comment

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

Bug: When the contribution fails re-validation at quiescence time, the DiscardFunding event includes all inputs/outputs from the contribution — even those shared with the prior locked round's splice tx.

Scenario: User calls rbf_sync() which adjusts the prior contribution (reusing the same wallet inputs). The RBF contribution fails re-validation here. into_contributed_inputs_and_outputs() returns the prior round's inputs. The DiscardFunding event tells the user those inputs can be re-spent, but they're still committed to the prior pending splice tx.

Compare with maybe_create_splice_funding_failed! (line 6753-6759) which filters prior contributions:

for input in pending_splice.prior_contributed_inputs() {
    contributed_inputs.retain(|i| *i != input);
}

Since the failing contribution was never pushed to contributions, ALL entries in contributions are prior rounds. The fix should filter against contributed_inputs() (all contributions), not prior_contributed_inputs():

Suggested change
let (contributed_inputs, contributed_outputs) =
contribution.into_contributed_inputs_and_outputs();
self.context.channel_state.clear_quiescent();
return Ok(Some(StfuResponse::SpliceFailed(SpliceFundingFailed {
funding_txo: None,
channel_type: None,
contributed_inputs,
contributed_outputs,
})));
let (mut contributed_inputs, mut contributed_outputs) =
contribution.into_contributed_inputs_and_outputs();
if let Some(pending_splice) = self.pending_splice.as_ref() {
for input in pending_splice.contributed_inputs() {
contributed_inputs.retain(|i| *i != input);
}
for output in pending_splice.contributed_outputs() {
contributed_outputs.retain(|o| o.script_pubkey != output.script_pubkey);
}
}
self.context.channel_state.clear_quiescent();
return Ok(Some(StfuResponse::SpliceFailed(SpliceFundingFailed {
funding_txo: None,
channel_type: None,
contributed_inputs,
contributed_outputs,
})));

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Yup, noticed this too and addressed now using a helper for filtering.

);
let (contributed_inputs, contributed_outputs) =
contribution.into_contributed_inputs_and_outputs();
self.context.channel_state.clear_quiescent();
Copy link
Collaborator

Choose a reason for hiding this comment

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

Bug: clear_quiescent() exits the quiescent session locally, but no message is sent to the counterparty. At this point, both sides have exchanged stfu — the counterparty is quiescent and waiting for us (the initiator) to send splice_init or tx_init_rbf. By clearing quiescent without sending anything:

  1. The counterparty remains in quiescent mode indefinitely, blocking all channel operations on their side.
  2. If our side later tries to send an update (HTLC, commitment_signed), the counterparty will reject it as a quiescence violation, causing a disconnect.

This contrasts with reset_pending_splice_state (line 6982) which also calls clear_quiescent() — but there, a tx_abort message has already been exchanged, so the counterparty has exited quiescence too.

Consider either:

  • Proceeding with splice_init anyway (letting the negotiation fail gracefully via tx_abort), or
  • Sending a warning/error message to the counterparty to signal that the quiescent session is being abandoned

Copy link
Contributor Author

Choose a reason for hiding this comment

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

@wpaulino Should we just let quiescence timeout instead of clearing here?

Copy link
Collaborator

Choose a reason for hiding this comment

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

We should presumably disconnect, at a minimum, but if there's no way to cancel a quiescence before we've initiated a quiescent action there really should be in the spec. For now just disconnect tho?

Copy link
Contributor

Choose a reason for hiding this comment

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

Disconnect immediately via ChannelError::WarnAndDisconnect rather than waiting for the timeout

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Needed to also return QuiescentError in order to emit events.

@jkczyz jkczyz force-pushed the 2026-03-splicing-rbf-merge branch from e6ceb82 to 2fc79c8 Compare March 23, 2026 23:49
Copy link
Collaborator

@TheBlueMatt TheBlueMatt left a comment

Choose a reason for hiding this comment

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

The fixups look good to me. IMO feel free to squash and we can land when @wpaulino is happy.

@jkczyz jkczyz force-pushed the 2026-03-splicing-rbf-merge branch 2 times, most recently from 89b094c to 082bc58 Compare March 24, 2026 20:54
@jkczyz jkczyz requested review from TheBlueMatt and wpaulino March 24, 2026 20:55
@jkczyz jkczyz force-pushed the 2026-03-splicing-rbf-merge branch from 082bc58 to eba82b8 Compare March 24, 2026 22:20
@jkczyz jkczyz force-pushed the 2026-03-splicing-rbf-merge branch 3 times, most recently from f625159 to fdb0b21 Compare March 25, 2026 05:45
jkczyz and others added 4 commits March 25, 2026 09:40
…uted

When splice_channel is called before a counterparty's splice exists, the
user builds a contribution at their chosen feerate without a minimum RBF feerate.
If the counterparty completes a splice before funding_contributed is
called, the contribution's feerate may be below the 25/24 RBF
requirement. Rather than always waiting for the pending splice to lock
(which would proceed as a fresh splice), funding_contributed now attempts
to adjust the contribution's feerate upward to the minimum RBF feerate when the
budget allows, enabling an immediate RBF.

When the adjustment isn't possible (max_feerate too low or insufficient
fee buffer), the contribution is left unchanged and try_send_stfu delays
until the pending splice locks, at which point the splice proceeds at the
original feerate.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Users previously had to choose between splice_channel (fresh splice) and
rbf_channel (fee bump) upfront. Since splice_channel already detects
pending splices and computes the minimum RBF feerate, rbf_channel was
redundant. Merging into a single API lets the user call one method and
discover from the returned FundingTemplate whether an RBF is possible.

The FundingTemplate now carries the user's prior contribution from the
previous splice negotiation when one is available. This lets users reuse
their existing contribution for an RBF without performing new coin
selection. A PriorContribution enum distinguishes whether the
contribution has been adjusted to the minimum RBF feerate (Adjusted) or
could not be adjusted due to insufficient fee buffer or max_feerate
constraints (Unadjusted).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
When the counterparty initiates an RBF, the prior contribution was
popped and replaced with the feerate-adjusted version. If the RBF
aborted, the adjusted version persisted, leaving a stale higher
feerate in contributions.

Change contributions to be an append-only log where each negotiation
round pushes a new entry. On abort, pop the last entry if its feerate
doesn't match the locked feerate. This naturally preserves the original
contribution as an earlier entry in the vec.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Replace opaque Err(()) returns from FundingTemplate methods with a
descriptive FundingContributionError enum. This gives callers diagnostic
information about what went wrong: feerate bounds violations, invalid
splice values, coin selection failures, or non-RBF scenarios.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@jkczyz jkczyz force-pushed the 2026-03-splicing-rbf-merge branch 2 times, most recently from 145c7b8 to 2bd0236 Compare March 25, 2026 20:23
@jkczyz
Copy link
Contributor Author

jkczyz commented Mar 25, 2026

Fuzz failure is legit, possibly caused by a547960. Investigating.

jkczyz and others added 2 commits March 25, 2026 20:20
Outbound HTLCs can be sent between funding_contributed and quiescence,
reducing the holder's balance. Re-validate the contribution when
quiescence is achieved and balances are stable. On failure, emit
SpliceFailed + DiscardFunding events and disconnect the peer so both
sides cleanly exit quiescence.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
After a few RBF attempts, both our own and the counterparty's RBF
should target a feerate that will actually confirm. Reject attempts
with feerates below the fee estimator's NonAnchorChannelFee target
to prevent exhausting the RBF budget at low feerates.

The spec requires: "MUST set a high enough feerate to ensure quick
confirmation."

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@jkczyz jkczyz force-pushed the 2026-03-splicing-rbf-merge branch from 2bd0236 to 8d00139 Compare March 26, 2026 01:26
Copy link
Collaborator

@TheBlueMatt TheBlueMatt left a comment

Choose a reason for hiding this comment

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

Two tiny nits but imo we should land this and fix in post

/// or acceptor). Rounds where we don't contribute (e.g., counterparty-only splice) do not
/// add an entry. Once non-empty, every subsequent round appends: when the counterparty
/// initiates an RBF, the last entry is adjusted to the new feerate and appended as a new
/// entry (or the RBF is rejected if the adjustment fails, in which case no round starts).
Copy link
Collaborator

Choose a reason for hiding this comment

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

This doesn't make it clear what happens if we were RBF'ing and contributing but then we RBF and remove all our contributions? (I guess also unclear if that's possible? Should be but maybe we have to RBF to remove contributions then the counterparty RBFs to be the one who pays the fee?)

Comment on lines +6966 to +6970
if let Some(pending_splice) = self.pending_splice.as_mut() {
if let Some(last) = pending_splice.contributions.last() {
let was_locked = pending_splice
.last_funding_feerate_sat_per_1000_weight
.is_some_and(|f| last.feerate() == FeeRate::from_sat_per_kwu(f as u64));
Copy link
Collaborator

Choose a reason for hiding this comment

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

It does still seem a bit weird to determine this based on looking at the feerate vs more explicit state checks. Can you at least leave a comment as to why feerate having changed is the right heuristic here.

@wpaulino wpaulino merged commit 38a62c3 into lightningdevkit:main Mar 26, 2026
22 of 23 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Status: Done

Development

Successfully merging this pull request may close these issues.

5 participants