Skip to content

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

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

tnull wants to merge 3 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.

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

(tx, locked_wallet.take_staged().unwrap_or_default())
};
locked_persister.persist_changeset(change_set).await.map_err(|e| {

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.

If this persistence fails, the inputs locked above are leaked. persist_changeset retains the staged changeset on failure, but this function returns without the transaction, so the caller cannot release the locks and LDK cannot later emit DiscardFunding.

A subsequent successful wallet persistence makes the orphan locks durable. I reproduced this across a wallet reload with a fault-injecting store. The same issue occurs in select_confirmed_utxos below. Could we roll back both the in-memory and staged locks before returning the error, with regression tests for both paths?

@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
Comment on lines +1374 to +1380
// Keep selected wallet inputs unavailable until LDK either broadcasts a transaction
// spending them or returns them through `DiscardFunding`.
for txin in unsigned_tx.input.iter().filter(|txin| {
must_spend.iter().all(|input| input.outpoint != txin.previous_output)
}) {
locked_wallet.lock_outpoint(txin.previous_output);
}

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.

Claude:

The locks are persisted here, but nothing records yet which splice they belong to. If anything fails before that record exists, they stay locked for good:

  • validate_inputs rejects an input (oversized prevtx) right after this returns.
  • The persist below fails (Jolah1's point at 673).
  • The node crashes.

Once the splice confirms, nothing unlocks its inputs either.

Once #1057/#1079/#1080 land, #1080 persists a record of each splice, inputs included, before calling LDK, and releases from it at startup. So:

Comment thread src/wallet/mod.rs
locked_wallet.list_unspent().filter(|u| confirmed_txs.contains(&u.outpoint.txid));
let unspent_confirmed_utxos = locked_wallet.list_unspent().filter(|u| {
confirmed_txs.contains(&u.outpoint.txid)
&& !locked_wallet.is_outpoint_locked(u.outpoint)

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.

Claude:

A splice-in that selects every confirmed UTXO starves anchor CPFP for the whole negotiation. Leave an anchor reserve unlocked (or don't filter here), and keep the original assertion at 917, which this would have tripped.

Comment thread src/tx_broadcaster.rs
Comment on lines +146 to +148
Some(LdkTransactionType::Funding { .. }) => {
wallet.prepare_funding_broadcast(tx).await?
},

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.

Note that #1057 removes classify_package and wallet` from the broadcaster, so we may want to consider landing that first. This would move to the event handler, IIUC.

Comment thread src/wallet/mod.rs
.any(|txin| locked_wallet.is_outpoint_locked(txin.previous_output));
let owns_initial_funding_locks = is_funding && !was_known;

if unavailable_conflict || (has_locked_input && !owns_initial_funding_locks) {

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.

Cluade:

After a splice round we contributed to is evicted from the mempool, the tip-change rebroadcast (495) sends it back through here: has_locked_input is true because its own inputs are still locked from select_confirmed_utxos, and owns_initial_funding_locks is false because it isn't Funding-typed, so our own round is refused as if a newer selection held those inputs. The check can't tell a transaction's own locks from another's; once #1057/#1079/#1080 land, the splice tracker knows each round's inputs and can answer that.

Comment thread src/event.rs
.map(|script_pubkey| bitcoin::TxOut { value: bitcoin::Amount::ZERO, script_pubkey })
.collect(),
}),
FundingInfo::OutPoint { .. } => None,

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.

Claude:

An LSPS2 manual-broadcast channel closed before broadcast lands here, so the inputs locked in create_funding_transaction are never released. The handler can fetch the transaction the service stored, or the wallet can keep the created transaction in the graph and cancel by txid.

Comment thread src/chain/bitcoind.rs Outdated
let mempool_entries_cache = mempool_entries_cache.lock().await;
let observed_at =
SystemTime::now().duration_since(UNIX_EPOCH).unwrap_or_default().as_secs();
let evicted_at = observed_at.max(latest_mempool_timestamp.load(Ordering::Relaxed));

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.

Claude:

If a poll runs before Bitcoin Core has a just-applied transaction, evicted_at equals its last_seen and BDK drops it (it needs last_evicted < last_seen), so its inputs are selectable again. Skip txids whose last_seen is at or after the snapshot time.

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

tnull added 3 commits October 6, 2026 10:48
Record wallet transactions before broadcast and reserve funding inputs
until their transactions are durable. This prevents concurrent
operations from selecting the same inputs. Dropped transactions remain
recoverable through the existing rebroadcast path.

Co-Authored-By: HAL 9000
Keep wallet-reuse validation compatible with main’s asynchronous data
store API and explicit cache policies. The wallet mutex must not cross
an await because background wallet tasks are Send.

Co-Authored-By: HAL 9000
Reserve wallet inputs as soon as splice coin selection returns so
concurrent wallet operations cannot reuse them before the funding
transaction reaches the wallet. Release discarded contributions so
failed or superseded splice rounds do not strand funds.

Co-Authored-By: HAL 9000
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

4 participants