Skip to content

Commit 51ba859

Browse files
Add test demonstrating funding tx input double-spend
`create_funding_transaction` never applies the built-and-signed funding transaction back to the on-chain wallet, so the inputs it selected remain selectable until the next chain sync. Two channel opens between two syncs therefore build funding transactions double-spending the same UTXO(s). With 0-conf channels both channels go ChannelReady immediately, but only one funding transaction can enter the mempool. The other is rejected by bitcoind at broadcast time (HTTP 400), which the Esplora broadcaster logs at trace level and otherwise ignores. The losing channel stays ready and usable while being completely unbacked on-chain. Add an integration test proving this with two sequential 0-conf opens against a wallet holding a single UTXO.
1 parent 47dad6d commit 51ba859

1 file changed

Lines changed: 176 additions & 0 deletions

File tree

tests/integration_tests_rust.rs

Lines changed: 176 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ use common::{
2727
wait_for_tx, TestChainSource, TestConfig, TestStoreType, TestSyncStore,
2828
};
2929
use electrsd::corepc_node::Node as BitcoinD;
30+
use electrsd::electrum_client::ElectrumApi;
3031
use electrsd::ElectrsD;
3132
use ldk_node::config::{AsyncPaymentsRole, EsploraSyncConfig};
3233
use ldk_node::entropy::NodeEntropy;
@@ -169,6 +170,181 @@ async fn channel_full_cycle_0conf_0reserve() {
169170
.await;
170171
}
171172

