Skip to content

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

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

Track in-flight splices for failure reporting and crash recovery#1080
jkczyz wants to merge 14 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.

Wallet sync resolves a funding payment's id for any transaction linked
to the record through its conflicting txids, 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 or a classified
candidate. 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 added this to the 0.8 milestone Sep 3, 2026
@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 and others added 13 commits September 10, 2026 12:37
A queued broadcast whose payment-record classification failed was
dropped outright, on the theory that broadcasting a transaction we
failed to record would leave it on-chain without a payment. For
interactive funding that theory doesn't hold: the counterparty
broadcasts the same transaction once the signature exchange completes,
so dropping the package keeps nothing off-chain -- it only guarantees
the round is never recorded as a candidate on our side. The
funding-status ownership gate then treats the round's confirmation as
foreign to the funding record and re-keys it to a stray duplicate
record, which shadows the funding record's txid lookups permanently:
the splice payment stays Pending forever while an untyped duplicate
holds the confirmation.

Keep the package alive instead: retry classification after a short
delay, holding the broadcast back until it succeeds. Other packages
keep flowing while a retry waits, and pending retries are dropped when
the node stops -- a retry that outlived a stop would classify and
broadcast a stale package after a later start. Classification failures
are persistence failures, so there is no limit on attempts -- a store
that never recovers keeps the node from functioning anyway -- and
every failed round is logged.

The waiting packages are deduplicated and bounded. LDK re-broadcasts
pending claims every 30 seconds and regenerates sweeps once per block
until they confirm, so over a long store outage a copy per rebroadcast
would otherwise pile up and replay as a burst on recovery. A package
whose transactions already await a retry is not queued again. At the
bound, an incoming package that LDK would re-broadcast anyway makes room
by dropping the oldest such waiting package, whose transactions return
with the next rebroadcast; if every waiting package is one nothing
re-broadcasts, the incoming package is dropped instead. Fundings and
cooperative closes are never dropped to make room and never refused at
the bound, since nothing re-broadcasts them: a dropped funding would
leave its transaction confirming without a recorded candidate, and a
dropped cooperative close might lose the only copy of the signed closing
transaction. Fee-bumped rebroadcasts carry new txids, so the bound, not
the deduplication, is what limits their accumulation.

Generated with assistance from Claude Code.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Since declining to adopt a conflicting close's confirmation, a funding
payment whose transaction was double-spent stayed Pending forever --
nothing wrote a terminal status for an on-chain record -- and the sync
loop kept re-queueing the dead transaction for rebroadcast on every
tip change.

Mark such a record Failed once a conflict from outside its candidate
history has confirmed through ANTI_REORG_DELAY while neither its own
transaction nor any RBF candidate can still confirm, mirroring the
anti-reorg finality the Succeeded transition already assumes. Removing
the payment's pending entry then stops the re-queueing.

Settling also removes the entry that maps candidate txids to the
record, so a later wallet event for a dead candidate falls back to
keying by that candidate's txid -- which, for the first candidate, is
the record's own id. Skip such events rather than let the generic
handling resurrect the settled record, and let a replayed replacement
event finish an entry removal a crash interrupted instead of stamping
the terminal status into the leftover entry.

Implemented with Claude Code.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Wallet sync can learn of a splice transaction before broadcast-time
classification records it: once tx_signatures are exchanged, the
counterparty may broadcast first, and sync then files the round under a
duplicate record keyed by its txid, which shadows the funding record's
txid lookups from then on. Retrying a failed classification only
narrows that window: a round the counterparty broadcasts is still
observed before our record exists.

Record the funding payment while handling
FundingTransactionReadyForSigning, before funding_transaction_signed
hands our signatures to LDK. The counterparty cannot broadcast without
them, so the record precedes anything wallet sync can observe, and every
later observer resolves to it. The record is written in full from the
channel's pending splice history, so the round's broadcast has nothing
left to record and records nothing. If the record cannot be written, the
event is replayed rather than proceeding unrecorded: LDK re-offers it
in-session and regenerates it across restarts while the transaction
remains unsigned. A failed write leaves no half-written record behind
for the replayed event to build on. Should undoing it fail as well, the
replayed event removes what was left of a first round once the round is
gone from the channel's history; the leftovers of a bump live under an
earlier round's record, which wallet sync moves on as that round
confirms or fails.

