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 495e2b5e9..aed4615bc 100644 --- a/src/liquidity.rs +++ b/src/liquidity.rs @@ -2066,9 +2066,9 @@ where let fee_rate = self.fee_estimator.estimate_fee_rate(ConfirmationTarget::ChannelFunding); - let inputs = self + let (inputs, reserved_outpoints) = 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(), })?; @@ -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();