Skip to content

Track in-flight splices for failure reporting and crash recovery - #1080

Open
jkczyz wants to merge 11 commits into
lightningdevkit:mainfrom
jkczyz:2026-08-splice-tracking
Open

jkczyz wants to merge 11 commits into
lightningdevkit:mainfrom
jkczyz:2026-08-splice-tracking

Conversation

@jkczyz

@jkczyz jkczyz commented Sep 3, 2026 •

Copy link
Copy Markdown
Contributor

Persist every application-initiated splice until LDK is guaranteed to remember it, so that a startup pass can release wallet inputs held for splices lost to a crash, and failure events can say which operation failed and why.

LDK persists a splice only once negotiation reaches AwaitingSignatures; rounds short of that are failed on reload through the SpliceNegotiationFailed/DiscardFunding events a ChannelManager write records alongside itself. But a splice initiated after the last manager write leaves no trace at all — no event ever comes. Once #1037 lands and splice contributions lock wallet inputs, a splice lost in that window would leave its inputs reserved forever, with nothing left running that knows to release them.

What changes

Intent record. The splice is written into its pending payment record (from #1079) before the contribution is handed to LDK, and settled once the splice locks, its failure surfaces, or its channel closes. Each splice gets a record of its own — a channel may carry several, a pending splice and the splices queued behind it. Only a fee bump joins an existing record, replacing the intent of the round it replaces; a submission is refused while the channel carries an intent anchored at another funding.

Signing-time record, tracked. #1057 records each negotiated round into the funding payment while handling Event::FundingTransactionReadyForSigning, before funding_transaction_signed releases our tx_signatures. Here that write goes through the tracker, under the lock that serializes splice submissions, and the round is filed under the intent whose contribution it carries, so the payment moves from intent to funding under one id.

Abort on signing failure. If signing fails or LDK refuses the signed transaction, the splice is aborted through cancel_funding_contributed. LDK's DiscardFunding and SpliceNegotiationFailed then release the contribution, settle the intent, and (through #1057's handling) take back the signing-time record of the refused round. Previously the handler logged and left the negotiation dangling.

Failure events. Event::SpliceNegotiationFailed gains the failure's reason (mirroring LDK's negotiation-failure reasons) and the initiating request's parameters (In / Out / FeeBump). reason is always set; parameters is None when the failure matches no recorded intent. Both are new odd TLVs, so events written by v0.7.0 still read and v0.7.0 readers skip them.

Startup reconciliation. Runs before syncing and event processing, checking each record against the reloaded channel:

  • A round LDK still sees through on its own — AwaitingSignatures, resumed on reconnect, or Negotiated with our contribution — keeps its record. Only inputs no surviving candidate spends are released; a fee bump lost with the restart may have reserved extras.
  • No surviving round of ours, or the channel is gone: the intent is settled and the contribution released (for a gone channel, only if its record holds no signed round). A record with no funding payment behind it is removed outright; one recorded at signing time keeps its payment for Keep funding payment records accurate #1057's own handling of rounds LDK no longer holds.
  • A funding outpoint that moved while the node was down is handled as a live splice lock is: an intent LDK still holds as a queued splice is re-anchored to the new funding, any other is settled.

Recovery is silent — the initiating call already returned and the channel shows no pending splice, so no failure event is fabricated. The failures LDK replays on reload are consumed before the node is marked running.

