From fd60a230337ec2cbf335b85e9ffef42faf20e245 Mon Sep 17 00:00:00 2001 From: amackillop Date: Fri, 26 Jun 2026 09:05:04 -0700 Subject: [PATCH 1/2] Select confirmed UTXOs for LSPS4 splices 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. --- src/liquidity.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/liquidity.rs b/src/liquidity.rs index 495e2b5e9..20e54720c 100644 --- a/src/liquidity.rs +++ b/src/liquidity.rs @@ -2068,7 +2068,7 @@ where let inputs = self .wallet - .select_utxos(vec![shared_input], &[shared_output], fee_rate) + .select_confirmed_utxos(vec![shared_input], &[shared_output], fee_rate) .map_err(|()| APIError::APIMisuseError { err: "Insufficient confirmed UTXOs for splice".to_string(), })?; From 547f6f6f3a79ec70465460120ca2cf297ead9033 Mon Sep 17 00:00:00 2001 From: amackillop Date: Mon, 29 Jun 2026 06:54:50 -0700 Subject: [PATCH 2/2] Reserve splice coins without marking them spent 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). --- src/event.rs | 70 ++++++++---- src/lib.rs | 40 +++---- src/liquidity.rs | 39 +++---- src/wallet/mod.rs | 193 +++++++++++++++++++++++++------ tests/integration_tests_rust.rs | 195 ++++++++++++++++++++++++++++++++ 5 files changed, 435 insertions(+), 102 deletions(-) diff --git a/src/event.rs b/src/event.rs index 03ae5e2a8..d16863037 100644 --- a/src/event.rs +++ b/src/event.rs @@ -1761,29 +1761,53 @@ where counterparty_node_id, unsigned_transaction, .. - } => match self.wallet.sign_owned_inputs(unsigned_transaction) { - Ok(partially_signed_tx) => { - match self.channel_manager.funding_transaction_signed( - &channel_id, - &counterparty_node_id, - partially_signed_tx, - ) { - Ok(()) => { - log_info!( - self.logger, - "Signed funding transaction for channel {} with counterparty {}", - channel_id, - counterparty_node_id - ); - }, - Err(e) => { - // TODO(splicing): Abort splice once supported in LDK 0.3 - debug_assert!(false, "Failed signing funding transaction: {:?}", e); - log_error!(self.logger, "Failed signing funding transaction: {:?}", e); - }, - } - }, - Err(()) => log_error!(self.logger, "Failed signing funding transaction"), + } => { + // Keep a copy of the unsigned tx: signing consumes it, but a sign failure still + // needs the tx to identify and free the coins this splice reserved. + let reserved_tx = unsigned_transaction.clone(); + match self.wallet.sign_owned_inputs(unsigned_transaction) { + Ok(partially_signed_tx) => { + let funding_tx = partially_signed_tx.clone(); + match self.channel_manager.funding_transaction_signed( + &channel_id, + &counterparty_node_id, + partially_signed_tx, + ) { + Ok(()) => { + if let Err(e) = self.wallet.finalize_splice(funding_tx) { + // The signed tx will be broadcast by LDK but the spend was not + // persisted; a restart would see these coins spendable again. + log_error!( + self.logger, + "Failed to record splice funding spend for channel {}: {:?}", + channel_id, + e + ); + } + log_info!( + self.logger, + "Signed funding transaction for channel {} with counterparty {}", + channel_id, + counterparty_node_id + ); + }, + Err(e) => { + // TODO(splicing): Abort splice once supported in LDK 0.3 + self.wallet.abort_splice(&reserved_tx); + debug_assert!(false, "Failed signing funding transaction: {:?}", e); + log_error!( + self.logger, + "Failed signing funding transaction: {:?}", + e + ); + }, + } + }, + Err(()) => { + self.wallet.abort_splice(&reserved_tx); + log_error!(self.logger, "Failed signing funding transaction"); + }, + } }, LdkEvent::SplicePending { channel_id, diff --git a/src/lib.rs b/src/lib.rs index 809775d61..51e643ed1 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1338,7 +1338,7 @@ impl Node { let fee_rate = self.fee_estimator.estimate_fee_rate(ConfirmationTarget::ChannelFunding); - let inputs = self + let (inputs, reserved_outpoints) = self .wallet .select_confirmed_utxos(vec![shared_input], &[shared_output], fee_rate) .map_err(|()| { @@ -1365,30 +1365,22 @@ impl Node { }, }; - self.channel_manager - .splice_channel( - &channel_details.channel_id, - &counterparty_node_id, - contribution, - funding_feerate_per_kw, - None, - ) - .map_err(|e| { + match self.channel_manager.splice_channel( + &channel_details.channel_id, + &counterparty_node_id, + contribution, + funding_feerate_per_kw, + None, + ) { + // Coins stay reserved until the deferred signing step frees them. + Ok(()) => Ok(()), + Err(e) => { log_error!(self.logger, "Failed to splice channel: {:?}", e); - let tx = bitcoin::Transaction { - version: bitcoin::transaction::Version::TWO, - lock_time: bitcoin::absolute::LockTime::ZERO, - input: vec![], - output: vec![bitcoin::TxOut { - value: Amount::ZERO, - script_pubkey: change_address.script_pubkey(), - }], - }; - match self.wallet.cancel_tx(&tx) { - Ok(()) => Error::ChannelSplicingFailed, - Err(e) => e, - } - }) + // The splice never reached LDK; free the reserved coins now. + self.wallet.release_reserved_utxos(&reserved_outpoints); + Err(Error::ChannelSplicingFailed) + }, + } } else { log_error!( self.logger, diff --git a/src/liquidity.rs b/src/liquidity.rs index 20e54720c..aed4615bc 100644 --- a/src/liquidity.rs +++ b/src/liquidity.rs @@ -2066,7 +2066,7 @@ where let fee_rate = self.fee_estimator.estimate_fee_rate(ConfirmationTarget::ChannelFunding); - let inputs = self + let (inputs, reserved_outpoints) = self .wallet .select_confirmed_utxos(vec![shared_input], &[shared_output], fee_rate) .map_err(|()| APIError::APIMisuseError { @@ -2092,28 +2092,21 @@ where }, }; - self.channel_manager - .splice_channel( - &channel_id, - &counterparty_node_id, - contribution, - funding_feerate_per_kw, - None, - ) - .map_err(|e| { - // Cancel change address reservation on failure - let tx = bitcoin::Transaction { - version: bitcoin::transaction::Version::TWO, - lock_time: bitcoin::absolute::LockTime::ZERO, - input: vec![], - output: vec![bitcoin::TxOut { - value: Amount::ZERO, - script_pubkey: change_address.script_pubkey(), - }], - }; - let _ = self.wallet.cancel_tx(&tx); - e - }) + match self.channel_manager.splice_channel( + &channel_id, + &counterparty_node_id, + contribution, + funding_feerate_per_kw, + None, + ) { + // Coins stay reserved until the deferred signing step frees them. + Ok(()) => Ok(()), + Err(e) => { + // The splice never reached LDK; free the reserved coins now. + self.wallet.release_reserved_utxos(&reserved_outpoints); + Err(e) + }, + } } /// Open a channel for LSPS4. Extracted from the OpenChannel event handler diff --git a/src/wallet/mod.rs b/src/wallet/mod.rs index 23538ed46..ad8991e83 100644 --- a/src/wallet/mod.rs +++ b/src/wallet/mod.rs @@ -5,6 +5,7 @@ // http://opensource.org/licenses/MIT>, at your option. You may not use this file except in // accordance with one or both of these licenses. +use std::collections::HashSet; use std::future::Future; use std::ops::Deref; use std::pin::Pin; @@ -27,7 +28,7 @@ use bitcoin::secp256k1::ecdh::SharedSecret; use bitcoin::secp256k1::ecdsa::{RecoverableSignature, Signature}; use bitcoin::secp256k1::{All, PublicKey, Scalar, Secp256k1, SecretKey}; use bitcoin::{ - Address, Amount, FeeRate, ScriptBuf, Transaction, TxOut, Txid, WPubkeyHash, Weight, + Address, Amount, FeeRate, OutPoint, ScriptBuf, Transaction, TxOut, Txid, WPubkeyHash, Weight, WitnessProgram, WitnessVersion, }; use lightning::chain::chaininterface::BroadcasterInterface; @@ -67,6 +68,7 @@ pub(crate) struct Wallet { // A BDK on-chain wallet. inner: Mutex>, persister: Mutex, + reserved_utxos: Mutex>, broadcaster: Arc, fee_estimator: Arc, payment_store: Arc, @@ -83,7 +85,22 @@ impl Wallet { ) -> Self { let inner = Mutex::new(wallet); let persister = Mutex::new(wallet_persister); - Self { inner, persister, broadcaster, fee_estimator, payment_store, config, logger } + let reserved_utxos = Mutex::new(HashSet::new()); + Self { + inner, + persister, + reserved_utxos, + broadcaster, + fee_estimator, + payment_store, + config, + logger, + } + } + + // Snapshot the coins reserved for in-flight splices, for exclusion from coin selection. + fn reserved_outpoints(&self) -> Vec { + self.reserved_utxos.lock().unwrap().iter().copied().collect() } pub(crate) fn get_full_scan_request(&self) -> FullScanRequest { @@ -234,10 +251,15 @@ impl Wallet { ) -> Result { let fee_rate = self.fee_estimator.estimate_fee_rate(confirmation_target); + let reserved = self.reserved_outpoints(); let mut locked_wallet = self.inner.lock().unwrap(); let mut tx_builder = locked_wallet.build_tx(); - tx_builder.add_recipient(output_script, amount).fee_rate(fee_rate).nlocktime(locktime); + tx_builder + .add_recipient(output_script, amount) + .fee_rate(fee_rate) + .nlocktime(locktime) + .unspendable(reserved); let mut psbt = match tx_builder.finish() { Ok(psbt) => { @@ -326,19 +348,37 @@ impl Wallet { pub(crate) fn get_balances( &self, total_anchor_channels_reserve_sats: u64, ) -> Result<(u64, u64), Error> { + // Reserved coins are still owned (kept in `total`) but not spendable: discount them from + // `spendable` and from the Anchor-availability assert below. + let reserved_sat = self.reserved_confirmed_sat(); let balance = self.inner.lock().unwrap().balance(); // Make sure `list_confirmed_utxos` returns at least one `Utxo` we could use to spend/bump - // Anchors if we have any confirmed amounts. + // Anchors if we have any confirmed amounts left after reservations. #[cfg(debug_assertions)] - if balance.confirmed != Amount::ZERO { + if balance.confirmed.to_sat() > reserved_sat { debug_assert!( self.list_confirmed_utxos_inner().map_or(false, |v| !v.is_empty()), "Confirmed amounts should always be available for Anchor spending" ); } - self.get_balances_inner(balance, total_anchor_channels_reserve_sats) + let (total, spendable) = + self.get_balances_inner(balance, total_anchor_channels_reserve_sats)?; + Ok((total, spendable.saturating_sub(reserved_sat))) + } + + // Total value of coins currently reserved for in-flight splices. Splices only reserve confirmed + // coins, so this is the confirmed amount that is no longer spendable. + fn reserved_confirmed_sat(&self) -> u64 { + // Snapshot and drop the reserved lock before taking the wallet lock: the two never overlap. + let reserved = self.reserved_outpoints(); + let locked_wallet = self.inner.lock().unwrap(); + reserved + .iter() + .filter_map(|outpoint| locked_wallet.get_utxo(*outpoint)) + .map(|utxo| utxo.txout.value.to_sat()) + .sum() } fn get_balances_inner( @@ -377,12 +417,15 @@ impl Wallet { let fee_rate = fee_rate.unwrap_or_else(|| self.fee_estimator.estimate_fee_rate(confirmation_target)); + // Exclude coins reserved for an in-flight splice. + let reserved = self.reserved_outpoints(); + let tx = { let mut locked_wallet = self.inner.lock().unwrap(); // Prepare the tx_builder. We properly check the reserve requirements (again) further down. const DUST_LIMIT_SATS: u64 = 546; - let tx_builder = match send_amount { + let mut tx_builder = match send_amount { OnchainSendAmount::ExactRetainingReserve { amount_sats, .. } => { let mut tx_builder = locked_wallet.build_tx(); let amount = Amount::from_sat(amount_sats); @@ -407,7 +450,8 @@ impl Wallet { change_address_info.address.script_pubkey(), Amount::from_sat(cur_anchor_reserve_sats), ) - .fee_rate(fee_rate); + .fee_rate(fee_rate) + .unspendable(reserved.clone()); match tmp_tx_builder.finish() { Ok(psbt) => psbt.unsigned_tx, Err(err) => { @@ -460,6 +504,8 @@ impl Wallet { }, }; + tx_builder.unspendable(reserved); + let mut psbt = match tx_builder.finish() { Ok(psbt) => { log_trace!(self.logger, "Created PSBT: {:?}", psbt); @@ -585,20 +631,16 @@ impl Wallet { pub(crate) fn select_confirmed_utxos( &self, must_spend: Vec, must_pay_to: &[TxOut], fee_rate: FeeRate, - ) -> Result, ()> { - self.select_utxos_inner(must_spend, must_pay_to, fee_rate, true) - } - - pub(crate) fn select_utxos( - &self, must_spend: Vec, must_pay_to: &[TxOut], fee_rate: FeeRate, - ) -> Result, ()> { - self.select_utxos_inner(must_spend, must_pay_to, fee_rate, false) + ) -> Result<(Vec, Vec), ()> { + self.select_utxos_inner(must_spend, must_pay_to, fee_rate) } + // Select coins to fund a splice, add them to `reserved_utxos`, and return their outpoints so the + // caller can release them on `finalize_splice` (signed) or `abort_splice` (failed). fn select_utxos_inner( &self, must_spend: Vec, must_pay_to: &[TxOut], fee_rate: FeeRate, - confirmed_only: bool, - ) -> Result, ()> { + ) -> Result<(Vec, Vec), ()> { + let reserved = self.reserved_outpoints(); let mut locked_wallet = self.inner.lock().unwrap(); debug_assert!(matches!( locked_wallet.public_descriptor(KeychainKind::External), @@ -626,29 +668,114 @@ impl Wallet { } tx_builder.fee_rate(fee_rate); - if confirmed_only { - tx_builder.exclude_unconfirmed(); - } + tx_builder.unspendable(reserved); + tx_builder.exclude_unconfirmed(); - tx_builder + let selection_tx = tx_builder .finish() .map_err(|e| { log_error!(self.logger, "Failed to select UTXOs: {}", e); })? - .unsigned_tx + .unsigned_tx; + + // The wallet coins selected to fund the splice (everything but the shared channel input). + let selected_outpoints: Vec = selection_tx .input .iter() - .filter(|txin| must_spend.iter().all(|input| input.outpoint != txin.previous_output)) - .filter_map(|txin| { - locked_wallet - .tx_details(txin.previous_output.txid) - .map(|tx_details| tx_details.tx.deref().clone()) - .map(|prevtx| FundingTxInput::new_p2wpkh(prevtx, txin.previous_output.vout)) + .map(|txin| txin.previous_output) + .filter(|outpoint| must_spend.iter().all(|input| input.outpoint != *outpoint)) + .collect(); + + let inputs = selected_outpoints + .iter() + .map(|outpoint| match locked_wallet.tx_details(outpoint.txid) { + Some(tx_details) => { + log_debug!( + self.logger, + "Selected UTXO {} (confirmed: {})", + outpoint, + tx_details.chain_position.is_confirmed() + ); + let prevtx = tx_details.tx.deref().clone(); + FundingTxInput::new_p2wpkh(prevtx, outpoint.vout) + }, + None => { + log_error!( + self.logger, + "Selected UTXO {} dropped: no wallet tx details (parent likely evicted)", + outpoint + ); + Err(()) + }, + }) + .collect::, ()>>()?; + + // Drop the wallet lock before taking the reservation lock: the two never overlap. + drop(locked_wallet); + + self.reserved_utxos.lock().unwrap().extend(selected_outpoints.iter().copied()); + + Ok((inputs, selected_outpoints)) + } + + // Drop a splice reservation, freeing the coins for reuse. Used when the splice fails before its + // funding tx is on chain. + pub(crate) fn release_reserved_utxos(&self, outpoints: &[OutPoint]) { + let mut reserved = self.reserved_utxos.lock().unwrap(); + for outpoint in outpoints { + reserved.remove(outpoint); + } + } + + // Commit a signed splice funding tx: record it as unconfirmed so the wallet graph marks the + // coins spent (closing the double-spend window until the next sync), then drop their reservation. + pub(crate) fn finalize_splice(&self, funding_tx: Transaction) -> Result<(), Error> { + let outpoints = self.reserved_inputs_of(&funding_tx); + if outpoints.is_empty() { + return Ok(()); + } + + let persist_result = { + let mut locked_wallet = self.inner.lock().unwrap(); + let mut locked_persister = self.persister.lock().unwrap(); + + let last_seen = + SystemTime::now().duration_since(UNIX_EPOCH).unwrap_or(Duration::ZERO).as_secs(); + locked_wallet.apply_unconfirmed_txs([(funding_tx, last_seen)]); + locked_wallet.persist(&mut locked_persister).map(|_| ()).map_err(|e| { + log_error!(self.logger, "Failed to persist wallet: {}", e); + Error::PersistenceFailed }) - .collect::, ()>>() + }; + + // Release even if persist failed: the in-memory graph already marks the coins spent, so + // double-spend protection holds and keeping the reservation would only strand the balance. + self.release_reserved_utxos(&outpoints); + + persist_result + } + + // Free the coins a failed splice reserved, recovered by intersecting the tx's inputs with the + // reservation set (the signing event hands back the tx, not the original selection). + pub(crate) fn abort_splice(&self, tx: &Transaction) { + let outpoints = self.reserved_inputs_of(tx); + self.release_reserved_utxos(&outpoints); + } + + // The tx inputs currently reserved for an in-flight splice. + fn reserved_inputs_of(&self, tx: &Transaction) -> Vec { + let reserved = self.reserved_utxos.lock().unwrap(); + tx.input + .iter() + .map(|txin| txin.previous_output) + .filter(|outpoint| reserved.contains(outpoint)) + .collect() } fn list_confirmed_utxos_inner(&self) -> Result, ()> { + // Snapshot and drop the reserved lock before taking the wallet lock: the two never overlap. + let reserved: HashSet = + self.reserved_utxos.lock().unwrap().iter().copied().collect(); let locked_wallet = self.inner.lock().unwrap(); let mut utxos = Vec::new(); let confirmed_txs: Vec = locked_wallet @@ -656,8 +783,10 @@ impl Wallet { .filter(|t| t.chain_position.is_confirmed()) .map(|t| t.tx_node.txid) .collect(); - let unspent_confirmed_utxos = - locked_wallet.list_unspent().filter(|u| confirmed_txs.contains(&u.outpoint.txid)); + // Skip coins reserved for an in-flight splice. + let unspent_confirmed_utxos = locked_wallet.list_unspent().filter(|u| { + confirmed_txs.contains(&u.outpoint.txid) && !reserved.contains(&u.outpoint) + }); for u in unspent_confirmed_utxos { let script_pubkey = u.txout.script_pubkey; diff --git a/tests/integration_tests_rust.rs b/tests/integration_tests_rust.rs index 173bc33e1..e39e60cd1 100644 --- a/tests/integration_tests_rust.rs +++ b/tests/integration_tests_rust.rs @@ -207,6 +207,201 @@ async fn zero_conf_channel_funding_tx_no_double_spend() { node_c.stop().unwrap(); } +// Regression test for the splice double-spend race. `select_confirmed_utxos` now +// records the coins a splice selects in the wallet's in-memory reserved set, which every +// selection path excludes, so a concurrent funding build cannot reselect them. Unlike a channel +// open, a splice signs its funding tx later (in a detached FundingTransactionReadyForSigning +// event), so the coins must stay live in the BDK graph rather than be marked spent at selection. +// Splices previously reserved nothing — their `cancel_tx` rollback passed a tx with an empty +// input list, a no-op — so a competing build could reselect and double-spend the same coin. +// +// This mirrors `zero_conf_channel_funding_tx_no_double_spend`: rather than race two threads, it +// gives node A a single confirmed coin, drives the splice, then without a sync asserts the coin +// is no longer spendable and a competing open is rejected with `InsufficientFunds`. The +// reservation is taken synchronously before `splice_in` returns, so the serialized second build +// sees it — the same outcome a truly concurrent build would get from the reservation lock. +#[tokio::test(flavor = "multi_thread", worker_threads = 1)] +async fn splice_funding_tx_no_double_spend() { + let (bitcoind, electrsd) = setup_bitcoind_and_electrsd(); + let chain_source = TestChainSource::Esplora(&electrsd); + + println!("== Node A =="); + let config_a = random_config(false); + let node_a = setup_node(&chain_source, config_a, None); + + println!("\n== Node B =="); + let config_b = random_config(false); + let node_b = setup_node(&chain_source, config_b, None); + + println!("\n== Node C =="); + let config_c = random_config(false); + let node_c = setup_node(&chain_source, config_c, None); + + // Give A and B a single confirmed UTXO each. + let premine_amount_sat = 1_000_000; + let addr_a = node_a.onchain_payment().new_address().unwrap(); + let addr_b = node_b.onchain_payment().new_address().unwrap(); + premine_and_distribute_funds( + &bitcoind.client, + &electrsd.client, + vec![addr_a, addr_b], + Amount::from_sat(premine_amount_sat), + ) + .await; + node_a.sync_wallets().unwrap(); + node_b.sync_wallets().unwrap(); + + // Open and confirm a channel A -> B. A's on-chain change confirms into a single UTXO that the + // later splice must spend. + let channel_amount_sat = 200_000; + println!("\nA -- open_channel -> B"); + node_a + .open_channel( + node_b.node_id(), + node_b.listening_addresses().unwrap().first().unwrap().clone(), + channel_amount_sat, + None, + None, + ) + .unwrap(); + let funding_txo = expect_channel_pending_event!(node_a, node_b.node_id()); + expect_channel_pending_event!(node_b, node_a.node_id()); + wait_for_tx(&electrsd.client, funding_txo.txid).await; + generate_blocks_and_wait(&bitcoind.client, &electrsd.client, 6).await; + node_a.sync_wallets().unwrap(); + node_b.sync_wallets().unwrap(); + let user_channel_id_a = expect_channel_ready_event!(node_a, node_b.node_id()); + expect_channel_ready_event!(node_b, node_a.node_id()); + + // A's only confirmed coin is now the change from the open; any funding build must spend it. + assert!(node_a.list_balances().spendable_onchain_balance_sats > channel_amount_sat); + + // Splice that confirmed coin into the A -> B channel. Selection reserves it before `splice_in` + // returns. + let splice_amount_sat = 600_000; + println!("\nA -- splice_in -> B"); + node_a.splice_in(&user_channel_id_a, node_b.node_id(), splice_amount_sat).unwrap(); + + // WITHOUT a sync: the coin stays live in the wallet graph (a splice signs later, so it must not + // be marked spent), but get_balances discounts the reserved value, so spendable drops below + // splice_amount. Pre-fix this still read the full confirmed balance. + assert!( + node_a.list_balances().spendable_onchain_balance_sats < splice_amount_sat, + "splice inputs were not reserved at selection time" + ); + + // A competing open to C would double-spend the reserved coin. With it reserved the open is + // rejected up front instead of silently building a conflicting funding tx. + println!("\nA -- open_channel -> C"); + assert_eq!( + Err(NodeError::InsufficientFunds), + node_a.open_channel( + node_c.node_id(), + node_c.listening_addresses().unwrap().first().unwrap().clone(), + splice_amount_sat, + None, + None, + ) + ); + + node_a.stop().unwrap(); + node_b.stop().unwrap(); + node_c.stop().unwrap(); +} + +// Companion to `splice_funding_tx_no_double_spend`: when a splice fails before reaching LDK, the +// coins it reserved at selection must be freed again. A second `splice_in` on a channel that +// already has a splice pending selects and reserves a coin, then `channel_manager.splice_channel` +// rejects it ("already a splice pending") and `splice_in` runs `release_reserved_utxos`. The freed +// coin must be spendable and reusable afterwards; if release were broken it would stay stranded. +#[tokio::test(flavor = "multi_thread", worker_threads = 1)] +async fn splice_failure_releases_reserved_utxos() { + let (bitcoind, electrsd) = setup_bitcoind_and_electrsd(); + let chain_source = TestChainSource::Esplora(&electrsd); + + println!("== Node A =="); + let config_a = random_config(false); + let node_a = setup_node(&chain_source, config_a, None); + + println!("\n== Node B =="); + let config_b = random_config(false); + let node_b = setup_node(&chain_source, config_b, None); + + println!("\n== Node C =="); + let config_c = random_config(false); + let node_c = setup_node(&chain_source, config_c, None); + + // Give A two confirmed UTXOs so a splice can reserve one and still leave a coin to select. + let premine_amount_sat = 1_000_000; + let addr_a1 = node_a.onchain_payment().new_address().unwrap(); + let addr_a2 = node_a.onchain_payment().new_address().unwrap(); + premine_and_distribute_funds( + &bitcoind.client, + &electrsd.client, + vec![addr_a1, addr_a2], + Amount::from_sat(premine_amount_sat), + ) + .await; + node_a.sync_wallets().unwrap(); + + // Open and confirm a channel A -> B to splice into. The funding tx spends one coin; A is left + // with that coin's change plus the untouched second coin, i.e. two confirmed coins. + let channel_amount_sat = 200_000; + println!("\nA -- open_channel -> B"); + node_a + .open_channel( + node_b.node_id(), + node_b.listening_addresses().unwrap().first().unwrap().clone(), + channel_amount_sat, + None, + None, + ) + .unwrap(); + let funding_txo = expect_channel_pending_event!(node_a, node_b.node_id()); + expect_channel_pending_event!(node_b, node_a.node_id()); + wait_for_tx(&electrsd.client, funding_txo.txid).await; + generate_blocks_and_wait(&bitcoind.client, &electrsd.client, 6).await; + node_a.sync_wallets().unwrap(); + node_b.sync_wallets().unwrap(); + let user_channel_id_a = expect_channel_ready_event!(node_a, node_b.node_id()); + expect_channel_ready_event!(node_b, node_a.node_id()); + + // First splice succeeds and stays pending; it reserves one coin, leaving the other free. + let first_splice_sat = 600_000; + println!("\nA -- splice_in -> B (succeeds, stays pending)"); + node_a.splice_in(&user_channel_id_a, node_b.node_id(), first_splice_sat).unwrap(); + + // Second splice on the same channel: selection reserves the remaining coin, then + // `splice_channel` rejects it because a splice is already pending, so `splice_in` errors and + // must release the just-reserved coin. + let second_splice_sat = 100_000; + println!("\nA -- splice_in -> B (rejected: splice already pending)"); + assert!( + node_a.splice_in(&user_channel_id_a, node_b.node_id(), second_splice_sat).is_err(), + "a second splice on a channel with one pending must be rejected" + ); + + // The coin the failed splice reserved is free again: a fresh channel open to C can spend it. + // If release were broken both coins would be reserved and this would fail with InsufficientFunds. + println!("\nA -- open_channel -> C (spends the released coin)"); + assert!( + node_a + .open_channel( + node_c.node_id(), + node_c.listening_addresses().unwrap().first().unwrap().clone(), + 500_000, + None, + None, + ) + .is_ok(), + "the coin reserved by the failed splice was not released" + ); + + node_a.stop().unwrap(); + node_b.stop().unwrap(); + node_c.stop().unwrap(); +} + #[tokio::test(flavor = "multi_thread", worker_threads = 1)] async fn channel_open_fails_when_funds_insufficient() { let (bitcoind, electrsd) = setup_bitcoind_and_electrsd();