Skip to content

Add pending SpliceDetails to ChannelDetails#4687

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

Add pending SpliceDetails to ChannelDetails#4687
jkczyz wants to merge 8 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.

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>
@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 thread lightning/src/ln/channel.rs Outdated
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.

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 don't think this is an issue, 0.2 doesn't support RBF so it can't have multiple candidates/contributions.

@ldk-claude-review-bot

ldk-claude-review-bot commented Jun 12, 2026

Copy link
Copy Markdown
Collaborator

I've completed a thorough re-review of the current state of the PR (commit 984f4cc), tracing the redesigned serialization, the new details() construction, the pending_splice_details queued-contribution path, the prior-version splice_channel rejection, and all the negotiated_candidates/negotiation_contribution accessor changes.

No issues found.

The two issues I flagged in my earlier pass were both eliminated by the redesign and remain resolved in this commit:

  • The in-flight contribution lives in odd TLV 13 (negotiation_contribution), and the only even downgrade gate is rbf_gate (TLV 14), written exactly for multi-round RBF — the one state LDK 0.2 cannot operate.
  • The candidate list is authoritative via odd TLV 11, with the brittle length-based old→new recovery heuristic gone (TLV 3 fallback yields contribution: None).

I verified:

  • prior_contributed_inputs/outputs (channel.rs:3332/3339) now scan all negotiated_candidates contributions; this matches old semantics because both callers (maybe_splice_funding_failed, reset_pending_splice_state) only run with an active in-flight negotiation, where the in-flight contribution is the separate negotiation_contribution — so "prior" correctly excludes it.
  • All pending_funding() call sites compile against the new ExactSizeIterator return (.len() at channel.rs:8881 is the only non-iterator use), and every former .is_empty()/.iter() use was converted to negotiated_candidates()/pending_funding() correctly.
  • details() only builds confirmed_candidate when confirmations > 0, and minimum_depth().expect(...) is sound for a ready channel; splice_locked_sent is correct since sent_funding_txid is reorg-cleared.
  • The new splice_channel prior-version error path (channel.rs:12919) cleanly refuses RBF when neither feerate source is present, avoiding the feerate debug_assert.

Cross-cutting note (carried from my prior summary, not re-posted inline): the field 8 → 9 renumber of last_funding_feerate_sat_per_1000_weight relies on no released 0.2 build ever having persisted the old even field 8 for a single splice — worth a maintainer sanity-check since the reader no longer accepts field 8. The PR's upgrade_single_splice_from_0_2 test exercises this and passes, which is consistent with 0.2 not writing that field.

@ldk-reviews-bot

Copy link
Copy Markdown

🔔 1st Reminder

Hey @wpaulino! 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 lightning/src/ln/channel.rs Outdated
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
Contributor

Choose a reason for hiding this comment

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

I don't think this is an issue, 0.2 doesn't support RBF so it can't have multiple candidates/contributions.

Comment thread lightning/src/ln/channel_state.rs Outdated
@wpaulino wpaulino requested a review from TheBlueMatt June 15, 2026 18:43
@jkczyz jkczyz mentioned this pull request Jun 15, 2026
50 tasks
@jkczyz jkczyz requested a review from wpaulino June 15, 2026 23:08
Comment thread lightning/src/ln/channel.rs Outdated
Comment thread lightning/src/ln/channel_state.rs Outdated
jkczyz and others added 4 commits June 16, 2026 17:44
PendingFunding wrote our per-candidate contributions, the in-flight round's
contribution, and the last-negotiated feerate in even (required) TLVs. LDK
0.2 predates all three but does understand a single negotiated candidate,
yet any one of those even fields made it refuse the whole channel -- even a
single, non-RBF splice it can otherwise operate. The feerate in particular
is written for every negotiated splice, so even a non-contributory single
splice was refused.

Serialize the negotiated candidates so a single pending splice stays
loadable by 0.2 while RBF is refused loudly:
- TLV 3 carries only the first candidate's funding -- the single-splice
  view 0.2 reads.
- The authoritative candidate list (each funding bundled with its
  contribution) moves to the odd TLV 11, the in-flight contribution to the
  odd TLV 13, and the last-negotiated feerate to the odd TLV 9. 0.2 skips
  all three.
- An even TLV 14 is written only when there is more than one negotiation
  round (RBF), so 0.2 refuses exactly the splices it cannot operate while
  loading the rest.

