Splice: select confirmed UTXOs + prevent double spend#42
Merged
amackillop merged 2 commits intoJun 29, 2026
Conversation
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).
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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_spliceslot 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_utxosset: selection adds the coins, every selection pathexcludes the set, and
get_balancesdiscounts the reserved value. The signingevent hands back the funding tx, whose inputs are exactly the reserved coins, so
finalize_splice(success) marks them spent andabort_splice(failure) freesthem.
Commits
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 andrestart residuals are documented inline.
Testing
cargo test --test integration_tests_rust splice_:splice_funding_tx_no_double_spend: one coin, splice, then without a syncspendable drops below the splice amount and a competing open is rejected.
splice_failure_releases_reserved_utxos: two coins, a second splice on thesame channel is rejected and its reserved coin is freed and reusable.
Both verified genuine by neutering the fix and confirming the test fails.