Skip to content

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

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

jkczyz wants to merge 6 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

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

@jkczyz
jkczyz force-pushed the 2026-08-splice-tracking branch from 219c46f to 8e5fe8b Compare October 30, 2026 14:51
@jkczyz

jkczyz commented Oct 30, 2026

Copy link
Copy Markdown
Contributor Author

Rebased and had Claude address those:

Thanks — went through all eight. Four led to fixups, the rest I've answered inline.

1. reconcile Keep branch and the outputs of a lost queued splice. The Keep branch does leave B's outputs alone, but there is nothing to release there. Releasing an output means cancel_tx, which only clears BDK's in-memory used marks, and at startup there are none: BDK does not persist them, and the splice change script comes from next_unused_address, which does not set one. A splice-out output pays an address the caller supplied, which cancel_tx would not take back either. B's inputs are unlocked in the Keep branch already. So the Lost branch's cancel_tx is just as much a no-op at startup; the two branches differ in code, not in what they release.

2. clear_persisted_intent with a record promoted mid-settlement. The window is in the code, but I could not find a path into it. Wallet sync files a payment under a bare intent record's id only through the facts a signing wrote, and that signing also wrote the round's candidates onto the record. The record is bare again only after a drop of rounds LDK let go of, that nobody broadcast, and that the wallet's graph has not seen. The counterparty can broadcast such a round only with our tx_signatures, which LDK releases only once commitment_signed has been received, and from then on LDK no longer resets the round on an open channel; on a closed channel, ChannelClosed keeps the round the monitor watches. Were the window reached anyway, the leftover intent would sit on a record with details and be cleared in place by the next settlement, or at the next startup; no funds or addresses are stuck. I left it as is. A retry of the mutate when remove_if declines would be cheap if you'd rather close the window regardless.

3. The signing event when the cancel fails. cancel_funding_contributed fails only when there is nothing left to cancel: no such peer or channel, channel not funded, no pending splice negotiation, no local contribution, or already signed. In this arm the event itself means a local contribution exists, and nothing has been signed yet, so a failure means the negotiation or the channel is gone, and LDK's SpliceNegotiationFailed or ChannelClosed is on its way. Replaying on an APIMisuseError would fail the same way on every offer and block every event queued behind it. The doc on cancel_splice says this, so I left the code as is.

4. concerns_channel and funding_channels. Fixed in 6697fe0: it matches the record's funding_channels(), which covers the channels a signing recorded as well as the classification. records_concerning_a_channel now has the signed-round case. The effect was real: after a failed bump's settlement cleared such a record's intent, the next bump got a fresh record while its round was filed under the old one, and a startup after a close while the node was down released the bump's contribution from that fresh record although the recorded rounds could still confirm.

5. reconcile and the stale snapshot. The skipped evaluation would not have changed anything, so I only added a comment (72ad320). The settle pass reads the channel's records from the store, so a record listed later is gone by its turn, or re-anchored because LDK still tracks a candidate with the same inputs (decide_on_lock requires is_same_splice). For such an intent decide_reconcile returns Keep, and the extras unlock finds no input the candidate doesn't claim. The pass does run once per stale record rather than once per channel; each repeat finds nothing to do.

6. The second removal. Fixed in d8d6fca: the removal is gone. It was left over from the read-then-remove it replaced.

7. Force-close vs cooperative close. Documented on the parameters field in 64551c9. Matching the intents of a channel that just closed would need on_channel_closed to know which intents LDK will still report a failure for, and some never get one (a signed round the monitor watches, or a failure a cooperative close already reported), so those would need another trigger to clear them.

8. PersistenceFailed from the splice entry points. Kept. These methods already returned other variants before this PR: NotRunning, InsufficientFunds from the splice-in checks, InvalidAddress from splice-out. PersistenceFailed is the accurate error here: the splice was not attempted because its intent or the wallet's staged state could not be persisted, and mapping it to ChannelSplicingFailed would hide the storage failure. The method docs don't list variants, so nothing documented changed.

@jkczyz
jkczyz force-pushed the 2026-08-splice-tracking branch 2 times, most recently from 946f2d3 to 957275d Compare October 30, 2026 16:11
@jkczyz

jkczyz commented Oct 30, 2026

Copy link
Copy Markdown
Contributor Author

5. reconcile and the stale snapshot. The skipped evaluation would not have changed anything, so I only added a comment (72ad320). The settle pass reads the channel's records from the store, so a record listed later is gone by its turn, or re-anchored because LDK still tracks a candidate with the same inputs (decide_on_lock requires is_same_splice). For such an intent decide_reconcile returns Keep, and the extras unlock finds no input the candidate doesn't claim. The pass does run once per stale record rather than once per channel; each repeat finds nothing to do.

The comment that Claude wrote for this wasn't really understandable. I had it rename a couple methods and re-write it.

🤖 Five more fixups on top. The comment I added for 5 (72ad320) leaned on words that aren't in the code, so 95523e0 rewrites it to name the call, the snapshot, and the check it talks about. The two helpers it refers to are renamed after what they answer rather than after their caller: decide_on_lock / LockDecision::{Keep, Refresh, Settle} → effect_of_splice_lock / SpliceLockEffect::{Unaffected, StillQueued, Superseded} (a524fad + e5c83e6), and decide_reconcile / ReconcileDecision::{Keep, Lost} → splice_survived_restart returning a bool (739ba2e). The docs, comments and refusal messages that said "the funding moved on" now say a splice locked, LDK's own word for it (e5c83e6, 95361b3). No behavior change.

@jkczyz
jkczyz requested a review from tnull October 30, 2026 16:13

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

Another round of Claude feedback:

  1. Medium: cancel_splice can cancel the wrong splice and leave the failed round stuck (src/event.rs:949, called from lines 2832, 2859 and 2871)

The handler assumes that cancelling aborts the round it just declined to sign. The pinned LDK's Channel::cancel_funding_contributed (channel.rs:13681) checks
for a queued contribution first, and if there is one it abandons that and returns.

LDK explicitly allows a queued contribution alongside a counterparty-initiated negotiation (channel.rs:13570). The PR's own docs also describe joining a
counterparty's round.

Failure scenario:

  1. This node has a contribution in a counterparty-initiated round A, which is awaiting signatures.
  2. The user submits splice B. LDK queues it, because already_contributing only looks at negotiations this node initiated.
  3. Handling FundingTransactionReadyForSigning for A fails, through a signing failure, Unmeasurable, or LDK refusing the witnesses.
  4. cancel_splice cancels B, so the user sees LocallyCanceled for a healthy splice.
  5. A stays unsigned and the handler returns Ok(()), so nothing replays it in-session. A keeps the channel's splice state stuck until a restart regenerates
    the event.

In the "LDK refused" path, A's record also isn't taken back, contrary to the comment at event.rs:2857.

Possible fix: call cancel again if the first call only removed a queued contribution, or have LDK expose which one it cancelled. A regression test should
queue B behind a counterparty round and force a signing failure.

  1. Low: restart recovery releases a lost fee bump's inputs but not its outputs (src/channel/mod.rs:193)

When a splice survives a restart, reconcile only unlocks unclaimed_inputs, whereas the other release paths use release_contribution, which also frees
outputs.

If a fee bump that re-selected its inputs is lost, its new change address stays marked as used. Nothing else recovers it at that point: the kept intent
settles without a release, and LDK's DiscardFunding may not cover a bump it never persisted. The cost is a skipped address rather than lost funds.

Possible fix: use unclaimed_parts here and release both parts.

  1. Low: two comments describe an event LDK doesn't emit (src/channel/mod.rs:72, :561)

Both justify the submit lock partly by the failure event of a synchronously rejected hand-off. LDK's funding_contributed docs and code say a rejection emits
no SpliceNegotiationFailed, only DiscardFunding. The lock is still justified by the other races listed there, but these comments send readers after a race
that can't happen.

@tnull

tnull commented Nov 5, 2026

Copy link
Copy Markdown
Collaborator

I also asked Claude whether it sees a way to keep this PR less invasive, and it seems to think so:

Why the intent is mostly redundant

  • Failure details: LDK's SpliceNegotiationFailed already carries reason and contribution (channelmanager.rs:3643). parameters can be built from the
    contribution's value_added() and outputs(), with no store lookup.
  • Better event coverage: the PR returns parameters: None after a force-close and for many restart replays, because the intent has already been settled.
    Deriving them from the event would fill those cases, and the event docs would get simpler.
  • Restart recovery: per the PR's own docs, LDK replays the failure of a contribution it was queueing or negotiating when last persisted, and the
    DiscardFunding preceding it is emitted the same way. The intent is only uniquely needed when a hand-off never reached a manager write, plus the fee-bump
    extras from my finding 2.
  • What's being protected today: the wallet doesn't lock inputs at coin selection until Fix wallet UTXO reuse for funding transactions and onchain spends #1037, so the unlocks do essentially nothing for now. The real leak in
    that small window is a change address left marked as used, which costs a skipped address rather than funds.

Proposed split

┌────────────────────────┬───────────────────────┬──────────────────────────────────────────────────────────────────────────────────────────────────────┐
│ Piece │ Scope │ Change │
├────────────────────────┼───────────────────────┼──────────────────────────────────────────────────────────────────────────────────────────────────────┤
│ 1. Failure events │ Small, no new │ Add reason and derive parameters from the contribution │
│ │ persistence │ │
├────────────────────────┼───────────────────────┼──────────────────────────────────────────────────────────────────────────────────────────────────────┤
│ 2. Abort on signing │ Small │ Keep the cancel_splice paths, and fix finding 1 │
│ failure │ │ │
├────────────────────────┼───────────────────────┼──────────────────────────────────────────────────────────────────────────────────────────────────────┤
│ 3. Facts changes │ Independent │ Move "Refuse to sign a round this node cannot measure" and "Let the funding output alone identify a │
│ │ │ funding" to their own PR │
├────────────────────────┼───────────────────────┼──────────────────────────────────────────────────────────────────────────────────────────────────────┤
│ 4. Restart recovery │ Moves to #1037 │ Solve it where the locks are actually introduced │
└────────────────────────┴───────────────────────┴──────────────────────────────────────────────────────────────────────────────────────────────────────┘

For piece 4, I'd try a stateless pass at startup: take the wallet's locked outpoints, subtract what LDK's splice candidates and pending events still claim,
and unlock the rest. If that can't be made safe, persist a small record of locked outpoints per channel, with no lifecycle tied to payment records.

The stateless version needs care in two places:

What this removes

  • Most of the production code in src/channel/mod.rs (about 1,060 lines), including the submit lock, anchoring, re-anchoring and place_intent.
  • FailureSettlement and the settlement hooks in the event handlers.
  • From the wallet: bare and kept intents, remove_failed_funding_entry_locked, drop_unindexed_record_of_settled_intent, and the multi-record
    find_splice_payment_id.
  • Probably the splice_intent field too. Production code on main doesn't appear to create intents yet, since pending_splice is #[cfg(test)] there, but I'd
    check it has never shipped in a release before dropping it.

The wallet changes are where this helps most, because they currently tie splice lifecycle state into payment tracking.

What you give up

  • FeeBump label: a bump can't be reliably told apart from the contribution alone, since a bump and its original can have the same inputs and outputs. I'd
    drop the label and report the bump as In or Out, or let a small in-memory map supply it on a best-effort basis.
  • Lost-before-persist splices: until Fix wallet UTXO reuse for funding transactions and onchain spends #1037 they'd go unrecovered, leaving the skipped address described above. The PR doesn't emit a failure event for these
    either.
  • Payment ID continuity: the intent's ID would no longer carry over to the funding record, but the code notes that ID only has to be unique.

Main thing to check first

Everything rests on LDK's replay guarantee, so I'd confirm it first with a restart test that stops the node mid-negotiation. If that holds, the rest is
mostly deletion. If it has gaps wider than the unpersisted hand-off, some persisted record is justified, though it could still be much smaller than this one.

The main decision is whether you can give up the FeeBump label and defer recovery to #1037. If so, I can prototype pieces 1 and 2 on a fresh branch to
measure the actual reduction.