Notes for reviewers

  • Nothing locks inputs on this base, so the input unlocks are no-ops until Fix wallet UTXO reuse for funding transactions and onchain spends #1037 lands; releasing a contribution already frees the addresses of its outputs. Fix wallet UTXO reuse for funding transactions and onchain spends #1037 also carries the DiscardFunding handler that releases a discarded contribution's inputs, and a companion commit proposed there makes coin selection stage its locks so they reach disk only with the intent record.
  • Known wart until a planned upstream LDK fix: a funding_contributed LDK refuses as a failed splice also queues a SpliceNegotiationFailed, so the call both returns an error and surfaces a failure event. For a splice-in or splice-out the event matches no intent by then; for a fee bump built from the round's own inputs it matches the intent of the round being bumped and settles it early, while the round itself is unaffected.
  • No automatic retries in this release: every failure LDK reports surfaces to the application, which re-initiates (a splice lost to a restart before LDK persisted it is dropped silently, as above). The retry engine is a post-release follow-up.
  • Signing-time recording (Keep funding payment records accurate #1057) covers splices only; V1 opens and non-funding broadcasts still record at classification, guarded by Keep funding payment records accurate #1057's retry queue.

Last in the PR stack replacing #930 for this release, per the discussion there; stacked on #1057 → #1079 (funding payment model).

Developed with assistance from Claude Code.

@jkczyz jkczyz added this to the 0.8 milestone Sep 3, 2026
@ldk-reviews-bot

ldk-reviews-bot commented Sep 3, 2026 •

Copy link
Copy Markdown

👋 I see @tnull was un-assigned.
If you'd like another reviewer assignment, please click here.

@jkczyz
jkczyz force-pushed the 2026-08-splice-tracking branch from aba7686 to 80a1b27 Compare September 4, 2026 04:16
@jkczyz
jkczyz force-pushed the 2026-08-splice-tracking branch from 80a1b27 to ca4e5fd Compare September 4, 2026 17:09
@jkczyz
jkczyz force-pushed the 2026-08-splice-tracking branch 3 times, most recently from d457713 to 74e0acd Compare September 10, 2026 19:59
@jkczyz
jkczyz force-pushed the 2026-08-splice-tracking branch 2 times, most recently from d520e2e to 8f2f58d Compare September 22, 2026 03:34
@jkczyz
jkczyz force-pushed the 2026-08-splice-tracking branch 4 times, most recently from 0767802 to 67315bb Compare September 28, 2026 17:26
@jkczyz
jkczyz force-pushed the 2026-08-splice-tracking branch from 67315bb to f597be8 Compare September 29, 2026 18:41
@jkczyz
jkczyz force-pushed the 2026-08-splice-tracking branch 5 times, most recently from ce2f247 to 3194e0c Compare October 15, 2026 13:17
@jkczyz
jkczyz force-pushed the 2026-08-splice-tracking branch 2 times, most recently from f5e5753 to 9f474a0 Compare October 20, 2026 20:56
@jkczyz
jkczyz force-pushed the 2026-08-splice-tracking branch from 9f474a0 to 9a0c982 Compare October 22, 2026 22:23
@jkczyz
jkczyz force-pushed the 2026-08-splice-tracking branch 5 times, most recently from d1d9cc8 to b347dc0 Compare October 27, 2026 17:18
@tnull

tnull commented Oct 28, 2026

Copy link
Copy Markdown
Collaborator

This needs a rebase now that #1079 landed.

jkczyz and others added 11 commits October 27, 2026 20:28
LDK only persists a splice once its negotiation reaches
AwaitingSignatures, so a splice in flight when the node stops can leave
no trace in LDK. Persist each user-initiated splice as an intent record
before its contribution is handed to LDK, so such a splice can be
recognized at the next startup -- releasing whatever the wallet still
reserves for it, which a later commit adds -- and so events about the
splice can be described in terms of the original request.

Each splice gets a record of its own, so that its failure is described
from its own intent and a restart recognizes it whatever became of the
channel's other splices: a splice queued behind a pending one negotiates
as a splice of its own once the pending one locks, and its rounds must
not be filed under the pending splice's payment. Only a fee bump joins
an existing record, that of the round it replaces; when wallet sync has
removed that record in between, as it does once the round's payment
graduates, the bump gets a record of its own rather than going out
with none. A splice is refused
while the channel carries an intent anchored at another funding -- one
the lock that superseded it failed to settle or to re-anchor -- rather
than recorded beside it.

A submission reads the channel's funding under the lock that serializes
splice submissions and anchors its intent there, not at the funding the
caller read before building the contribution: a splice locking in
between moves the funding, and an intent anchored at the old one would
never be settled by the lock that superseded it. A funding that moved
refuses a fee bump, whose round has locked, and a splice-in, whose
inputs the locked round may have spent; a splice-out carries no wallet
inputs and proceeds. A splice submitted after the previous one locked
with zero confirmations settles that splice's intent first, as the
lock's event would have: LDK promotes the funding as soon as
splice_locked is exchanged but only queues the event. The lock and close
event handlers settle intents under the same lock, so a lock handled
mid-submission cannot settle the new intent before its contribution
reaches LDK.

The record is undone when LDK rejects the hand-off synchronously and
settled once the splice locks, its failure is surfaced, or its channel
closes. A failure event settles the intent only after the event is
durably queued -- a crash in between leaves the intent for the replayed
event to settle, erring toward a duplicate report over a lost one -- and
only when the event's contribution identifies the recorded splice: a
mismatch means the failure concerns an older, superseded attempt with no
record of its own. Dropping the signed round a failure abandoned from
its record leaves the intent the record carried behind as a bare intent,
so the report can still describe the splice. A splice queued behind
another pending splice survives the pending splice's lock, so its intent
is re-anchored to the new funding rather than settled.

Failing a funding payment -- when a round other than its own locks, when
its channel closes, or when wallet sync finds its round lost to a
confirmed conflicting spend -- likewise keeps the intent its entry
carried, as a bare intent under an id of its own. LDK carries a fee bump
queued behind a round it does not overlap across that round's lock and
begins a fresh splice from it, so the intent is still needed: to
re-anchor it at the new funding, to file the fresh round under it when
signed, and to describe the failure LDK reports if the fresh negotiation
fails instead. Under the failed record's id, the fresh round would take
that record and go untracked. The lock and close handlers settle the
kept intent right after it is kept, unless LDK still tracks its splice
and the lock re-anchors it instead; one kept from wallet sync stays
anchored at the channel's unchanged funding, where a later fee bump
joins it, and the submission check does not refuse a splice on its
account. An entry without a payment record yet, failed on those
occasions under a record written for it then, keeps its intent the same
way.

Wallet state staged on a splice's behalf is flushed only after the
intent record persists, so what the wallet reserves for a splice can
never outlive the record through which a later startup would release
it. A splice that fails before the hand-off immediately releases what
the wallet reserves for it, short of what another round uses as well --
a fee bump built by adjusting the fee of the round it replaces shares
that round's inputs and change address, which stay reserved while the
round can confirm; one LDK rejects has it returned through the
DiscardFunding event instead. A lock settles an intent without
releasing anything: what the locked round did not spend, LDK returns
through the DiscardFunding events it queues at the promotion.

The round recorded when this node signs a splice round is filed under
the record of the intent carrying the round's contribution, written
while holding the lock that serializes splice submissions, so neither a
fee bump replacing the intent nor a failure settling it can interleave
with the write. Wallet sync later files the funding payment under that
id and indexes it in the pending store in the same write, so a payment
record found under a bare intent is the first half of a write that never
completed, which the pending store would never drive; settling the
intent removes it along with the entry rather than leaving a record that
could never be driven.

Developed with assistance from Claude Code.

Co-Authored-By: Elias Rohrer <dev@tnull.de>
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Written with the help of an AI tool.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The signing handler previously logged and dropped both failure paths
(with TODOs to abort once LDK supported it), leaving the negotiation
dangling until a peer disconnect abandons it.

Cancel the contributed funding instead. LDK then emits DiscardFunding,
releasing whatever the wallet reserves for the contribution, and
SpliceNegotiationFailed, which surfaces the failure and settles the
persisted intent. Cancel errors are only logged: every error case means
the splice is already beyond canceling.

When LDK refuses the already-signed transaction, the failure report that
cancelling produces also drops the round recorded at signing time from
its record: the round is gone from the channel's history and can never
be broadcast, so left in place the record would wait forever on a
payment that can never confirm.

Developed with assistance from Claude Code.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
An application handling SpliceNegotiationFailed had nothing to act on:
the event did not say why the splice failed, nor what the failed call
had attempted. Both matter for deciding what to do next — a fee bump
lost to a disconnect can simply be re-issued, while the splice it meant
to bump may still confirm at the prior feerate.

Attach a reason, mapped from LDK's NegotiationFailureReason onto an
ldk-node-owned enum so the event's serialization and bindings do not
change with LDK's, and the parameters of the originating API call,
taken from the persisted splice intent when the failure identifies it.
Both fields are optional and serialized as odd TLVs: events written by
LDK Node v0.7 read back as None, and v0.7 readers ignore the new
fields.

Developed with assistance from Claude Code.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
LDK only persists a splice once its negotiation reaches
AwaitingSignatures, so a splice in flight when the node stops can leave
no trace in LDK's channel state, and LDK never returns through an event
what the wallet reserved for it -- today the addresses its outputs pay;
once inputs are locked at coin selection, those inputs as well.

At startup, reconcile each persisted splice intent against live channel
state: release the reservations of a splice LDK no longer tracks and
drop its record, re-anchor a queued splice whose predecessor locked
while the node was down, and keep -- minus any inputs no surviving round
still claims -- those LDK resumes on its own. A splice whose channel
closed meanwhile is released only if its rounds never reached signing: a
signed round is one the channel's monitor watches until the close
matures, and what it reserved is spent by it or returned through
DiscardFunding then. Reconciliation holds the lock that serializes
splice submissions, as the event handlers settling intents do.

Recovery fabricates no failure event for a splice lost this way: the
initiating call already returned, and the channel simply no longer
shows a pending splice. LDK itself reports the loss of a contribution
it was still queueing or negotiating when it was last persisted -- it
fails the contribution as it is written and replays the failure at
startup. The replay runs after reconciliation, so that report carries
the splice's parameters only where reconciliation kept the intent: for
a splice queued behind a pending one of ours, or a fee bump of one, but
not for a channel's only splice, whose intent reconciliation settled.

Reconciliation runs before background syncing and broadcasting start,
so the stale reservations are gone before anything can act on them.
Events LDK replays from its last persisted state (e.g. a DiscardFunding
for a splice that died before the node stopped) are likewise consumed
before the node is running, so they cannot act on state a new user
operation set up since.

Developed with assistance from Claude Code.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Written with the help of an AI tool.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
A disconnect during the interactive negotiation fails the splice with
PeerDisconnected. The first test asserts that exactly one
SpliceNegotiationFailed reaches the user -- carrying the reason and the
originating request's parameters -- and that a new splice initiated
afterwards completes with a single funding payment. The window only
exists mid-negotiation: a contribution still queued at disconnect is
resumed by LDK itself on reconnect, and one awaiting signatures
survives re-establishment. The test therefore synchronizes on the
counterparty's splice_ack -- logged by LDK's peer handler -- and
stretches the negotiation by funding the splice from many small UTXOs,
each of which adds an interactive-tx round trip.

A splice dropped by a restart is recovered silently: startup
reconciliation releases what the wallet reserved and drops the record
without fabricating a failure event. What does reach the user is the
failure LDK persisted at shutdown and replays at startup -- once, with
parameters only when it still matches a kept record. The restart tests
cover both cases: a dropped splice-out surfaces without parameters and
a further restart stays silent, while a dropped fee bump -- whose record
reconciliation keeps, since LDK still tracks the negotiated splice --
surfaces with the bump's parameters. In both, the application
re-initiates and the splice completes. A splice confirmed while its
node was offline keeps exactly one payment record under its splice-time
id across the restart.

Three more cases: a second splice submitted right after a zero-conf lock
gets a record of its own rather than being folded into the record of the
splice that just locked; a queued splice the node stopped on, which LDK
fails as it shuts down, is reported at startup with its parameters --
its record, an intent that never became a payment, outlives the pending
splice's graduation, and reconciliation keeps it while LDK still tracks
that splice; and a funding record left half-written by a stop between
the two stores of the write that files it is dropped at the next startup
instead of lingering as a payment that is never indexed.

The existing test of a signed round the channel's monitor never watched
asserts the reason the node surfaces at the force-close, ChannelClosing,
and that no parameters come with it, the intent having been cleared at
the close.

Developed with assistance from Claude Code.

Co-Authored-By: Elias Rohrer <dev@tnull.de>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Recording what a signed interactive funding round is, and what this
node's share of it comes to, can be refused for want of room, and the
signing went ahead regardless. The transaction was then released without
this node's contribution on record, so whoever first observed it
recorded the wallet's view of a funding output both parties own: the
whole of it read as this node's spend, a figure that is never corrected.

The round's facts are now admitted however many records the store
contains. The cap bounds what a counterparty drives by opening and
closing channels or replacing a negotiated funding; a round this node
chose to sign is not that, and its record describes the round and this
node's share of it while carrying no outputs. What can still refuse the
round is a record already as large as one record may be, which never
shrinks, so the round is reported unmeasurable and the handler cancels
the splice, as it already does where LDK refuses the signed transaction.
The failure is then the splice's rather than the node's, and the round
is never released with the wallet's view of a funding output both
parties own standing in for this node's share.

Failing the signing for the handler to replay would not do: LDK stops
handling events at the first failure and leaves the failing one at the
head of its queue, so everything behind it waits, claims on inbound
HTLCs among them, while the same pass flags the channel manager for
persistence and notifies a waiter that re-enters at once. A full store
is not a condition a replay clears: room comes only from the dropping
pass, which wants a record a year old whose channels have all resolved.

The commit introducing the refusal said its cost was a transaction
reported without a classification. That holds for a producer reporting
on something it has already released, which still only logs, and the
places restating it now distinguish the two.

Co-Authored-By: HAL 9000
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
A splice is persisted with nothing but its intent before anything about
it has been observed. Wallet sync promotes that entry once it sees the
transaction -- unless the payment has already advanced past pending, in
which case the store should not have an entry for it and the write is
declined. Declining left the bare intent where it was, so a splice that
was over went on looking like one still in flight, and the next restart
acted on it.

The intent is now taken back instead, and only while the entry still is
the bare one: a round signed or a fee bump submitted since then is live
state of its own, which the splice lifecycle has to be the one to
resolve.

Co-Authored-By: HAL 9000
A channel becoming pending reported both the output that funds it and
that the transaction is a funding of that channel. The second only
repeats the first, since a funding output is what a funding transaction
is recognised by.

It also cost something. One transaction can open several channels, and
each reports only its own output; a report stating outright what the
transaction is lists one channel, so the next channel's contradicts it
and is refused whole, taking its funding output with it. The payment
then listed one of the channels opened instead of all of them.

A channel becoming ready already reports only its funding output, so
the two backstops now agree.

Co-Authored-By: HAL 9000
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
It only forwarded to mutate_with, so that method is now pub(crate) and
the callers that await fallible reads use it directly.

Written with the help of an AI tool.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@jkczyz
jkczyz force-pushed the 2026-08-splice-tracking branch from b347dc0 to 219c46f Compare October 28, 2026 14:55
@jkczyz
jkczyz marked this pull request as ready for review October 28, 2026 14:55
@jkczyz
jkczyz requested a review from tnull October 28, 2026 14:55
@tnull

tnull commented Oct 29, 2026

Copy link
Copy Markdown
Collaborator

To start, here are a few things Claude says need/should be addressed:

Must address

  1. reconcile leaks the change and splice-out addresses of a lost queued splice (src/channel/mod.rs:188). Scenario: splice A is negotiated with our
    contribution, splice B is queued behind it, and the node stops before LDK's next manager write, so LDK has no trace of B and will never emit a
    DiscardFunding for it. At startup decide_reconcile returns Keep because A exists, and the Keep branch only calls unlock_outpoints on B's unclaimed inputs.
    B's change output, and any splice-out output to a wallet address, never reach cancel_tx. B's intent is later settled at A's lock through
    LockDecision::Settle, which by design releases nothing. Fix: in the Keep branch use both halves of unclaimed_parts against the surviving candidates, the
    same way release_contribution does in the Lost branch.
  2. clear_persisted_intent has no fallback when the bare record is promoted mid-settlement (src/channel/mod.rs:526). The mutate sees a bare record, writes
    nothing, and defers to remove_if. Wallet sync holds only the payment-stores lock, so it can file the round under that id in between; the entry then has
    details, remove_if declines, and the settled intent stays live. Until the next submit or restart heals it, place_intent can reuse the record for a bump
    and reconcile treats the splice as in flight. Fix: when remove_if returns false, run the mutate again so the in-place record_with_intent_cleared path
    applies, or do the whole decision inside one mutate_with.
  3. Unmeasurable round: the signing event is consumed even when the cancel fails (src/event.rs:2834). cancel_splice only logs an error from
    cancel_funding_contributed, and the handler then returns Ok(()), so LDK drops the FundingTransactionReadyForSigning event. If the cancel failed for any
    reason other than the negotiation already being gone, the round stays pending in LDK with our signatures withheld until a restart regenerates the event,
    and the counterparty waits on tx_signatures the whole time. Fix: have cancel_splice return the error, and return Err(ReplayEvent()) unless it is
    ChannelUnavailable. The same applies to the funding_transaction_signed error arm at src/event.rs:2851.
  4. concerns_channel ignores funding_channels (src/channel/mod.rs:875). A record written by record_signed_funding before wallet sync observed the round has
    candidates and funding_channels but no details. Once a failed bump's settlement clears its intent, concerns_channel returns false for it, so a second
    bump's place_intent cannot find the record with the tracked round and returns Fresh. The bump then carries a bare record of its own while its round is
    filed under the original record by resolve_interactive_funding_id. That contradicts the rule stated in the place_intent docs at line 947 ("intent-less
    when an earlier bump failed"). Fix: also match records whose funding_channels() lists the channel.

Should address

  1. reconcile runs the settle pass per record from a stale snapshot (src/channel/mod.rs:165). For a channel whose funding moved, each record with that channel
    triggers a full settle_superseded_intents_locked pass. Later snapshot entries still compare their stale pre_splice_funding_txo, hit continue, and never
    reach decide_reconcile or the extras unlock, even if the first pass just re-anchored them. Group records by channel, settle once per channel, then re-read
    and evaluate the survivors.
  2. Redundant second removal (src/wallet/mod.rs:3346). remove_payment_if already removed the record or found it gone, yet the !history_only branch calls
    remove_payment again. The comment above still says the record "went first", which reads like a leftover from before remove_if existed. Drop the extra
    call.
  3. Force-close and cooperative close report the same failure differently. on_channel_closed wipes all intents at ChannelClosed. For a force-close, LDK's
    ChannelClosing failures arrive after that event, so they surface with parameters: None; for a cooperative close they arrive before and carry parameters.
    The integration test pins the None case, but the parameters doc on Event::SpliceNegotiationFailed does not mention it. Either let on_negotiation_failed
    also match intents of a channel that just closed, or document the asymmetry.
  4. Splice entry points now surface PersistenceFailed (src/lib.rs, the three map_err(|e| e) sites in splice_in, splice_out and bump_channel_funding_fee).
    Previously every failure was ChannelSplicingFailed. Either map back to that or add the new error to the method docs.

@tnull
tnull removed their request for review October 29, 2026 20:44
@tnull

tnull commented Oct 29, 2026

Copy link
Copy Markdown
Collaborator

Also needs a rebase again.

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