Skip to content

Add pending SpliceDetails to ChannelDetails#4687

Open
jkczyz wants to merge 2 commits into
lightningdevkit:mainfrom
jkczyz:2026-06-splice-details-fable
Open

Add pending SpliceDetails to ChannelDetails#4687
jkczyz wants to merge 2 commits into
lightningdevkit:mainfrom
jkczyz:2026-06-splice-details-fable

Conversation

@jkczyz

@jkczyz jkczyz commented Jun 12, 2026

Copy link
Copy Markdown
Contributor

Wallets like LDK Node need to show a channel's pending splice state (e.g., when displaying channel details), but it is currently only observable by tracking events or the broadcaster's TransactionType::InteractiveFunding, neither of which can be queried on demand.

This adds an optional splice_details field to ChannelDetails exposing that state: any splice/RBF round under negotiation (status, feerate, our contribution, txid and post-splice value once known) and any negotiated candidates awaiting confirmations, along with the splice_locked txids exchanged.

The first commit refactors PendingFunding to store each round's contribution with its negotiated candidate instead of in a tail-aligned parallel list, which every consumer had to realign (and which this API initially got wrong). The on-disk format is unchanged and remains readable by LDK 0.2.

jkczyz and others added 2 commits June 12, 2026 17:47
PendingFunding tracked our splice contributions in a compact list
implicitly aligned to the tail of the negotiated candidates, with the
in-flight negotiation round's contribution as the implicit last entry.
Every consumer had to re-derive this positional relationship, which is
easy to get wrong -- e.g., attributing an in-flight round's
contribution to a completed counterparty-only candidate.

Instead, store each candidate's contribution with the candidate itself
and give the in-flight round's contribution its own field, making such
misattribution unrepresentable. The contributions still form a suffix
of the candidates -- once a round includes our contribution, every
subsequent round carries it forward (possibly feerate-adjusted) so the
splice intention is never lost -- which is now asserted when a round
completes.

The on-disk format is unchanged: the suffix invariant makes the compact
encoding lossless, so candidate fundings and contributions are still
written as parallel fields readable by LDK 0.2 and recomposed when
read, with the in-flight contribution stored under a new TLV.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
A channel may have splice attempts in progress: one under negotiation
with the counterparty and any negotiated transactions (the original
splice and any RBF replacements) waiting on confirmations. This state
was only observable through events and the broadcaster's
TransactionType::InteractiveFunding, neither of which can be queried
on demand.

Add an optional splice_details field to ChannelDetails exposing the
negotiation status and our contribution to it, the negotiated
candidates (txid, post-splice channel value, our contribution, and
confirmation progress), and the txids of any splice_locked messages
exchanged with the counterparty.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@ldk-reviews-bot

ldk-reviews-bot commented Jun 12, 2026

Copy link
Copy Markdown

👋 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 +3121 to +3130
// Data written before the in-flight round's contribution was stored separately kept it
// as the last entry while a negotiation was pending.
if negotiation_contribution.is_none() && contributions.len() > fundings.len() {
negotiation_contribution = contributions.pop();
}
// An in-flight contribution is only meaningful while its negotiation round is alive;
// rounds not surviving serialization round trips have their events handled separately.
if funding_negotiation.is_none() {
negotiation_contribution = None;
}

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.

This heuristic for recovering the in-flight contribution from LDK 0.2 data is insufficient when there are leading counterparty-only (None) candidates.

In the old format the in-flight (AwaitingSignatures) round's contribution was appended as the last entry of contributions while a negotiation was pending, but it is not in negotiated_candidates yet. Since contributions form a suffix, with K contributed prior candidates and M total prior candidates (M >= K), the old contributions length is K + 1 (the +1 being the in-flight). This heuristic only pops when K + 1 > M, i.e. only when K == M (no leading None candidates).

When M > K (at least one leading counterparty-only candidate), K + 1 <= M, so the pop is skipped and:

  • negotiation_contribution stays None (the in-flight contribution is lost), and
  • contrib_offset = M - (K+1) mis-assigns: one prior candidate at contrib_offset wrongly receives a contribution and the in-flight contribution is consumed into the candidate list.

Concrete minimal case from old 0.2 data: candidates=[None] (counterparty-only splice) followed by a we-contribute RBF round at AwaitingSignatures → old bytes are fundings=[f0], contributions=[c]. Here 1 > 1 is false, so this reads back as candidate[0].contribution = Some(c) and negotiation_contribution = None, both incorrect (should be candidate[0].contribution = None, negotiation_contribution = Some(c)). This is exactly the first-contribution-on-RBF flow exercised by the new test, and would corrupt contribution tracking / splice_funding_failed and later trip the suffix debug_assert! in splice_funding_negotiated.

The presence of an in-flight contribution in old data can't be detected by length alone here; it needs to be tied to funding_negotiation.is_some() together with the suffix structure.

@ldk-claude-review-bot

Copy link
Copy Markdown
Collaborator

Summary

Found one significant backward-compatibility bug in the refactored serialization.

  • lightning/src/ln/channel.rs:3121-3130 — The heuristic used to recover the in-flight round's contribution from LDK 0.2 data (negotiation_contribution.is_none() && contributions.len() > fundings.len()) is insufficient when there are leading counterparty-only candidates. In that case the old format's in-flight contribution length doesn't exceed the candidate count, so it is misattributed to a prior candidate and negotiation_contribution is lost. The minimal failing case (candidates=[None] + a we-contribute RBF round at AwaitingSignatures) is the first-contribution-on-RBF flow the PR's own new test exercises, and would corrupt contribution tracking on upgrade.

Cross-cutting notes

  • The new→new round trip, the reset path, and the all-candidates-contributed old→new path all reconstruct correctly; the issue is specifically old→new when contributions are a partial suffix (leading None candidates) with a persisted in-flight contribution.
  • No tests cover reading actual 0.2-format bytes for the leading-counterparty-only + in-flight-contribution scenario; adding such a serialization fixture would catch this.

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants