Skip to content

Fix wallet UTXO reuse for funding transactions and onchain spends - #1037

Open
tnull wants to merge 5 commits into
lightningdevkit:mainfrom
tnull:2026-08-fix-wallet-utxo-reuse
Open

tnull wants to merge 5 commits into
lightningdevkit:mainfrom
tnull:2026-08-fix-wallet-utxo-reuse

Conversation

@tnull

@tnull tnull commented Aug 10, 2026 •

Copy link
Copy Markdown
Collaborator

Fixes #41, #1117.

For the longest time BDK didn't offer any UTXO locking mechanisms and only considered transactions canonical once seen in the mempool during syncing. This always left a gap between the time of transaction signing/broadcast and the time of sync during which the wallet could double-spend itself. Since bdk_wallet v3.0 they finally offer UTXO locking APIs which we finally use here to close this gap for funding transactions and onchain spends.

Note: We intentionally leave splicing transactions out-of-scope of this PR because with #962 and #930 there are related PRs inflight. Depending on the order these land, this PR or they need to be updated to marry the two approaches. (cc @jkczyz)

@tnull tnull added this to the 0.8 milestone Aug 10, 2026
@tnull
tnull requested a review from wpaulino August 10, 2026 12:41
@ldk-reviews-bot

ldk-reviews-bot commented Aug 10, 2026 •

Copy link
Copy Markdown

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

@tnull
tnull force-pushed the 2026-08-fix-wallet-utxo-reuse branch from 5385ef8 to 2932580 Compare August 10, 2026 14:03
@tnull
tnull marked this pull request as draft August 10, 2026 14:12
@tnull

tnull commented Aug 10, 2026

Copy link
Copy Markdown
Collaborator Author

Drafting this for now as it might make sense to wait for #962 to land first, and then rebase this.

@tnull
tnull force-pushed the 2026-08-fix-wallet-utxo-reuse branch from 2932580 to 61b7adf Compare August 12, 2026 09:51
@tnull
tnull marked this pull request as ready for review August 12, 2026 09:52
@tnull

tnull commented Aug 12, 2026

Copy link
Copy Markdown
Collaborator Author

Rebased to resolve minor conflicts. Should be good for review now that #962 landed.

@wpaulino @jkczyz Do you think we should also cover UTXO locking for splices in this PR, or would that be the concern of #930?

@jkczyz

jkczyz commented Aug 12, 2026

Copy link
Copy Markdown
Contributor

Rebased to resolve minor conflicts. Should be good for review now that #962 landed.

@wpaulino @jkczyz Do you think we should also cover UTXO locking for splices in this PR, or would that be the concern of #930?

I'd say either here or in a dedicated PR. It can be done independent of #930, which is already pretty big. That touches SpliceNegotiationFailed handling, but for unlocking we only need to touch DiscardFunding handling. The bigger change is using our own CoinSelectionSource for locking rather than relying on LdkWallet, IIUC.

@tnull
tnull force-pushed the 2026-08-fix-wallet-utxo-reuse branch from 61b7adf to 4e7409c Compare August 13, 2026 10:46
@tnull

tnull commented Aug 13, 2026

Copy link
Copy Markdown
Collaborator Author

Rebased to resolve minor conflicts. Should be good for review now that #962 landed.
@wpaulino @jkczyz Do you think we should also cover UTXO locking for splices in this PR, or would that be the concern of #930?

I'd say either here or in a dedicated PR. It can be done independent of #930, which is already pretty big. That touches SpliceNegotiationFailed handling, but for unlocking we only need to touch DiscardFunding handling. The bigger change is using our own CoinSelectionSource for locking rather than relying on LdkWallet, IIUC.

Now added a commit here, let me know what you think

@tnull
tnull requested a review from jkczyz August 13, 2026 10:49

@Jolah1 Jolah1 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The overall approach looks good, but I found one persistence-failure issue that should be addressed before merging. Both channel-funding creation and splice coin selection can leave their selected inputs durably locked when persisting the locks fails.
I reproduced this with a fault-injecting store: the operation returned an error, the inputs remained locked, and after persistence recovered the locks survived a wallet reload. The same issue occurs in select_confirmed_utxos at line 1384.
I left the details inline.

Comment thread src/wallet/mod.rs Resolved
@tnull

tnull commented Aug 19, 2026

Copy link
Copy Markdown
Collaborator Author

Rebased to resolve some conflicts.

@tnull
tnull force-pushed the 2026-08-fix-wallet-utxo-reuse branch from ff48307 to 6605157 Compare August 19, 2026 07:35
jkczyz added a commit to jkczyz/ldk-node that referenced this pull request Sep 8, 2026
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>
jkczyz added a commit to jkczyz/ldk-node that referenced this pull request Sep 10, 2026
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>
jkczyz added a commit to jkczyz/ldk-node that referenced this pull request Sep 10, 2026
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>
jkczyz added a commit to jkczyz/ldk-node that referenced this pull request Sep 18, 2026
Reword what the comments say LDK returns for a synchronously rejected
contribution and for a signed round the monitor never watched, so they
hold at the pinned LDK and once it carries the fixes for rust-lightning
issues 4986 and 4967: a refusal's `DiscardFunding` names the parts no
pending splice attempt still uses, except that before 4986 is fixed a
refusal for a channel or peer LDK no longer knows names the whole
contribution, and once 4967 is fixed a round the monitor never watched
is released by the `DiscardFunding` LDK reports at the force-close.
Scope the `TODO(lightningdevkit#1037)` notes to those fixes.

Developed with assistance from Claude Code.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
jkczyz added a commit to jkczyz/ldk-node that referenced this pull request Sep 18, 2026
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>
jkczyz added a commit to jkczyz/ldk-node that referenced this pull request Sep 18, 2026
Reword what the reconciliation comments say about rounds LDK wrote, so
they hold once the pinned LDK carries the fix for rust-lightning issue
4967: LDK then reports a `DiscardFunding` at a force-close for a
recorded round the monitor never watched, which nothing released
before. Split the `TODO(lightningdevkit#1037)` note into the part that fix removes and
the case that stays: a hand-off LDK never wrote, whose channel is
force-closed as stale at startup, gets no event and must be released
here.

Developed with assistance from Claude Code.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
jkczyz added a commit to jkczyz/ldk-node that referenced this pull request Sep 22, 2026
Reword what the comments say LDK returns for a synchronously rejected
contribution and for a signed round the monitor never watched, so they
hold at the pinned LDK and once it carries the fixes for rust-lightning
issues 4986 and 4967: a refusal's `DiscardFunding` names the parts no
pending splice attempt still uses, except that before 4986 is fixed a
refusal for a channel or peer LDK no longer knows names the whole
contribution, and once 4967 is fixed a round the monitor never watched
is released by the `DiscardFunding` LDK reports at the force-close.
Scope the `TODO(lightningdevkit#1037)` notes to those fixes.

Developed with assistance from Claude Code.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
jkczyz added a commit to jkczyz/ldk-node that referenced this pull request Sep 22, 2026
Reword what the reconciliation comments say about rounds LDK wrote, so
they hold once the pinned LDK carries the fix for rust-lightning issue
4967: LDK then reports a `DiscardFunding` at a force-close for a
recorded round the monitor never watched, which nothing released
before. Split the `TODO(lightningdevkit#1037)` note into the part that fix removes and
the case that stays: a hand-off LDK never wrote, whose channel is
force-closed as stale at startup, gets no event and must be released
here.

Developed with assistance from Claude Code.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
jkczyz added a commit to jkczyz/ldk-node that referenced this pull request Sep 22, 2026
Reword what the comments say LDK returns for a synchronously rejected
contribution and for a signed round the monitor never watched, so they
hold at the pinned LDK and once it carries the fixes for rust-lightning
issues 4986 and 4967: a refusal's `DiscardFunding` names the parts no
pending splice attempt still uses, except that before 4986 is fixed a
refusal for a channel or peer LDK no longer knows names the whole
contribution, and once 4967 is fixed a round the monitor never watched
is released by the `DiscardFunding` LDK reports at the force-close.
Scope the `TODO(lightningdevkit#1037)` notes to those fixes.

Developed with assistance from Claude Code.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
jkczyz added a commit to jkczyz/ldk-node that referenced this pull request Sep 22, 2026
Reword what the reconciliation comments say about rounds LDK wrote, so
they hold once the pinned LDK carries the fix for rust-lightning issue
4967: LDK then reports a `DiscardFunding` at a force-close for a
recorded round the monitor never watched, which nothing released
before. Split the `TODO(lightningdevkit#1037)` note into the part that fix removes and
the case that stays: a hand-off LDK never wrote, whose channel is
force-closed as stale at startup, gets no event and must be released
here.

Developed with assistance from Claude Code.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
jkczyz added a commit to jkczyz/ldk-node that referenced this pull request Sep 22, 2026
Reword what the comments say LDK returns for a synchronously rejected
contribution and for a signed round the monitor never watched, so they
hold at the pinned LDK and once it carries the fixes for rust-lightning
issues 4986 and 4967: a refusal's `DiscardFunding` names the parts no
pending splice attempt still uses, except that before 4986 is fixed a
refusal for a channel or peer LDK no longer knows names the whole
contribution, and once 4967 is fixed a round the monitor never watched
is released by the `DiscardFunding` LDK reports at the force-close.
Scope the `TODO(lightningdevkit#1037)` notes to those fixes.

Developed with assistance from Claude Code.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
jkczyz added a commit to jkczyz/ldk-node that referenced this pull request Sep 22, 2026
Reword what the reconciliation comments say about rounds LDK wrote, so
they hold once the pinned LDK carries the fix for rust-lightning issue
4967: LDK then reports a `DiscardFunding` at a force-close for a
recorded round the monitor never watched, which nothing released
before. Split the `TODO(lightningdevkit#1037)` note into the part that fix removes and
the case that stays: a hand-off LDK never wrote, whose channel is
force-closed as stale at startup, gets no event and must be released
here.

Developed with assistance from Claude Code.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
jkczyz added a commit to jkczyz/ldk-node that referenced this pull request Sep 28, 2026
Reword what the comments say LDK returns for a synchronously rejected
contribution and for a signed round the monitor never watched, so they
hold at the pinned LDK and once it carries the fixes for rust-lightning
issues 4986 and 4967: a refusal's `DiscardFunding` names the parts no
pending splice attempt still uses, except that before 4986 is fixed a
refusal for a channel or peer LDK no longer knows names the whole
contribution, and once 4967 is fixed a round the monitor never watched
is released by the `DiscardFunding` LDK reports at the force-close.
Scope the `TODO(lightningdevkit#1037)` notes to those fixes.

Developed with assistance from Claude Code.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
jkczyz added a commit to jkczyz/ldk-node that referenced this pull request Sep 28, 2026
Reword what the reconciliation comments say about rounds LDK wrote, so
they hold once the pinned LDK carries the fix for rust-lightning issue
4967: LDK then reports a `DiscardFunding` at a force-close for a
recorded round the monitor never watched, which nothing released
before. Split the `TODO(lightningdevkit#1037)` note into the part that fix removes and
the case that stays: a hand-off LDK never wrote, whose channel is
force-closed as stale at startup, gets no event and must be released
here.

Developed with assistance from Claude Code.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
jkczyz added a commit to jkczyz/ldk-node that referenced this pull request Sep 28, 2026
Reword what the comments say LDK returns for a synchronously rejected
contribution and for a signed round the monitor never watched, so they
hold at the pinned LDK and once it carries the fixes for rust-lightning
issues 4986 and 4967: a refusal's `DiscardFunding` names the parts no
pending splice attempt still uses, except that before 4986 is fixed a
refusal for a channel or peer LDK no longer knows names the whole
contribution, and once 4967 is fixed a round the monitor never watched
is released by the `DiscardFunding` LDK reports at the force-close.
Scope the `TODO(lightningdevkit#1037)` notes to those fixes.

Developed with assistance from Claude Code.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
jkczyz added a commit to jkczyz/ldk-node that referenced this pull request Sep 28, 2026
Reword what the reconciliation comments say about rounds LDK wrote, so
they hold once the pinned LDK carries the fix for rust-lightning issue
4967: LDK then reports a `DiscardFunding` at a force-close for a
recorded round the monitor never watched, which nothing released
before. Split the `TODO(lightningdevkit#1037)` note into the part that fix removes and
the case that stays: a hand-off LDK never wrote, whose channel is
force-closed as stale at startup, gets no event and must be released
here.

Developed with assistance from Claude Code.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
jkczyz added a commit to jkczyz/ldk-node that referenced this pull request Sep 29, 2026
Reword what the comments say LDK returns for a synchronously rejected
contribution and for a signed round the monitor never watched, so they
hold at the pinned LDK and once it carries the fixes for rust-lightning
issues 4986 and 4967: a refusal's `DiscardFunding` names the parts no
pending splice attempt still uses, except that before 4986 is fixed a
refusal for a channel or peer LDK no longer knows names the whole
contribution, and once 4967 is fixed a round the monitor never watched
is released by the `DiscardFunding` LDK reports at the force-close.
Scope the `TODO(lightningdevkit#1037)` notes to those fixes.