Recording before the round is negotiated means a recorded round can
still be abandoned: the counterparty may abort after we sign but before
its commitment_signed, or the channel may close, and until LDK has
released our signatures nothing can ever broadcast the transaction. Left
in place, the record would wait forever on a payment nothing can
confirm. The signed round is therefore marked as awaiting broadcast
until LDK reports the splice negotiated, which it does as it hands the
fully signed round to the broadcaster: from then on the counterparty
holds our signatures and can broadcast on its own. If the mark cannot be
cleared, that event is replayed as well. A marked round is dropped once
LDK no longer holds it, unless the wallet has seen its transaction: the
counterparty may broadcast a round it received our signatures for while
LDK still waits on its own. A round whose negotiation LDK has reported
keeps its place whether or not wallet sync has seen it yet, and so does
the channel's current funding: a zero-conf splice becomes the funding as
soon as splice_locked is exchanged, before its transaction confirms or
LDK's report of its negotiation has necessarily been handled. Dropping a
round leaves the record on the last remaining round this node
contributed to, moving it there if it still names the dropped round, or
removes the record when none remains. LDK's view is consulted when it
reports the failed negotiation of a channel it still lists, when the
channel closes -- a round awaiting the counterparty's signatures gets no
failure report then, and a failure reported once the channel is gone is
resolved by what this report carries, the channel's last funding, and by
the rounds its monitor still watches -- and at startup, before any
background task runs: LDK reports the loss of a negotiation its last
channel manager write carried mid-way, but a round committed, negotiated
and signed since that write gets no report if the node stops before the
next one. The channel manager forgets a closed channel's pending rounds,
but its monitor keeps watching every round the counterparty's
commitment_signed reached, and our signatures cannot have left the node
before that message: the counterparty may hold the fully signed
transaction and broadcast it, as when this node's contributed input
value is the smaller and its tx_signatures therefore go first, so such a
round is kept for wallet sync to resolve should it confirm, while a
marked round the monitor never watched is dropped, as nothing can
broadcast it. A round already missing from the channel's history when
the signing event is handled is not recorded at all.

Rounds without a local contribution emit no signing event and are not
recorded at broadcast either, as before; they are left to wallet sync.

Developed with assistance from Claude Code.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
A splice round this node signed is kept at `ChannelClosed` when the
channel's monitor watches it: the counterparty committed to it, so our
signatures may have left the node, and the counterparty may broadcast
the round and see it confirm. A close the wallet sees as a conflict --
a cooperative close spending an input the round shares -- fails the
payment once it confirms beyond the reorg depth, but nothing resolved
such a record when a commitment transaction, which pays no wallet
script, won instead. Once the close matures -- after the reorg delay
for a counterparty's commitment transaction, and once the to_self_delay
on our balance has passed for one of our own -- the monitor stops
watching the rounds it kept and queues a `DiscardFunding` event for
each, and the handler only reclaimed the contribution's addresses: the
funding payment stayed `Pending` forever. Likewise for a round of ours
that a sibling round this node did not contribute to replaced on an
open channel: LDK discards our round as the sibling locks, and the
payment stayed `Pending` for a transaction that can no longer confirm.

Resolve the channel's funding payments by the rounds LDK holds. A round
nothing ever broadcast is dropped first, as `ChannelClosed` already
did, and with it a record no broadcast round of ours remains under. A
payment is then left alone if a round of ours that LDK still holds
remains in its record -- the round that locked, or one still pending --
or one LDK promoted to the funding before, and failed otherwise: no
round of ours can confirm anymore, whether the channel closed on a
commitment transaction or a round we did not contribute to locked. The
rounds LDK holds are the channel's pending rounds and funding while the
manager lists the channel, and once it does not, the funding its
monitor settled on plus whatever the monitor still watches. The monitor
is left out for a listed channel: its updates land after the manager's,
deferred to the background processor's flush, so it may still watch a
round the manager let go.

The event names this node's contribution, not the round: the inputs and
output scripts LDK returns of it. Matching that to a recorded round
would take the parts of every contribution on record. LDK discards the
round's siblings as it promotes the round and reports the promotion
through `ChannelReady`, so that event resolves the payments of a listed
channel instead: it records the promotion and resolves the channel's
other payments by the rounds the manager holds once updated -- the
promoted round, and whatever was negotiated behind it. For a channel
the manager no longer lists it records the promotion alone and leaves
the payments to the close. A `DiscardFunding` for a listed channel then
only drops a round nothing broadcast that the manager no longer holds
and reclaims the contribution's addresses.

