Skip to content

Retry user-initiated splices across restarts and disconnects - #930

Draft
jkczyz wants to merge 11 commits into
lightningdevkit:mainfrom
jkczyz:2026-06-splicing-restart
Draft

Retry user-initiated splices across restarts and disconnects#930
jkczyz wants to merge 11 commits into
lightningdevkit:mainfrom
jkczyz:2026-06-splicing-restart

Conversation

@jkczyz

@jkczyz jkczyz commented Jun 11, 2026

Copy link
Copy Markdown
Contributor

LDK abandons an in-progress splice negotiation whenever the peer disconnects (which includes stopping the node) and only durably records a splice once it reaches signing. Between calling splice_in/splice_out/bump_channel_funding_fee and that point, a restart or an ill-timed disconnect silently drops the splice with no way to recover it.

This makes those splices durable and self-healing: the intent is persisted before the contribution is handed to LDK, and a startup reconciler plus a SpliceNegotiationFailed handler resubmit it (gated on LDK's NegotiationFailureReason::is_retriable) until the splice locks or is genuinely unrecoverable. As a result SpliceNegotiationFailed is now emitted only once a splice is given up on, rather than for every failed negotiation round.

Rather than adding a dedicated store, the intent lives in the existing PendingPaymentStore under a PaymentId generated at splice time — which becomes the splice's payment id, replacing the previous first-candidate-txid derivation. That required modeling the pending record as an enum, since a not-yet-broadcast splice has no funding transaction, and therefore no PaymentDetails, yet. The change is scoped to user-initiated splices; counterparty-initiated splices and V2 opens are untouched and keep the txid-derived id.

Restart resumption is covered by two integration tests (a splice-out and an RBF fee-bump), alongside unit coverage of the retry-decision matrix; the existing splice/funding/on-chain suites continue to pass.

Based on #962.

@ldk-reviews-bot

Copy link
Copy Markdown

👋 Hi! I see this is a draft PR.
I'll wait to assign reviewers until you mark it as ready for review.
Just convert it out of draft status when you're ready for review!

@jkczyz jkczyz self-assigned this Jun 11, 2026
@tnull tnull added this to the 0.8 milestone Jun 12, 2026
@jkczyz jkczyz mentioned this pull request Jun 12, 2026
@jkczyz
jkczyz force-pushed the 2026-06-splicing-restart branch 2 times, most recently from 3ae2507 to 6098d0e Compare June 15, 2026 19:46

@joostjager joostjager 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.

The PR description explains that LDK currently does not durably record a splice until signature exchange, but I don’t think it explains why ldk-node should therefore become the owner of that durability?

This seems similar to the functionality added in #882: common enough, and close enough to channel/protocol state, that it feels like it should live in LDK if we want the behavior to be durable. Persisting it in ldk-node means we now have another store tracking protocol state alongside ChannelManager/ChannelMonitor, plus reconciliation logic to infer whether LDK still has or no longer has the splice. That creates desync risk between persistence layers.

I can see ldk-node owning product policy around retries or how/when to surface failures, but the durable record of an accepted splice contribution or in-flight splice intent feels like it should be owned by LDK.

@tnull

tnull commented Jun 30, 2026

Copy link
Copy Markdown
Collaborator

This will now need a (likely rather considerable) rebase now that #888 and a few related PRs landed.

@jkczyz
jkczyz force-pushed the 2026-06-splicing-restart branch from 6098d0e to c005623 Compare July 2, 2026 15:34
@jkczyz

jkczyz commented Jul 2, 2026

Copy link
Copy Markdown
Contributor Author

Re-wrote this to use the exiting PendingPaymentStore as discussed offline. See PR description for details. @tnull Not sure what you think about making PendingPaymentDetails an enum that transitions (see 29a93ef). But high-level feedback on that design would be appreciated.

@joostjager

Copy link
Copy Markdown
Contributor

I’d still be interested in the rationale for why this needs to live in ldk-node rather than ldk.

If ldk drops in-progress splice negotiation state on disconnect or restart before signing, doesn’t every splicing integration need to persist intent, reconcile on restart, retry when appropriate, and surface final failure?

@jkczyz

jkczyz commented Jul 8, 2026

Copy link
Copy Markdown
Contributor Author

I’d still be interested in the rationale for why this needs to live in ldk-node rather than ldk.

If ldk drops in-progress splice negotiation state on disconnect or restart before signing, doesn’t every splicing integration need to persist intent, reconcile on restart, retry when appropriate, and surface final failure?

Hmm... yeah it seems we didn't fully resolve this a couple meetings ago. The conversation was mostly around whether to persist in LDK Node by payment ID or channel ID.

Currently, if we have reached quiescence but haven't exchanged signatures, LDK will opportunistically persist an Event::SpliceNegotiationFailed with NegotiationFailureReason::PeerDisconnected. This allows LDK Node to retry the splice when processing the event. However, if we haven't reached quiescence, no such event is persisted nor is the corresponding QuiescentAction::Splice, which is essentially the intent.

IIUC, even if we persisted an Event::SpliceNegotiationFailed for the QuiescentAction::Splice, a user may initiated the splice using ChannelManger::splice_channel / ChannelManager::funding_contributed, but we aren't guaranteed ChannelManager has been persisted before funding_contributed returns. Thus, if a restart happens after returning but before persistence, we've lost the intent.

cc: @TheBlueMatt @wpaulino

@TheBlueMatt

Copy link
Copy Markdown
Contributor

Right, currently we require the same downstream intent storage for outbound payments and splices (which are often outbound payments). One thing I've thought of is having those methods return a Future that completes when the channelmanager/monitors finish persistence and the intent is guaranteed to not be lost, which would then allow ldk-node to bubble that Future up to the caller and make the whole thing the caller's responsibility (which ultimately likely means the end user will be responsible for retrying if their app crashes when they were mid-payment). Its something we could explore for 0.4.

jkczyz and others added 3 commits July 28, 2026 18:45
Funding broadcasts are classified into payment records off the
broadcaster's queue, which can run after wallet sync has already recorded
the transaction -- for instance when the counterparty's broadcast of the
funding transaction is observed by wallet sync first. In that case the
classification overwrote a record wallet sync had already advanced,
downgrading a confirmed or graduated funding payment back to
unconfirmed/pending.

Merge only the classification and our contribution figures into an
existing record, leaving the confirmation state that the wallet-sync
events own in place.

Raised by Codex in the review of lightningdevkit#888.

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

Co-Authored-By: Claude <noreply@anthropic.com>
…fication

Co-Authored-By: Claude <noreply@anthropic.com>
jkczyz and others added 8 commits July 30, 2026 10:50
The keep-confirmed-figures guard overshot: when wallet sync recorded the
confirmation first, the record carries the wallet's own view of amount/fee,
which cannot represent our contribution to a shared funding output, and the
guard then discarded the late classification's correct contribution-derived
figures along with the losing-candidate updates it was meant to block. Let
an update that names the confirmed txid move the figures — it describes the
very candidate that confirmed — and have classification build its update
from the candidate matching an already-confirmed record, mirroring what
apply_funding_status_update reports when confirmation arrives after
classification. The txid comparison happens under the store's mutation lock
at apply time, so the unlocked snapshot read cannot misapply figures.

Generated with assistance from Claude Code.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Classification treated an absent pending entry as proof the payment had
graduated and only merged into existing entries. But a crash or failed
write between the payment-store and pending-store writes leaves a Pending
record with no index entry, and that state was never repaired: the payment
could no longer graduate (graduation iterates the pending store) and its
candidate txids could no longer be mapped back to the record, which for an
RBF splice invites a duplicate generic payment. Decide by the post-write
payment status instead: while the record is still Pending, insert the
missing entry (embedding the post-write record, so a confirmation wallet
sync already mirrored keeps driving graduation); once it advanced beyond
Pending, keep treating absence as graduated. A graduated payment is never
Pending, so the no-reindex rule is preserved by the status gate itself.

The repaired state is not constructible in a test: it requires a failure
injected between the two store writes, and no such seam exists. The store
primitives the decision rests on are unit-tested.

Generated with assistance from Claude Code.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Retrying a user-initiated splice across restarts requires persisting the
splice intent before handing it to LDK, which happens before negotiation
and therefore before any funding transaction exists. The pending-payment
record was built around an on-chain PaymentDetails carrying a txid, which
cannot represent a splice that has not been broadcast yet.

Reshape PendingPaymentDetails into an enum: a PendingSplice variant that
holds only the generated PaymentId and the splice intent, and a Tracked
variant that is the previous record plus an optional intent retained until
the splice locks. Add the SpliceIntent and SpliceKind types the intent
needs to resubmit or rebuild the contribution.

Wallet writes to the pending store go through a new DataStore::mutate
primitive that reads, transforms, and persists an entry under one critical
section of the mutation lock, replacing racy read-then-write pairs. The
wallet's pending-store writes share one helper whose closure promotes a
bare PendingSplice to a Tracked record once a payment exists under its id:
a plain payment-tracking merge would silently no-op against the variant,
leaving the splice invisible to txid lookups.

This is groundwork; nothing constructs a PendingSplice yet. The classify,
retry, and wiring that use it follow in subsequent commits.

Generated with assistance from Claude Code.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
A user-initiated splice will be keyed by a PaymentId generated at splice
time rather than derived from a candidate's txid, so its retry intent,
funding payment, and candidate history all share one record. Teach the
classifier to find a pre-broadcast splice intent by its channel and reuse
that id, promoting the intent record to a tracked funding payment while
preserving the intent until the splice locks. Splices we did not originate
(counterparty-initiated or V2 dual-funded opens) keep deriving the id from
the first candidate's txid via a fallback.

Also map any candidate txid back to the record in find_payment_by_txid,
since a splice under a generated id is no longer found by the txid-derived
lookup and an earlier RBF candidate may be the one that confirms. If the
intent is already gone when classification runs, the classifier probes
those same lookups for a record any candidate already created before
minting a txid-derived id, so a racing wallet sync and a late
classification converge on one record.

The pending-store write in persist_funding_payment now happens atomically
with reading the entry's prior state, and promotion of a pre-broadcast
intent is gated on the payment still being Pending: a payment that
confirmed through ANTI_REORG_DELAY before classification must not re-enter
the pending store, which graduation and rebroadcast assume holds only
Pending payments.

No splice intents are created yet, so behavior is unchanged; the splice
entry points that persist them follow.

Generated with assistance from Claude Code.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
LDK abandons an in-progress splice negotiation whenever the peer
disconnects -- which includes stopping the node -- and only durably records
a splice once its negotiation reaches signing. A splice dropped before then,
after splice_in, splice_out, or bump_channel_funding_fee returned Ok, is
therefore silently lost across a restart or an ill-timed disconnect.

Persist a splice intent before handing the contribution to LDK, keyed by a
PaymentId generated at splice time and reusing the channel's existing intent
record when one is present, so a splice and its fee bumps share one id and at
most one intent exists per channel. At startup a reconciler probes each intent
against LDK's live channel state and resubmits any LDK dropped -- including
those lost to a crash before LDK persisted anything -- surfacing
SpliceNegotiationFailed only when the channel is gone, a fee bump has nothing
left to replace, or the resubmission budget is exhausted. Resubmitting does
not require the peer to be connected: LDK holds the contribution and initiates
quiescence on reconnect.

Wallet sync can see the splice transaction before the broadcast-time
classification records it -- the counterparty broadcasts it too -- but a
pre-broadcast intent record carries no txids for the usual lookup to match.
Teach sync to recognize such a transaction by the funding outpoint it spends
and adopt the splice-time id, so both writers converge on one record instead
of sync minting a txid-derived duplicate.

A payment-tracking merge (e.g. from wallet sync) must leave a live intent
untouched. The splice tests now locate a funding payment by its candidate
txid, since its id is generated rather than derived from the funding txid.

Generated with assistance from Claude Code.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
A user-initiated splice can fail mid-negotiation while the node is running
-- the peer disconnects, or the contribution goes stale behind a competing
negotiation -- and LDK reports each such round via SpliceNegotiationFailed.

Drive those events through the splice retrier: resubmit the same
contribution when the peer merely disconnected, rebuild a fresh one when it
went stale, and give up (surfacing the failure) only for a non-retriable
reason or once the resubmission budget is exhausted, using LDK's own
is_retriable classification. Clear a splice's intent once the channel locks
its new funding or the channel closes.

Event::SpliceNegotiationFailed is now emitted only when a splice is finally
abandoned, not for every failed negotiation round, since a recoverable
failure is retried transparently.

Generated with assistance from Claude Code.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
apply_funding_status_update fetched the payment record, checked that it
was a classified funding payment, merged in the new confirmation status,
and wrote the result back as separate store operations. A classification
racing in between -- merging tx_type and the contribution-derived figures
into the record -- would be overwritten by the stale snapshot. Perform the
check and the merge under the payment store's mutation lock so no write
can interleave, and skip persisting when the merge changes nothing.

Generated with assistance from Claude Code.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Add integration coverage for resuming a dropped splice: splice_resumed_after_restart
initiates a splice-out while disconnected, restarts the node before anything is
negotiated, and asserts the reconciler resumes and completes the splice -- and that a
second restart does not resubmit the now-locked splice. splice_rbf_resumed_after_restart
does the same for a fee bump.

Also cover the id-agreement race: splice_payment_tracked_across_restart_before_lock
stops the node right after splice negotiation, lets the counterparty's broadcast
confirm while it is down, and asserts after restart that wallet sync and
classification -- landing in either order -- produce exactly one payment record,
keyed by the splice-time id rather than a txid-derived one, through to Succeeded.

Document on splice_in, splice_out, and bump_channel_funding_fee that the splice is
retried automatically across restarts until it completes or is given up on.

Generated with assistance from Claude Code.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@jkczyz
jkczyz force-pushed the 2026-06-splicing-restart branch from c005623 to 7265f8c Compare July 30, 2026 20:26
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.

5 participants