Developed with assistance from Claude Code.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
jkczyz added a commit to jkczyz/ldk-node that referenced this pull request Sep 29, 2026
Reword what the reconciliation comments say about rounds LDK wrote, so
they hold once the pinned LDK carries the fix for rust-lightning issue
4967: LDK then reports a `DiscardFunding` at a force-close for a
recorded round the monitor never watched, which nothing released
before. Split the `TODO(lightningdevkit#1037)` note into the part that fix removes and
the case that stays: a hand-off LDK never wrote, whose channel is
force-closed as stale at startup, gets no event and must be released
here.

Developed with assistance from Claude Code.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

@jkczyz jkczyz left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hmm... might be better to do this after #1057, #1079, and #1080 based on what Claude found. Or at least do the splicing parts after those are merged.

Comment thread src/wallet/mod.rs Resolved
Comment thread src/wallet/mod.rs Resolved
Comment thread src/tx_broadcaster.rs Resolved
Comment thread src/wallet/mod.rs Resolved
Comment thread src/event.rs Resolved
Comment thread src/chain/bitcoind.rs Resolved
@tnull

tnull commented Oct 6, 2026

Copy link
Copy Markdown
Collaborator Author

Hmm... might be better to do this after #1057, #1079, and #1080 based on what Claude found. Or at least do the splicing parts after those are merged.

Yeah, that's why I initially left out the splicing parts. Anyways, we'll want to wait for them to land now.

@elsirion

Copy link
Copy Markdown

Just ran into this with my LN node management bot, looking forward to this landing!

