Skip to content

Splice: select confirmed UTXOs + prevent double spend#42

Merged
amackillop merged 2 commits into
lsp-0.7.0from
austin_mdk-1094_select-confirmed-txs-for-splice
Jun 29, 2026
Merged

Splice: select confirmed UTXOs + prevent double spend#42
amackillop merged 2 commits into
lsp-0.7.0from
austin_mdk-1094_select-confirmed-txs-for-splice

Conversation

@amackillop

Copy link
Copy Markdown

Problem

A staging LSPS4 splice went perma-pending: it selected a coin, negotiated the
splice, then the funding tx never confirmed. Signing failed every retry with
"witness count of 0" and the single pending_splice slot wedged forever.

Two potential causes: the LSPS4 path selected unconfirmed coins (an unconfirmed parent can
be evicted before the detached signing step, leaving an empty witness), and even
confirmed coins were never reserved, so a concurrent build could double-spend
them.

Approach

A splice signs its funding tx later, in a detached event, so the coins can't be
marked spent at selection (a spent coin won't sign). Instead the wallet keeps an
in-memory reserved_utxos set: selection adds the coins, every selection path
excludes the set, and get_balances discounts the reserved value. The signing
event hands back the funding tx, whose inputs are exactly the reserved coins, so
finalize_splice (success) marks them spent and abort_splice (failure) frees
them.

Commits

  1. Select confirmed UTXOs for LSPS4 splices — one-line trigger fix.
  2. Reserve splice coins without marking them spent — the reserved set,
    selection exclusion, balance discounting, finalize/abort, and two integration
    tests.

Scope

Removes the unconfirmed-coin trigger and closes the in-process double-selection
race. It does not un-wedge an already-stuck splice slot — that needs an
LDK-level abort (separate fork change; see the TODO in event.rs). Reorg and
restart residuals are documented inline.

Testing

cargo test --test integration_tests_rust splice_:

  • splice_funding_tx_no_double_spend: one coin, splice, then without a sync
    spendable drops below the splice amount and a competing open is rejected.
  • splice_failure_releases_reserved_utxos: two coins, a second splice on the
    same channel is rejected and its reserved coin is freed and reusable.

Both verified genuine by neutering the fix and confirming the test fails.

The LSPS4 splice path picked coins with select_utxos, which includes
unconfirmed ones. Selection and signing are split across an event:
splice_channel_for_lsps4 chooses the coins, but they are only signed
later when FundingTransactionReadyForSigning fires. If the parent tx
leaves the mempool in that gap (dropped under fee pressure, replaced, or
lost when our node restarts without a persisted mempool), get_utxo
returns None at sign time, the input is left unsigned, and the splice
wedges permanently with a holder witness count of 0.
select_confirmed_utxos removes that trigger
and makes the existing "Insufficient confirmed UTXOs" error honest; the
manual splice_in path already selects confirmed coins.

The cost is losing unconfirmed funds as a splice source, which can break
the one-channel-per-client invariant under load. When a splice is needed
but no confirmed coin is left, selection fails and the SpliceChannel
handler falls back to open_channel_for_lsps4, opening a second channel to
a client who should only have one. With a single confirmed UTXO:

  1. Payment P1 to client C needs a splice. We spend the confirmed coin
     into splice A; its change Y is now unconfirmed. 0conf locks A and
     frees the single pending_splice slot.
  2. Payment P2 to C arrives before the next block. The only coin left is
     Y, unconfirmed, so selection fails.
  3. The handler falls back and opens a second channel to C.

Before this change P2 chained on Y and kept the single channel, at the
cost of wedging if A dropped from the mempool. We accept the
second-channel fallback (one block wide, only once confirmed funds are
exhausted) over permanently wedging on a coin that can vanish before we
sign. Restoring single-channel behaviour under load is left to a
follow-up. Reserving the selected coins is handled in later commits.
A splice signs its funding tx later, in a detached
FundingTransactionReadyForSigning event, not at selection time like a
channel open. So the coins cannot be reserved by recording an unsigned
placeholder via apply_unconfirmed_txs: a coin marked spent in the BDK
graph is not returned by get_utxo, so sign_owned_inputs leaves its input
unsigned and the witness count drops to 0. That is the same "witness
count of 0" failure that wedges a production splice, so reserving that
way reproduces the bug it was meant to prevent.

Instead keep the coins live and hold their outpoints in an in-memory
reserved set that every selection path excludes (select_utxos_inner,
create_funding_transaction, send_to_address, and the Anchor-bump UTXO
source), so a concurrent build cannot reselect them while signing stays
possible. get_balances discounts the reserved value from spendable and
gates the Anchor-availability assert on it, so reserving the only
confirmed coin neither reports it spendable nor trips the assert.

The reserved set is the single source of truth: the deferred signing
event hands back the funding tx, whose wallet inputs are exactly the
coins this splice reserved, so on success finalize_splice marks them
spent against that tx and on failure abort_splice frees them, both
recovered by intersecting the tx inputs with the set. No separate
channel-to-outpoints registry is needed. This also replaces the old
empty-input cancel_tx rollback in both splice paths, a no-op that freed
nothing.

The reserved set and the wallet are separate locks reachable from both a
splice selection and a background balance or Anchor-bump query, so they
must never be held together or they deadlock. Every path snapshots one
and drops its guard before taking the other.

The reserved set is in-memory, so a restart drops it, but the splice
funding tx that would spend the coins is lost too, so nothing is left
double-spendable. This does not un-wedge an already-stuck LDK splice
slot, which needs an LDK-level abort (separate change).
@amackillop amackillop changed the title Austin mdk 1094 select confirmed txs for splice Splice: select confirmed UTXOs + prevent double spend Jun 29, 2026
@amackillop amackillop merged commit 535b76f into lsp-0.7.0 Jun 29, 2026
9 of 34 checks passed
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.

1 participant