A zero-conf splice is promoted to the funding as `splice_locked` is
exchanged, before its transaction confirms, and a later splice moves the
funding on again: at the close neither the manager nor the monitor holds
the earlier round, although it can still confirm, the later round
descending from it. So the funding payment records each promotion LDK
reports through `ChannelReady`, and a round promoted once counts as one
that can confirm wherever the rounds LDK holds decide: as a sibling
round is promoted, and when the channel closes.

The monitor's events can reach the handler ahead of the channel's
`ChannelClosed` when one sync delivers the close and its maturity: the
channel manager polls the monitor's report of the close at the start of
each event pass and on peer traffic, and the monitor's own events are
handled right after the manager's. Each event then finds the channel
still listed and leaves the payments, there being no promotion to
resolve them. So `ChannelClosed` fails every payment of the channel
left with no round of ours the monitor watches and none promoted
before, and a `DiscardFunding` event for a channel the manager no
longer lists resolves each record the same way, by the funding its
monitor settled on and whatever it still watches.

Developed with assistance from Claude Code.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Funding records were keyed by a PaymentId derived from a funding txid:
the broadcast txid in the generic classification path, the first
negotiated candidate's txid in the interactive path. A txid is no
identity for a replaceable transaction -- the record deliberately
outlives RBF rounds of its funding, so its key carried the txid of
whichever round happened to come first, and code could be tempted to
re-derive the id from a txid instead of resolving it.

Generate the id from the OS entropy source when the record is created,
and resolve existing records through their transaction history
(find_payment_by_txid) everywhere. RBF stability now comes from
resolution instead of derivation. Resolution must share one lock
acquisition with the record writes: resolved outside it, the id could
go stale against a record wallet sync creates for the same transaction,
producing a divergent record -- so both the classification path and the
interactive path resolve the id under the lock they write under.

Resolution also reaches records that have graduated out of the pending
store. Without that, a funding classified again after graduation -- LDK
re-broadcasting a 0conf splice whose confirmation landed while the node
was offline -- would get a duplicate record under a fresh id, and a
reorg after graduation would never reach the record.

A record already failed is passed over when a newly signed round
resolves its id. Wallet sync fails a funding payment whose round lost to
a conflicting spend confirmed while the channel stays open, but LDK
still holds the round, so a fee bump of it is signed with the failed
round among its candidates. Filed under the failed record, the bump
would stay failed and untracked, so nothing would graduate it once it
confirmed. The bump gets a record of its own instead.

The funding-record surface (classification, candidates, stable ids)
debuts in the upcoming release -- v0.7.0 shipped splice_in with no
record machinery -- so changing the scheme now costs nothing, while one
release later it would break payment(&PaymentId(funding_txid)) lookups
for new records.

Generated with assistance from Claude Code.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
A user-initiated splice dropped before LDK persists it leaves no trace
in LDK. Recovering whatever the splice reserved and describing later
events about it in terms of the original request both require
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 that
record what was handed to LDK and the API call that produced it.

The wallet's pending-store writes that depend on a payment's status now
make that check and the write atomically, 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. A later
commit adds the classification that reads the variant; the entry points
that persist splice intents land with the splice tracking built on
this.

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 splice intent,
funding payment, and candidate history all share one record. Teach the
signing-time recording to find a pre-broadcast splice intent by its
channel and reuse that id for a splice no live record tracks yet,
promoting the intent record to a tracked funding payment while
preserving the intent until the splice locks.

A round already on record keeps its record, whatever id it is under: the
id of the first round of the history any record tracks is adopted before
the channel's intent is consulted, and a fresh id is generated only when
neither yields one. A record wallet sync has already failed does not
count: nothing revisits a failed record, so a fee bump signed with its
lost round in the history adopts the channel's intent instead, and its
entry carries the intent. The intent identifies the channel, not a
round, and must not decide the id of a round already on record: a splice
this node joins as a fee bump of a round wallet sync recorded first
converges on the record sync created, and consulting the intent first
would file the bump under the intent as a second record, with wallet
sync then graduating whichever of the two it finds first. Every splice
round this node contributes to that the wallet records is recorded when
it is signed, before our signatures are released, so the intent only
ever decides the id of a splice's first signed round, or of a bump
signed after wallet sync has failed every round on record before it.
Splices we did not originate (counterparty-initiated or V2 dual-funded
opens) have no intent. An intent submitted for a channel whose history
is already on a record under another id that has not failed is never
promoted and stays bare until the splice locks or fails.

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.