bitwalt pushed a commit to kaleidoswap/ldk-node that referenced this pull request Nov 6, 2026
`send_to_address` signed and broadcast a transaction without applying
it to the BDK wallet, which only learned of it at the next sync. A
second send before then could select the same inputs: the network
accepted one and rejected the other, while both callers got a txid.
The rejection was logged at TRACE only. This produced phantom reverse
swap lockups on signet (kaleidoswap/kaleidoswap-maker-rs#853 and
kaleidoswap/kaleidoswap-maker-rs#880).

Modelled on lightningdevkit#1037 (still open upstream), not
copied, and scoped for our mainnet branch:

- Apply sends to the wallet before broadcasting them, and record the
  pending payment then. A failed wallet persist undoes the in-memory
  application and returns an error without broadcasting. Once the
  transaction is durable it is always broadcast, so an error never
  means "maybe sent".
- Apply RBF replacements before broadcasting them too, carrying the
  replaced rounds forward in `conflicting_txids` (the next sync no
  longer reports `TxReplaced`).
- Do not let a sync evict one of our own spends seen in the last
  300 s: a chain source that has not indexed a fresh send yet would
  otherwise hand its inputs back to coin selection. Covers Esplora,
  Electrum and the bitcoind mempool poll. Transactions that only pay
  us are not shielded, so a sender's replacement is still noticed.
- Rebroadcast evicted outbound sends, reapplying them first, unless a
  canonical transaction now spends their inputs or a channel funding
  reserved them.
- Reserve channel funding inputs from creation until broadcast,
  failure or `DiscardFunding`. The locks are in memory only (our
  persister does not write BDK's `locked_outpoints`), so a restart
  releases them instead of stranding inputs. Locked value is not
  spendable and not offered for anchor spends.
- Log Esplora broadcast rejections at ERROR unless bitcoind merely
  already knows the transaction.

Splice inputs stay out of scope, as upstream.

Tests: wallet unit tests for disjoint back-to-back sends, a failed
persist releasing inputs, the eviction grace (own spends only), safe
rebroadcast, double RBF before sync, and funding reservations; the
`back_to_back_onchain_sends_before_sync` integration test against
bitcoind/electrs, which fails without the fix the way signet did.
@jkczyz

jkczyz commented Nov 11, 2026

Copy link
Copy Markdown
Contributor

Yeah, that's why I initially left out the splicing parts. Anyways, we'll want to wait for them to land now.

🤖 #1057, #1079 and #1080 have all landed, so the splicing parts can move again. A few notes from looking at f7b63a8 against current main and the pinned LDK:

The splice intent is gone. #1080 merged without the intent tracker, and #1232 removes the SpliceIntent groundwork #1079 left behind. Until LDK persists a splice round, the locks persisted here are the only record of its inputs.

Recovery of a dropped splice mostly comes for free from LDK. With the pinned LDK, funding_contributed marks the manager for persistence, and every manager write includes a DiscardFunding for any negotiation LDK would drop on reload, computed at write time (channelmanager.rs, the on_restart_splice_failures loop in write). A disconnect queues the same event and marks the manager for persistence, and Node::shutdown disconnects peers before the background processor's final write. So a splice dropped by a stop, a disconnect or a counterparty abort releases its locks through the DiscardFunding handler in this PR, after a restart too.

What remains is a crash window. The wallet persists the locks at coin selection, before the contribution reaches LDK. A crash between that write and the background processor's next manager write leaves the outpoints locked until a later transaction spends them. Since this PR introduces the persisted locks, it should also close that window: at startup, unlock any persisted outpoint that no pending channel funding or in-flight splice contribution references.

Rebase notes.

  • The DiscardFunding handler on main now resolves the channel's funding payments before it reclaims wallet state, returning ReplayEvent if that fails. The TODO(#1037) at event.rs:2535 marks where the unlock goes, after the resolution; the hunk here will conflict.
  • The PR description still says splicing is out of scope; the third commit covers it now.

tnull and others added 5 commits November 12, 2026 10:22
The wallet persister wrote every part of a BDK change set except the
locked outpoints, so a lock placed on a wallet output was forgotten on
restart.

Store the locked outpoints under their own key and restore them when
the wallet is loaded. Released locks are dropped from the stored set
so it does not grow with every lock ever taken.

Co-Authored-By: HAL 9000
The wallet learned of its own sends and fee bumps only from the next
chain sync. Until then a second send selected the same inputs, and a
second fee bump of the same payment failed as the wallet did not know
the first replacement.

Apply such a transaction to the wallet when it is created, and record
its payment the way a sync would. A chain source that has not seen the
transaction yet reports it as gone, so a sync returns a pending send
to the wallet unless that would overrule what else the chain source
reported.

Fixes lightningdevkit#1117.

Co-Authored-By: HAL 9000
Bumping the same on-chain payment twice left the first replacement
unmapped. The payment id is derived from the original txid and each
bump sets the payment's txid to the newest one, so an earlier
replacement is found only through the pending-store entry's
conflicting txids. The bump never added the replaced txid to that
list, and since the bump applies the replacement to the wallet itself,
no later sync reports the replacement to fill it in.

The bump now records the replaced txid in the pending-store entry, so
a replaced round that confirms or is reported by sync still resolves
to the payment.

Co-Authored-By: HAL 9000
The wallet learns of a channel funding transaction only from a chain
sync, as LDK broadcasts it once the counterparty signed. Until then
nothing kept a second channel open or an on-chain send from selecting
the same inputs, leaving one of the two transactions unable to
confirm.

Lock the inputs of a funding transaction when it is built, and keep
them out of coin selection and the spendable balance. The locks are
returned when LDK discards the transaction or never takes it, and
dropped once the transaction confirms. They are recorded with what
the node knows about the transaction, so that a startup sweep can
return the locks of a channel that is gone, as LDK does not report
every transaction it lets go.

Co-Authored-By: HAL 9000
The inputs the wallet selects for a splice are handed to LDK, which
negotiates the transaction spending them with the counterparty. The
wallet sees nothing of that negotiation, so a concurrent splice,
channel open or on-chain send could select the same inputs.

Lock the inputs of a splice contribution when they are selected, the
way channel funding inputs are. They are returned when LDK discards
the contribution, dropped once the splice transaction confirms, and
kept across a restart for as long as LDK tracks the round they were
contributed to. Inputs LDK would refuse to announce are left out of
the selection, as it refuses them only after they are locked.

Co-Authored-By: HAL 9000
@tnull
tnull force-pushed the 2026-08-fix-wallet-utxo-reuse branch from f7b63a8 to 00136a4 Compare November 12, 2026 12:07
@tnull
tnull requested a review from jkczyz November 12, 2026 12:08
@tnull

tnull commented Nov 12, 2026

Copy link
Copy Markdown
Collaborator Author

Now rewrote the branch after #1080 landed.

@tnull

tnull commented Nov 12, 2026

Copy link
Copy Markdown
Collaborator Author

Also included changes to address #1117, superseding #1124.

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.

Don't double-spend ourselves on sequential channel opens

5 participants