Skip to content

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

Open
jkczyz wants to merge 13 commits into
lightningdevkit:mainfrom
jkczyz:2026-06-splicing-restart
Open

Retry user-initiated splices across restarts and disconnects#930
jkczyz wants to merge 13 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 and a test that a promoted 0conf splice's payment record survives LDK's funding re-broadcasts; the existing splice/funding/on-chain suites continue to pass.

Based on #1049.

Generated with assistance from Claude Code (Claude Fable 5).

@ldk-reviews-bot

ldk-reviews-bot commented Jun 11, 2026

Copy link
Copy Markdown

👋 Thanks for assigning @joostjager 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.

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

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 DataStore::mutate, replacing
racy read-then-write pairs. They share one helper whose closure re-reads
the payment's status inside the critical section — only Pending payments
belong in the pending store, and a status read taken outside it can go
stale against graduation — and 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>
@jkczyz
jkczyz force-pushed the 2026-06-splicing-restart branch from cadfed5 to 1adcc98 Compare August 13, 2026 15:13
@jkczyz
jkczyz marked this pull request as ready for review August 13, 2026 15:14
@jkczyz
jkczyz requested review from joostjager and tnull August 13, 2026 15:14

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

Some more high-level/design questions and a first round of bot review.

Comment thread src/wallet/mod.rs
self.pending_payment_store
.list_filter(|p| {
p.splice_intent().is_some_and(|intent| {
// A cooperative or force close also spends the funding outpoint, and a

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.

Codex:

  1. [P1] A closing transaction can corrupt a pending splice payment.
    /home/tnull/worktrees/ldk-node/pr-930-review-20260817/src/wallet/mod.rs:2034 associates any transaction spending the old funding outpoint with an Out or Rbf splice intent. If that transaction is instead a cooperative or force close, /home/tnull/worktrees/ldk-node/pr-930-review-20260817/
    src/wallet/mod.rs:2099 can replace the splice payment’s txid while retaining its InteractiveFunding classification and figures. A failed splice may consequently appear successfully confirmed.

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.

🤖 Good catch, and it ran deeper than the probe. Two fixes:

  • The intent probe now requires the transaction to carry the intent contribution's inputs and outputs instead of matching on input shape. Every round of the splice carries the contribution forward, while a close carries none of it — previously a single-input close spending the funding outpoint matched any splice-out or bump intent.
  • The adoption itself is now gated: wallet sync only adopts a transaction into a funding record when it is the record's current txid, a classified candidate, or an unclassified round carrying the live intent's contribution (a counterparty RBF round can reach sync before classification). Anything else — e.g. a close resolved through the record's conflicting_txids — is recorded under its own txid-keyed id. That also fixes a side effect where the close's own payment record never received its confirmation because the funding record consumed the event.

Worth noting the second route predates this PR: on main, a close that races a pending splice double-spends the splice's funding transaction, lands in the record's conflicting_txids via TxReplaced, and its confirmation is adopted the same way. I put that fix in its own commit so it can be split out if we'd rather land it separately.

Comment thread src/channel/mod.rs Outdated
channel_id,
counterparty_node_id,
);
let _ = self.submit(id, &channel_id, &counterparty_node_id, intent).await;

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.

Codex:

  1. [P1] Retry-submission errors are silently swallowed.
    /home/tnull/worktrees/ldk-node/pr-930-review-20260817/src/channel/mod.rs:294 discards errors from persisting or resubmitting a retry, then suppresses the original SpliceNegotiationFailed event. The splice can remain stalled indefinitely until another restart, with no user-facing failure.

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.

🤖 The rejection branch mostly self-corrects, which is why the event was suppressed there: when funding_contributed rejects a resubmission, LDK synchronously enqueues a fresh SpliceNegotiationFailed carrying the same contribution, which re-enters this handler and bounds at MAX_SPLICE_ATTEMPTS — the give-up is surfaced then, and surfacing the current event too would report the same failure twice.

The genuinely silent branch was a failure to persist the incremented attempt count: the contribution is never handed to LDK, so no follow-up event ever fires and the splice stalls until the next restart's reconcile. submit now distinguishes the two failure modes — a persist failure gives up on the intent and lets the in-flight event surface — and logs both with channel context. The startup reconciler still discards submission errors: there's no in-flight event to gate at startup, LDK rejections surface through the same follow-up event, and a persist failure there leaves the intent for the next restart, now logged.

Comment thread src/lib.rs

/// Undoes a splice intent persisted for an originating call whose `funding_contributed` then
/// failed: restores an existing record's prior intent, or removes a freshly created record.
fn discard_splice_intent(&self, payment_id: &PaymentId, restore: Option<Option<SpliceIntent>>) {

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.

Codex:

  1. [P2] Failed cleanup can preserve an operation that the API reported as failed.
    /home/tnull/worktrees/ldk-node/pr-930-review-20260817/src/lib.rs:1754 ignores errors while removing or restoring a splice intent after funding_contributed fails. A leftover durable intent may be resubmitted at the next startup despite the original API returning ChannelSplicingFailed.

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.

🤖 Right. The undo can fail after the API already returned ChannelSplicingFailed, and the leftover intent is resubmitted by the next startup's reconcile. Persisting the intent before funding_contributed is deliberate — it's what covers a crash between the two — so the failure mode is inherent to that ordering; the problem was that it was invisible, since the store only logs a generic key-value failure. Both undo paths now log the payment id and the resubmit-after-restart consequence. The worst case is bounded meanwhile: a stale intent that keeps failing burns its attempts and surfaces SpliceNegotiationFailed after MAX_SPLICE_ATTEMPTS.

candidates: Vec<FundingTxCandidate>,
/// The splice intent to resubmit if LDK drops the splice before it locks, or `None` for a
/// non-splice payment or a splice that has locked.
splice_intent: Option<SpliceIntent>,

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.

Hmm, it's a bit odd that we now have an enum where one variant is completely splice specific, and the other that you would expect to cover only the non-splice related transactions still has splice-specific fields? I'm not sure I fully follow why we have the SpliceIntent in this variant also? If we need it, shoudl this just be a TrackedSplice or whatever variant so we can keep the 'regular' RBF tracking separate?

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.

The SpliceIntent in the Tracked variant is for splice RBF intents. If we had separated Tracked and TrackedSplice variants, there are cases where we'd initially mark a splice tx as Tracked before migrating it to TrackedSplice. Maybe this is fine though, especially if we think future LDK changes will prevent this situation? See below for Claude's full explanation.

🤖 The fields record two different observers, so they don't split by payment type: conflicting_txids is wallet sync's record of what BDK observed, while candidates is the negotiation's record of our contribution figures per RBF round. Concretely:

  • A replaced splice round does land in conflicting_txids: sync's TxReplaced handling treats a splice funding like any other wallet transaction and stores the replaced txid, plus BDK's reported conflicts, on whatever record the txid resolves to.
  • A conflict need not be a candidate: BDK reports any wallet transaction that double-spends an input, and a foreign conflict has no negotiated contribution figures, so candidates can't represent it.
  • A candidate need not be a conflict: with two bumps between wallet syncs, the middle round never gets a TxReplaced event of its own, so it exists only in candidates.

Each list can hold txids the other misses, so txid→record resolution probes both and a splice record carries both. That's also why there's no TrackedSplice variant: a splice needs conflicting_txids too, so TrackedSplice would be Tracked plus two fields — the split only spares non-splice records two empty fields. And sync can observe a splice's funding transaction before classification records it, so sync would create the record as Tracked and classification would then have to migrate it to TrackedSplice; today classification just fills in candidates. The intent is on Tracked because fee bumps need it there: a bump is a new negotiation that LDK abandons if the peer disconnects before signing, and it shares the original splice's record. I've expanded the enum docs to capture this.

Comment thread src/channel/mod.rs
use crate::types::{ChannelManager, PaymentStore, PendingPaymentStore};
use crate::Error;

/// Resubmits user-initiated splices that LDK dropped before durably recording them.

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's honestly a bit confusing that we only do this for splices now, but will still happily abandon regular funding transactions if we disconnect before LDK persists them.

If we add this, should this be more general and cover funding and maybe even closing intents as well (which are currently also left to the user to retry/escalate to force closes etc)?

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.

It's a little bit more involved for v1 opens since LDK already produces a ChannelClosed event which LDK Node passes along. Splices also are just lost where closes may be retransmitted, though there are some gaps. Would you like me to explore how involved this would be?

🤖 Fair, but each case needs a different mechanism, which is why this PR sticks to splices:

  • A v1 open dropped on disconnect can't be resumed by an intent: LDK force-closes the unfunded channel and surfaces ChannelClosed, so there is no surviving channel to hand anything back to. A stored "open intent" could only start a brand-new open from scratch — that's an auto-retry policy for a failure the user already sees, and their call to make.
  • A close needs no intent: the shutdown state is part of the persisted channel and shutdown is retransmitted on reestablish, so an interrupted close resumes by itself. A crash before the manager persists it can still lose a close, but that's the generic crash window all ChannelManager state shares — and if the message reached the peer, the peer drives the close to completion on reconnect anyway.
  • A splice is the one case that's dropped without a trace — LDK keeps the negotiation in memory only until AwaitingSignatures and abandons it on any disconnect, a normal node stop included — and that LDK can resume: the channel survives, so handing the stored contribution back picks the splice up where it left off.

Open to revisiting if we want an auto-retry policy for opens too, but that seemed like its own discussion.

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

I’m still unclear on the intended long-term architecture. Is this ldk-node state reconstruction meant to be temporary until splice intent persistence moves into ldk or do we want ldk-node to own this permanently?

And is there a way to split this PR. AI can review it no problem, but for manual review, I think 1700 lines is too much.

Comment thread src/channel/mod.rs Outdated
// `splice_channel` is a read-only probe of LDK's splice state. It fails when we already
// have a splice in flight (a held contribution, an in-progress negotiation, or one
// awaiting signatures), all of which LDK drives to completion on its own.
let template = match self

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.

Using splice_channel as a read-only probe into LDK’s state, then interpreting prior_contribution() to decide whether a persisted intent was already carried out or should be retried. It feels that the LDK API for implementing (ldk-)external retries this isn't quite optimal.

Also the clear_intent logic below, it keeps feeling as if this PR should really be an LDK PR.

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.

Using splice_channel as a read-only probe into LDK’s state, then interpreting prior_contribution() to decide whether a persisted intent was already carried out or should be retried. It feels that the LDK API for implementing (ldk-)external retries this isn't quite optimal.

We could use SpliceDetails instead. Added a fixup. Do you think think is any better? It handles some missed edge cases:

🤖 Agreed the probe was the awkward part, and LDK already exposes the state it was reaching for: reconciliation now reads ChannelDetails::splice_details, which reports each round's status and our contribution directly, instead of calling splice_channel and interpreting its failures. That also fixed a corner the probe hid: prior_contribution() is None on channels that can never RBF (zero-conf), which masked an already-negotiated splice and would have resubmitted it as a duplicate.

Also the clear_intent logic below, it keeps feeling as if this PR should really be an LDK PR.

That ship has sailed already for this release.

jkczyz and others added 12 commits August 17, 2026 13:42
Review asked why `conflicting_txids` isn't exclusive to regular payments
and why the splice intent lives on `Tracked`.

Generated with assistance from Claude Code.

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.

A splice under a generated id is no longer found by the txid-derived
lookup, so it leans on find_payment_by_txid's candidate probe to map its
txids back to the record. 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 wallet sync that recorded the transaction first and a late
classification converge on one record.

The generic funding classification resolves an existing record the same
way before minting a txid-derived id: LDK re-broadcasts a
promoted-but-unconfirmed 0conf funding transaction through that path, and
the rebroadcast must merge into the record classification already created
rather than mint a duplicate.

Promotion of a pre-broadcast intent in persist_funding_payment is gated on
the payment still being Pending, read inside the pending store's critical
section like the rest of the write's decision: 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; 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.

Dropping an intent must not drop the payment tracking behind it. A crash
between classification's two store writes leaves the payment recorded while
the pending entry is still pre-broadcast, so the reconciler consults the
payment store as well as the entry itself, and clearing the intent promotes
such an entry to a tracked funding payment so the payment keeps graduating.
A payment no longer Pending already graduated and is not re-indexed.

Wallet sync or a restarted broadcast classification can see the splice
transaction while only the pre-broadcast intent records it -- the
counterparty broadcasts the transaction too, and a crash can leave the
intent as the only trace of the splice-time id -- but an intent record
carries no txids for the usual lookup to match. Teach both writers to
recognize such a transaction by the funding outpoint it spends and adopt
the splice-time id, so they converge on one record instead of 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>
…annel

Reconciliation called `splice_channel` as a read-only probe, inferring
LDK's splice state from when it errors and from `prior_contribution()`.
`ChannelDetails::splice_details` reports each round's status and our
contribution directly, so reconciliation now decides from that state
instead of an initiation API's failure modes, and the decision is
extracted into a pure, unit-tested helper.

This fixes reconciliation on zero-conf channels: `prior_contribution()`
is `None` for channels that can never RBF, hiding an already-negotiated
splice and resubmitting it as a duplicate. It also stops treating
`splice_channel` errors unrelated to splice state (channel not yet
usable, commitment point not yet available) as reasons to skip silently:
a resubmission now proceeds and any persistent failure burns attempts
until the limit abandons the intent and surfaces the failure.

Generated with assistance from Claude Code.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
A cooperative close spends the same funding outpoint a live splice
intent was created for, and its single-input shape is indistinguishable
from a splice-out or fee bump, so the intent probe resolved a close to
the splice-time PaymentId: the splice record then adopts the close's
txid and confirmation while keeping its InteractiveFunding type and
figures. Instead, require the transaction to carry the intent
contribution's inputs and outputs — every negotiated round of the
splice carries them forward, while a close carries none of them. This
subsumes the old input-count heuristic for splice-ins and extends the
same protection to splice-outs and fee bumps.

Generated with assistance from Claude Code.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
When funding_contributed rejects a splice, the API returns
ChannelSplicingFailed and discards the persisted intent — but a failure
of that undo write was silently swallowed, leaving an intent the next
startup's reconcile resubmits even though the user was told the splice
failed. The persistence layer only logs a generic key-value failure
with no splice context, so log the payment id and the
resubmit-after-restart consequence at the call site.

Generated with assistance from Claude Code.

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>
When a retriable SpliceNegotiationFailed triggered a resubmission, any
submission error was swallowed along with the event, leaving the user
unaware the splice is stalled. The two failure modes need different
handling: when LDK rejects the contribution it enqueues a fresh
SpliceNegotiationFailed that re-enters the retry logic and bounds at
MAX_SPLICE_ATTEMPTS, so suppressing the current event avoids surfacing
the same failure twice. But when persisting the incremented attempt
count fails, the contribution is never handed to LDK and no further
event will fire — give up on the intent and let the failure surface
instead of stalling silently until the next restart's reconcile.

No test exercises the persist-failure branch: the retrier's LDK
interactions make it unbuildable in unit tests, and integration tests
have no deterministic seam to fail a single pending-store write here.

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>
A splice's intent is persisted before it is handed to LDK so that a crash
between the two still resubmits it. The same ordering means a splice or fee
bump that fails synchronously must have its intent removed (or the prior
intent restored) afterwards, and if that removal cannot be persisted, the
reported failure is resubmitted by the next restart's reconciliation. Extend
the automatic-retry docs on the splice and fee-bump methods to state this,
so callers don't treat a returned error as a guarantee the operation is over.

Generated with assistance from Claude Code.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
A splice on a 0conf channel locks before its funding transaction
confirms, so LDK promotes the new funding immediately and re-broadcasts
the still-unconfirmed transaction on every monitor-update completion,
re-typed as a generic funding transaction with wallet-view figures.
Exercise the full cycle end to end: the contributing side must keep a
single record with the splice-time id, interactive-funding
classification, and contribution-derived figures through the
re-broadcasts and on to graduation, and the non-contributing side must
not record anything.

Generated with assistance from Claude Code.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Wallet sync resolves a funding payment's id for any transaction linked
to the record — through its conflicting txids or the funding outpoint
it spends — and then adopted that transaction's txid and confirmation
outright. A cooperative close conflicts with a pending splice in
exactly that way: the splice record would report the close's txid and
confirmation under its InteractiveFunding type and contribution
figures and graduate as if the splice had confirmed, while the close's
own record never received its confirmation. Adopt a transaction only
when it is part of the payment's funding history — the record's
current txid, a classified candidate, or an unclassified round
carrying the live intent's contribution (e.g. a counterparty RBF round
wallet sync sees before classification). Anything else is recorded
under its own txid-keyed id, which also delivers the close's
confirmation to the close's own record.

Generated with assistance from Claude Code.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@jkczyz
jkczyz force-pushed the 2026-06-splicing-restart branch from 1adcc98 to 8761c0c Compare August 18, 2026 01:17
@jkczyz

jkczyz commented Aug 18, 2026

Copy link
Copy Markdown
Contributor Author

I’m still unclear on the intended long-term architecture. Is this ldk-node state reconstruction meant to be temporary until splice intent persistence moves into ldk or do we want ldk-node to own this permanently?

Ideally, yes it would temporary. From my exploration with Claude it seems even the Future idea mentioned in #930 (comment) isn't sufficient. Here's its findings:

🤖 Something from the latest review round that bears on this question: keeping the intent in a separate store can't be made fully consistent with LDK, even leaving restarts aside. We persist the intent before calling funding_contributed, because that ordering covers a crash between the two. So when the call fails synchronously, we have to undo the persisted intent afterwards — and if that undo write fails, the API has already returned an error, but the intent survives and the next restart resubmits a splice the caller was told had failed (it may then succeed, or keep failing and give up after MAX_SPLICE_ATTEMPTS). Reversing the order trades this for something worse: LDK accepts the splice, we crash before our write lands, and the accepted splice is silently forgotten — per my earlier comment, nothing guarantees LDK has persisted anything by the time funding_contributed returns. Whichever side goes first, there's a window where the two disagree. I've documented the residual window on the splice methods for now.

That's really the concrete argument for your position. If funding_contributed recorded the queued contribution as part of ChannelManager persistence, accepting the splice and remembering it would be a single step. The intent store, the restart reconciliation, the undo path, and the window above would all disappear. The Future @TheBlueMatt describes fixes the "returned success isn't durable yet" half, but the caller still keeps its own copy of the intent to retry from, with the same consistency question, just one layer up. Until LDK owns that state, this PR is the loop every splicing integration would otherwise have to write for itself — and I'd be happy to help move it down into LDK for 0.4.

And is there a way to split this PR. AI can review it no problem, but for manual review, I think 1700 lines is too much.

Heh, ~700 lines are tests, but with the recent changes we're now closer to 2500 lines total. We can divided into 3 or 4 PRs, though some are "groundwork" meaning you still need to see the follow-ups to understand the context. So I'm not sure how much breaking it up really helps. I'd imagine the agents wouldn't have been able to spot some of these problems if the PR was broken up in the first place. But maybe it makes sense to do so now?

@jkczyz

jkczyz commented Aug 18, 2026

Copy link
Copy Markdown
Contributor Author

And is there a way to split this PR. AI can review it no problem, but for manual review, I think 1700 lines is too much.

Heh, ~700 lines are tests, but with the recent changes we're now closer to 2500 lines total. We can divided into 3 or 4 PRs, though some are "groundwork" meaning you still need to see the follow-ups to understand the context. So I'm not sure how much breaking it up really helps. I'd imagine the agents wouldn't have been able to spot some of these problems if the PR was broken up in the first place. But maybe it makes sense to do so now?

@joostjager Here a potential split, which also required partitioning some of the test work.

PR split plan — branch 2026-08-splice-split-prep (tip e0d4e1a)

PR 1 — payment model groundwork (~500 lines)

  • b3eb4ac Model pending payments as an enum for pre-broadcast splices
  • a3ac559 f - Document which subsystem writes each Tracked field (squash into b3eb4ac)
  • 2ed0e86 Adopt the splice-time PaymentId when classifying a splice

PR 2 — persist/resubmit across restarts + tests (~1300 lines gross)

  • 8f389a4 Persist and resubmit user-initiated splices across restarts
  • 5213ae9 f - Read splice state from SpliceDetails instead of probing splice_channel (squash into 8f389a4)
  • 04219e4 f - Match a splice intent by its contribution, not input shape (squash into 8f389a4)
  • ace725c f - Log when undoing a failed splice's intent fails (squash into 8f389a4)
  • 3fd80c9 Test splice resumption across restarts and document restart resubmission
  • 8bcef1b Test 0conf splice promotion against funding rebroadcasts

PR 3 — retry policy (~350 lines)

  • 0805047 Retry recoverable splice failures and emit only final give-up
  • ec168a9 f - Surface a splice retry whose resubmission could not be persisted (squash into 0805047)
  • 8578691 Document automatic retry of recoverable splice failures

Bugfix PR against main (independent)

  • e0d4e1a Only adopt a funding payment's own transactions from wallet sync
    • Adapt for main: drop the live-intent/tx_carries_contribution clause, which needs PR 2's intent store; PR 2 re-adds it.

After squashing: 2 + 3 + 2 clean commits across the three chained PRs.
For a 3-PR split, PR 3's commits stay on top of PR 2's branch.

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