The generic funding classification already resolves an existing record
the same way before generating a fresh id: LDK re-broadcasts a
promoted-but-unconfirmed 0conf funding transaction through that path,
and a test added here covers the rebroadcast merging into the record the
signing created rather than creating a duplicate.

Promotion of a pre-broadcast intent in persist_funding_payment_locked 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 the write 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 land in a follow-up -- on this branch the intent probe stays
dormant.

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>
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Wallet sync can observe a funding round before it is recorded as a
candidate: the counterparty broadcasts a round this node did not
contribute to, which nothing records until this node signs a later
round of the same splice and records the channel's history with it. The
funding-status gate rightly reports such a round foreign, and sync
re-keys the event to the round's txid-derived id, creating an untyped
duplicate record whose pending entry from then on shadows the funding
record in txid resolution: even after the round is recorded as a
candidate, every later event routes to the duplicate, the confirmation
strands there, and the funding record never confirms or graduates.

Fold the duplicate back in when its round becomes a recorded candidate:
adopt its confirmation onto the funding record -- through the same
status-update path wallet sync uses, so the confirmed candidate's
figures land -- and remove the duplicate along with its pending entry. A
duplicate for a round that never confirmed is dropped without adopting
anything; the actively-broadcast candidate stays the record's current
txid. The merge runs when this node signs a round and records the
channel's history with it, and again when LDK reports the round
negotiated, under the writer's cross-store lock acquisition, so sync
cannot interleave, and is idempotent, so a replayed SpliceNegotiated
event can re-run it after a partial failure. At signing time the merge
is a courtesy and a failure is only logged: the signed round can have no
duplicate yet, as our signatures have not left the node, the round's
SpliceNegotiated event re-runs the merge and replays on failure, and
failing the signing would replay it against a record whose two-store
write already completed, which the write's rollback does not cover. The
pending entry is removed before the payment record: a replay
rediscovers the duplicate through the record, so a failure between the
two removals can still be cleaned up, instead of orphaning a pending
entry that would shadow txid resolution all over again.

Generated with assistance from Claude Code.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5.1 <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. 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
holds 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. 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. Taking back the funding record of a signed round the
failure abandoned leaves its intent 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 holds 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 no submission is refused on its account.

Wallet state staged on a splice's behalf is flushed only after the
intent record persists, so nothing the wallet reserves for a splice can
outlive the record through which a later startup would release it. A
splice that fails before the hand-off immediately releases what the
wallet holds for it and no other round uses -- 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.

Once a splice funding payment is classified, the intent is carried on
the payment's record until the splice locks or the payment fails; a
payment that already graduated instead removes the leftover intent
record. The funding payment 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. A signing write cut short
after the payment store leaves that payment under a bare intent; it
records a round whose signatures never left the node, so it is dropped
-- when the replayed signing finds the round gone, or with the intent
once the splice settles -- rather than promoted into a record nothing
could ever drive.

Developed with assistance from Claude Code.

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 holds 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 takes back the payment recorded at signing
time: the round is gone from the channel's history and nothing can ever
broadcast it, so left in place the record would wait forever on a
payment nothing can 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 no event of LDK's ever returns what
the wallet reserved for it — today the addresses its outputs pay; once
lightningdevkit#1037 locks a contribution's inputs in the wallet, those too, forever.
At startup, reconcile each persisted splice intent against live channel
state: release the reservations of a splice LDK no longer holds 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 no round of it 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 nothing can act on the stale reservations first. 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>
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 holds 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 regardless of whether wallet sync or classification sees the
confirmation first.

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 holds that splice; and a funding record left half-written by a stop
between the signing write's two stores is dropped at the next startup
instead of lingering as a payment nothing indexes.

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>
@jkczyz
jkczyz force-pushed the 2026-08-splice-tracking branch from d457713 to 74e0acd Compare September 10, 2026 19:59
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.

2 participants