Probably the items on the 'What you give up' section are the debatable parts here?

jkczyz and others added 6 commits November 5, 2026 13:09
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. 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.

Written with the help of an AI tool.

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 splice
would have done. Both matter for deciding what to do next — a splice
lost to a disconnect can simply be re-issued, while a failed fee bump
leaves the splice it meant to bump in flight, possibly to 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 splice's parameters — the amount a splice-in
would have added or the outputs a splice-out would have paid — read from
the failed contribution LDK reports with the failure. A fee bump
rebuilds the contribution of the splice it bumps, so a failed bump
describes that splice, less what the higher fee takes from a splice-in
of all available funds. 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.

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
splice-in's amount -- 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 reaches the user through the failure LDK
persisted at shutdown and replays at startup -- once, with what the
splice would have done. The restart tests cover a dropped splice-out,
reported with its output, after which a further restart stays silent;
a dropped fee bump, reported with the amount of the splice-in it meant
to replace; and a splice queued behind a pending one of this node,
reported with its own amount rather than the pending splice's. In each,
the application re-initiates and the splice completes.

Two more cases guard the payment records around these: a second splice
submitted right after a zero-conf lock gets a record of its own rather
than the record of the splice that just locked, and a splice confirmed
while its node was offline keeps exactly one payment record, under the
id recorded at signing, across the restart.

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 the splice-in's amount.

Written with the help of an AI tool.

Co-Authored-By: Elias Rohrer <dev@tnull.de>
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, as is the funding transaction this node builds for a channel
it opens. 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.

Written with the help of an AI tool.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
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.

Written with the help of an AI tool.

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 commented Nov 6, 2026 •

Copy link
Copy Markdown
Contributor Author

Discussed a bit offline. We'll simplify as noted above. For finding 1, we'll fix this upstream. See Claude summary below for details and issue number:

🤖 Restructured as we discussed. Dropped the splice intent and everything built on it: the persist-until-lock commit, the startup unlock, the take-back commit, and the SpliceKind/FeeBump variant. The PR is now six commits instead of twenty.

SpliceNegotiationFailed still gains reason and parameters, but parameters is now read from the FailedSpliceContribution LDK attaches to the event: the amount a splice-in would have added (value_added()) or the outputs a splice-out would have paid (outputs()). LDK rebuilds the bumped splice's contribution for a fee bump, so a failed bump reports the splice it meant to bump. LDK sets the contribution on every failure it emits, replayed ones included, so the restart tests now assert parameters in every case rather than only when an intent matched.

On the "what you give up" list, yes, those were the decisions. The FeeBump label is gone; a failed bump reports the splice it meant to bump, which is what the contribution LDK attaches describes. A splice lost before LDK's next manager write leaves nothing behind today: its change address is only marked used in memory, and the mark is gone with the restart. Once #1037 persists the input locks, such a splice leaves its inputs locked, so the recovery belongs with #1037. The payment id no longer carries over from the request to the funding record.

Of your eight items from 2026-10-29, only 3 and 7 touch code that is still here. 3 stays as answered. 7 is resolved: parameters now comes from the contribution in every case, including after a force-close.

Kept: the abort on signing failure, the two facts commits (admit a round this node signs however full the store is; let the funding output alone identify a funding), and the mutate_async drop. Happy to move the two facts commits to their own PR if you'd rather.

Your second and third findings are moot with the tracker gone. The first stands against the abort commit, and the cause is in LDK: cancel_funding_contributed abandons a queued contribution before it looks at the negotiation, so the abort on signing failure can cancel a queued fee bump instead of the round that failed to sign. Filed as https://git.rust-bitcoin.org/lightningdevkit/rust-lightning/issues/5512, with the fix being to count a contribution of ours in the in-flight round as already contributing and to have cancel_funding_contributed cancel both the negotiated and the queued contribution. I'd leave the abort commit as is and pick the LDK fix up with the next pin bump.

@jkczyz
jkczyz force-pushed the 2026-08-splice-tracking branch from 957275d to 4d9d872 Compare November 6, 2026 00:45
@jkczyz
jkczyz requested a review from tnull November 6, 2026 00:46
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