Skip to content

fix(wallet): reserve a funding transaction's inputs until it is broadcast or discarded - #2

Merged
tonible14012002 merged 1 commit into
chain-dependent-tierfrom
funding-utxo-reservation
Oct 4, 2026
Merged

tonible14012002 merged 1 commit into
chain-dependent-tierfrom
funding-utxo-reservation

Conversation

@tonible14012002

Copy link
Copy Markdown
Owner

Problem

Two channel opens in quick succession double-spend the wallet's own coins, and the second channel then shows "pending" forever (upstream lightningdevkit/ldk-node#41, open since 2023).

  • BDK keeps a coin selectable until a transaction spending it is in the wallet's graph.
  • LDK broadcasts a funding tx only after the peer's funding_signed.
  • The wallet only learns of that tx at the next sync.
  • So a second FundingGenerationReady inside that window builds its funding from the same coins.
  • The network rejects that funding as a conflict, and its channel waits for confirmations forever.

Seen on mainnet 2026-10-03 (node-3): autopilot opened two 12 080 sat channels 228 ms apart on a node holding one 28 000 sat coin.

  • 1bef01e0… confirmed at block 969743.
  • ce08a17f… was never accepted: mempool.space, blockstream and the node's own lookup all say "not found".
  • The second channel sat at 0/6 confirmations until it was force-closed by hand.
  • Balance check: 15 281 (change) + 12 080 + 639 fee = 28 000, so the second channel's coins never left the wallet.

Fix

  • Reserve at build time. Wallet::create_funding_transaction applies the signed funding to the wallet as unconfirmed (apply_unconfirmed_txs), so coin selection treats its inputs as spent. The next open spends the change, or fails with InsufficientFunds, instead of producing a phantom channel. The swap funding path (create_swap_funding_tx) goes through the same function.
  • Release on discard. New Wallet::release_funding_transaction(txid) evicts it (apply_evicted_txs). The event handler calls it on Event::DiscardFunding { funding_info: FundingInfo::Tx }, and when funding_transaction_generated returns an error.
  • Existing safety nets still apply. A reserved funding that never reaches the network is also released by the BROADCAST tail on rejection, and by any wallet sync that finds it in neither the mempool nor the chain (Electrum/Esplora expected_spk_txids eviction).

Upstream solves this in lightningdevkit/ldk-node#1037 with bdk_wallet 3 UTXO locks. This fork is on bdk_wallet 2, so it uses the wallet graph instead. Drop this commit in favour of lightningdevkit#1037 when the fork moves to bdk_wallet 3.

Tests

New tests in wallet::chain_listen_tests, against a real Wallet with one confirmed coin:

  • back_to_back_fundings_never_spend_the_same_coin
  • a_funding_too_big_for_what_is_left_fails_instead_of_double_spending
  • releasing_a_discarded_funding_hands_its_coin_back
  • releasing_a_transaction_the_wallet_never_built_is_a_no_op

Results:

  • With the reservation line removed, the first two fail. They reproduce the mainnet incident.
  • cargo test --lib --features swaps,cycles,cbf: 285 passed, 0 failed (built against node-app-ldk-node's Cargo.lock).

Release

Fast-forward chain-dependent-tier (head of lamtuanvu#8) to this commit, the same way #1 landed 95266ba. Then bump node-app-ldk-node's pin from 95266ba to de0ce4c. Do not merge, rebase or force-push.

🤖 Generated with Claude Code

…cast or discarded

BDK keeps a coin selectable until a transaction spending it is in the
wallet's graph. LDK broadcasts a channel's funding only after the peer's
`funding_signed`, and the wallet only learns of it at the next sync, so a
second open inside that window builds its funding from the same coins.
That funding is a double-spend the network rejects, and its channel then
waits for confirmations forever (upstream lightningdevkit#41).

Seen on mainnet 2026-10-03: autopilot opened two channels 228 ms apart on a
node with one coin. Tx 1bef01e0 confirmed; tx ce08a17f was never accepted.
The second channel sat at 0/6 confirmations until it was force-closed by
hand.

- `create_funding_transaction` now applies the signed funding to the
  wallet as unconfirmed, so coin selection treats its inputs as spent. The
  swap funding path shares it.
- `release_funding_transaction` evicts it again, and the event handler
  calls it when LDK discards the funding: on `DiscardFunding`, or when
  `funding_transaction_generated` refuses it.
- A funding that was never broadcast is also released by the BROADCAST
  tail on rejection, and by any sync that finds it in neither the mempool
  nor the chain.

Upstream fixes this in lightningdevkit#1037 with bdk_wallet 3's
UTXO locks. This fork is on bdk_wallet 2, so it uses the wallet graph
instead. Drop this commit for lightningdevkit#1037 when the fork moves to bdk_wallet 3.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@tonible14012002
tonible14012002 merged commit 1aec5c7 into chain-dependent-tier Oct 4, 2026
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