173+
// Demonstrates that `create_funding_transaction` never applies the built-and-signed funding
174+
// transaction back to the on-chain (BDK) wallet, so the inputs it selected remain selectable
175+
// until the next chain/mempool sync round-trip. Consequently, two channel opens that happen
176+
// between two wallet syncs build funding transactions that double-spend the same UTXO(s).
177+
//
178+
// With 0-conf channels this is especially nasty: both channels go `ChannelReady` immediately,
179+
// but only one funding transaction can ever enter the mempool. The other one is rejected by
180+
// bitcoind at broadcast time (HTTP 400, `txn-mempool-conflict`), which the Esplora broadcaster
181+
// logs at `trace` level and otherwise ignores. The losing channel remains "ready" and usable
182+
// while being completely unbacked on-chain: a phantom channel whose eventual commitment
183+
// transaction can never confirm (`bad-txns-inputs-missingorspent`).
184+
//
185+
// This test asserts the *current* (buggy) behavior to prove the issue exists. Once funding
186+
// inputs are properly marked as spent at build time (e.g. via `Wallet::apply_unconfirmed_txs`),
187+
// the second `open_channel` below should fail with `Error::InsufficientFunds` instead, and
188+
// this test will need to be inverted.
189+
#[tokio::test(flavor = "multi_thread", worker_threads = 1)]
190+
async fn zero_conf_channel_funding_tx_double_spend() {
191+
let (bitcoind, electrsd) = setup_bitcoind_and_electrsd();
192+
let chain_source = TestChainSource::Esplora(&electrsd);
193+
194+
// Node A is the funder. Nodes B and C trust node A for 0-conf channels.
195+
println!("== Node A ==");
196+
let config_a = random_config(false);
197+
let node_a = setup_node(&chain_source, config_a);
198+
199+
println!("\n== Node B ==");
200+
let mut config_b = random_config(false);
201+
config_b.node_config.trusted_peers_0conf.push(node_a.node_id());
202+
let node_b = setup_node(&chain_source, config_b);
203+
204+
println!("\n== Node C ==");
205+
let mut config_c = random_config(false);
206+
config_c.node_config.trusted_peers_0conf.push(node_a.node_id());
207+
let node_c = setup_node(&chain_source, config_c);
208+
209+
// Give node A exactly ONE UTXO. Every funding transaction node A builds *must* spend it,
210+
// so any two funding transactions built from the same wallet state are double-spends of
211+
// each other by construction.
212+
let premine_amount_sat = 100_000;
213+
let addr_a = node_a.onchain_payment().new_address().unwrap();
214+
premine_blocks(&bitcoind.client, &electrsd.client).await;
215+
let utxo_txid = distribute_funds_unconfirmed(
216+
&bitcoind.client,
217+
&electrsd.client,
218+
vec![addr_a.clone()],
219+
Amount::from_sat(premine_amount_sat),
220+
)
221+
.await;
222+
generate_blocks_and_wait(&bitcoind.client, &electrsd.client, 1).await;
223+
node_a.sync_wallets().unwrap();
224+
assert_eq!(node_a.list_balances().spendable_onchain_balance_sats, premine_amount_sat);
225+
226+
let utxo_tx = electrsd.client.transaction_get(&utxo_txid).unwrap();
227+
let utxo_vout = utxo_tx
228+
.output
229+
.iter()
230+
.position(|o| o.script_pubkey == addr_a.script_pubkey())
231+
.expect("premined output to node A not found");
232+
let utxo_outpoint = bitcoin::OutPoint { txid: utxo_txid, vout: utxo_vout as u32 };
233+
234+
// Two channels of 70k sats each require 140k sats total, but node A only owns 100k sats.
235+
// No two valid, non-conflicting funding transactions can exist for these amounts.
236+
let funding_amount_sat = 70_000;
237+
238+
// Open the first 0-conf channel A -> B and wait until its funding transaction is in the
239+
// mempool. There is no race here: the second funding transaction below is built strictly
240+
// *after* the first one was broadcast and accepted.
241+
println!("\nA -- open_channel -> B");
242+
node_a
243+
.open_channel(
244+
node_b.node_id(),
245+
node_b.listening_addresses().unwrap().first().unwrap().clone(),
246+
funding_amount_sat,
247+
None,
248+
None,
249+
)
250+
.unwrap();
251+
let funding_txo_b = expect_channel_pending_event!(node_a, node_b.node_id());
252+
expect_channel_pending_event!(node_b, node_a.node_id());
253+
wait_for_tx(&electrsd.client, funding_txo_b.txid).await;
254+
255+
// 0-conf: the channel is ready without a single confirmation.
256+
expect_channel_ready_event!(node_a, node_b.node_id());
257+
expect_channel_ready_event!(node_b, node_a.node_id());
258+
259+
// The first funding transaction spends node A's only UTXO.
260+
let funding_tx_b = electrsd.client.transaction_get(&funding_txo_b.txid).unwrap();
261+
assert_eq!(funding_tx_b.input.len(), 1);
262+
assert_eq!(funding_tx_b.input[0].previous_output, utxo_outpoint);
263+
264+
// NOTE: We deliberately do NOT call `node_a.sync_wallets()` here. Nothing else marks the
265+
// just-spent UTXO as spent: `create_funding_transaction` doesn't apply the transaction to
266+
// the wallet, so the wallet only learns about the spend when a sync pulls the transaction
267+
// back from the chain source. In production this window lasts until the next background
268+
// sync completes.
269+
270+
// Open the second 0-conf channel A -> C. Node A has already committed its only UTXO to the
271+
// first channel, so with correct input accounting this MUST fail with
272+
// `Error::InsufficientFunds` (only ~30k sats of unconfirmed change remain). Instead, the
273+
// stale wallet still reports 100k sats spendable and happily double-spends the same UTXO.
274+
println!("\nA -- open_channel -> C");
275+
node_a
276+
.open_channel(
277+
node_c.node_id(),
278+
node_c.listening_addresses().unwrap().first().unwrap().clone(),
279+
funding_amount_sat,
280+
None,
281+
None,
282+
)
283+
.unwrap();
284+
let funding_txo_c = expect_channel_pending_event!(node_a, node_c.node_id());
285+
expect_channel_pending_event!(node_c, node_a.node_id());
286+
assert_ne!(funding_txo_b.txid, funding_txo_c.txid);
287+
288+
// The second channel also goes `ChannelReady` at 0-conf, although its funding transaction
289+
// is a double-spend that can never confirm.
290+
expect_channel_ready_event!(node_a, node_c.node_id());
291+
expect_channel_ready_event!(node_c, node_a.node_id());
292+
293+
// Node A now has channels worth more than everything it ever owned on-chain.
294+
let total_channel_value_sats: u64 =
295+
node_a.list_channels().iter().map(|c| c.channel_value_sats).sum();
296+
assert_eq!(total_channel_value_sats, 2 * funding_amount_sat);
297+
assert!(total_channel_value_sats > premine_amount_sat);
298+
299+
// The second funding transaction was handed to the broadcaster, but bitcoind rejects it as
300+
// a mempool conflict. The Esplora broadcaster swallows the HTTP 400 at `trace` level, so
301+
// the transaction silently evaporates: it never enters the mempool.
302+
tokio::time::sleep(std::time::Duration::from_secs(3)).await;
303+
let mempool = bitcoind.client.get_raw_mempool().unwrap().into_model().unwrap();
304+
assert!(
305+
mempool.0.iter().any(|txid| *txid == funding_txo_b.txid),
306+
"First funding tx should be in the mempool"
307+
);
308+
assert!(
309+
!mempool.0.iter().any(|txid| *txid == funding_txo_c.txid),
310+
"Second funding tx should have been rejected as a double-spend"
311+
);
312+
313+
// Mine blocks: the first channel confirms, the second one never can.
314+
generate_blocks_and_wait(&bitcoind.client, &electrsd.client, 6).await;
315+
node_a.sync_wallets().unwrap();
316+
node_b.sync_wallets().unwrap();
317+
node_c.sync_wallets().unwrap();
318+
319+
assert!(
320+
electrsd.client.transaction_get(&funding_txo_c.txid).is_err(),
321+
"Second funding tx must not exist on-chain"
322+
);
323+
324+
let channel_a_b = node_a
325+
.list_channels()
326+
.into_iter()
327+
.find(|c| c.counterparty_node_id == node_b.node_id())
328+
.unwrap();
329+
assert!(channel_a_b.confirmations.unwrap() >= 6);
330+
331+
// The losing channel is a phantom: still "ready" and usable, holding 70k sats that are
332+
// backed by nothing on-chain. Its funding outpoint does not and will never exist, so any
333+
// commitment transaction spending it bounces with `bad-txns-inputs-missingorspent`.
334+
let channel_a_c = node_a
335+
.list_channels()
336+
.into_iter()
337+
.find(|c| c.counterparty_node_id == node_c.node_id())
338+
.unwrap();
339+
assert!(channel_a_c.is_channel_ready);
340+
assert!(channel_a_c.is_usable);
341+
assert_eq!(channel_a_c.confirmations, Some(0));
342+
343+
node_a.stop().unwrap();
344+
node_b.stop().unwrap();
345+
node_c.stop().unwrap();
346+
}
347+
172348
#[tokio::test(flavor = "multi_thread", worker_threads = 1)]
173349
async fn channel_open_fails_when_funds_insufficient() {
174350
let (bitcoind, electrsd) = setup_bitcoind_and_electrsd();

0 commit comments

Comments
 (0)