Putting the contributions in odd TLVs is safe: a single splice does not
need them, and RBF -- where current relies on them -- always sets the even
gate, so 0.2 refuses such a channel rather than skipping a contribution.

Co-Authored-By: Claude Opus 4.8 (1M context) <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>
Only one negotiated candidate can confirm since they double-spend each
other, so move confirmation tracking off each candidate into a single
ConfirmedSpliceCandidate, identifying the confirming candidate by txid.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The splice_locked we send always refers to the candidate that confirmed:
it is only sent once a candidate reaches sufficient confirmations, and is
cleared if that candidate is later unconfirmed by a reorg. So
SpliceDetails::sent_splice_locked_txid was redundant with the confirmed
candidate's txid.

Replace it with a splice_locked_sent boolean on ConfirmedSpliceCandidate.
The received splice_locked stays a separate txid on SpliceDetails, as the
counterparty may consider a different candidate sufficiently confirmed than
we do.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@jkczyz jkczyz force-pushed the 2026-06-splice-details-fable branch from 30fdc02 to 3f954c5 Compare June 16, 2026 23:12
@jkczyz jkczyz requested a review from wpaulino June 16, 2026 23:13
@jkczyz jkczyz added this to the 0.3 milestone Jun 17, 2026
@ldk-reviews-bot

Copy link
Copy Markdown

🔔 1st Reminder

Hey @TheBlueMatt @wpaulino! 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.

}

#[test]
fn upgrade_single_splice_from_0_2() {

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.

Let's expand this test to also attempt an RBF after upgrading?

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.

Hmm, seems like we won't be able to RBF as we can't determine if we've contributed. At least we can't without losing our prior contributions. I added a guard to prevent this, which was previously a debug_assert on the last funding feerate.

}

#[test]
fn downgrade_single_splice_loads_on_0_2() {

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.

Can we still RBF if we negotiate the splice on 0.3, downgrade to 0.2, and upgrade back? I assume no since we're missing the contribution.

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, same as upgrading from 0.2. See other comment.

/// Note that a negotiation which has not yet reached
/// [`SpliceNegotiationStatus::AwaitingSignatures`] does not survive a restart, so this only
/// reflects in-memory negotiation state.
pub negotiation: Option<SpliceNegotiationDetails>,

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 possible to also surface the state after funding_contributed but before actual splice negotiation? This might be useful to tighten fuzz invariants.

Suggestion from #4699 (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.

Yeah, good idea. If they called funding_contributed successfully, it should show up here even if we haven't reached quiescence.

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.

Great, will then tighten the invariant SpliceDetails when this lands

jkczyz and others added 3 commits June 18, 2026 11:10
A splice contribution committed via funding_contributed sits in the channel's
quiescent action until quiescence is reached and it becomes a negotiation, so
the window between funding_contributed and the negotiation starting was not
reflected in ChannelDetails. Surface it as SpliceDetails::queued_contribution
so that state is observable (e.g. for tightening fuzz invariants).

It is reported independently of any in-progress negotiation, as the acceptor
may queue its own contribution while a counterparty-initiated splice is in
flight. Whether we become the initiator is not settled until quiescence, so no
initiator flag is exposed. A debug assertion records that a queued splice
never coexists with a negotiation we initiated.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add tests exercising the 0.2/current wire boundary for pending splices:
- A current node with a single pending splice (whether or not we
  contributed to it) is loadable by LDK 0.2.
- A current node with a splice under RBF is refused by 0.2 via the even
  RBF-gate TLV.
- A single pending splice written by 0.2 is read by current with no
  contribution recorded, since 0.2 never tracked one.

The downgrade reload configs enable anchors so 0.2 accepts the current
channel type rather than refusing it before the splice state is reached.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…rsion

A pending splice negotiated before an upgrade from a prior LDK version
(e.g. 0.2) returns without its feerate or our contribution: 0.2 persists
neither and drops the odd TLVs that carry them. splice_channel derives the
RBF feerate floor from the prior splice's feerate and assumed it was always
present via a debug assertion, so attempting to splice such a channel
tripped that assertion.

Return a clean error instead, refusing to splice a channel whose pending
splice lacks the metadata to reconstruct the prior request.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@jkczyz jkczyz force-pushed the 2026-06-splice-details-fable branch from 22eba7d to 984f4cc Compare June 18, 2026 16:12
@jkczyz jkczyz requested a review from joostjager June 18, 2026 16:13
@jkczyz jkczyz requested a review from wpaulino June 18, 2026 16:13
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants