From 092a8864177b0320de948ef905f4a82137455823 Mon Sep 17 00:00:00 2001 From: Jeffrey Czyz Date: Wed, 14 Oct 2026 15:47:31 -0500 Subject: [PATCH 1/2] Share the splice RBF bitcoind setup across tests The RBF splice test builds its own bitcoind with a lower incrementalrelayfee so that LDK's minimum RBF bump of 25 sat/kwu satisfies BIP125's absolute fee increase requirement. Upcoming tests that fee-bump a splice_in_with_all splice need the same setup, so make it a helper next to setup_bitcoind_and_electrsd. Co-Authored-By: Claude Fable 5.1 --- tests/common/mod.rs | 12 ++++++++++++ tests/integration_tests_rust.rs | 27 ++++++--------------------- 2 files changed, 18 insertions(+), 21 deletions(-) diff --git a/tests/common/mod.rs b/tests/common/mod.rs index 93bab690f..325754050 100644 --- a/tests/common/mod.rs +++ b/tests/common/mod.rs @@ -345,11 +345,23 @@ macro_rules! expect_payment_successful_event { pub(crate) use expect_payment_successful_event; pub(crate) fn setup_bitcoind_and_electrsd() -> (BitcoinD, ElectrsD) { + setup_bitcoind_and_electrsd_with_args(&[]) +} + +/// Like [`setup_bitcoind_and_electrsd`], but with a lower `incrementalrelayfee` so that LDK's +/// minimum RBF feerate bump of 25 sat/kwu (0.1 sat/vB) satisfies BIP125's absolute fee increase +/// requirement when replacing a splice transaction. +pub(crate) fn setup_bitcoind_and_electrsd_for_splice_rbf() -> (BitcoinD, ElectrsD) { + setup_bitcoind_and_electrsd_with_args(&["-incrementalrelayfee=0.00000100"]) +} + +fn setup_bitcoind_and_electrsd_with_args(bitcoind_args: &[&'static str]) -> (BitcoinD, ElectrsD) { let bitcoind_exe = env::var("BITCOIND_EXE") .expect("set BITCOIND_EXE or source ./scripts/download_bitcoind_electrs.sh before testing"); let mut bitcoind_conf = corepc_node::Conf::default(); bitcoind_conf.network = "regtest"; bitcoind_conf.args.push("-rest"); + bitcoind_conf.args.extend_from_slice(bitcoind_args); let bitcoind = BitcoinD::with_conf(bitcoind_exe, &bitcoind_conf).unwrap(); let electrs_exe = env::var("ELECTRS_EXE") diff --git a/tests/integration_tests_rust.rs b/tests/integration_tests_rust.rs index 2c9480349..1d4734836 100644 --- a/tests/integration_tests_rust.rs +++ b/tests/integration_tests_rust.rs @@ -31,12 +31,12 @@ use common::{ generate_blocks_and_wait, generate_listening_addresses, invalidate_blocks, open_channel, open_channel_no_wait, open_channel_push_amt, open_channel_with_all, premine_and_distribute_funds, premine_blocks, prepare_rbf, random_chain_source, random_config, - setup_bitcoind_and_electrsd, setup_builder, setup_node, setup_node_with_store, setup_two_nodes, - splice_in_with_all, wait_for_block, wait_for_channel_ready_to_send, wait_for_outpoint_spend, - wait_for_tx, InMemoryStore, NodePaymentExt, TestChainSource, TestConfig, TestNode, - TestStoreType, TestSyncStore, + setup_bitcoind_and_electrsd, setup_bitcoind_and_electrsd_for_splice_rbf, setup_builder, + setup_node, setup_node_with_store, setup_two_nodes, splice_in_with_all, wait_for_block, + wait_for_channel_ready_to_send, wait_for_outpoint_spend, wait_for_tx, InMemoryStore, + NodePaymentExt, TestChainSource, TestConfig, TestNode, TestStoreType, TestSyncStore, }; -use electrsd::corepc_node::{self, Node as BitcoinD}; +use electrsd::corepc_node::Node as BitcoinD; use electrsd::electrum_client::ElectrumApi; use electrsd::ElectrsD; use ldk_node::config::{ @@ -3120,22 +3120,7 @@ async fn rbf_splice_channel_original_candidate_confirms() { } async fn run_rbf_splice_channel_test(confirm_original: bool) { - // Use a custom bitcoind config with a lower incrementalrelayfee so that the +25 sat/kwu - // (0.1 sat/vB) RBF feerate bump satisfies BIP125's absolute fee increase requirement. - let bitcoind_exe = std::env::var("BITCOIND_EXE") - .expect("set BITCOIND_EXE or source ./scripts/download_bitcoind_electrs.sh before testing"); - let mut bitcoind_conf = corepc_node::Conf::default(); - bitcoind_conf.network = "regtest"; - bitcoind_conf.args.push("-rest"); - bitcoind_conf.args.push("-incrementalrelayfee=0.00000100"); - let bitcoind = BitcoinD::with_conf(bitcoind_exe, &bitcoind_conf).unwrap(); - - let electrs_exe = std::env::var("ELECTRS_EXE") - .expect("set ELECTRS_EXE or source ./scripts/download_bitcoind_electrs.sh before testing"); - let mut electrsd_conf = electrsd::Conf::default(); - electrsd_conf.http_enabled = true; - electrsd_conf.network = "regtest"; - let electrsd = ElectrsD::with_conf(electrs_exe, &bitcoind, &electrsd_conf).unwrap(); + let (bitcoind, electrsd) = setup_bitcoind_and_electrsd_for_splice_rbf(); let chain_source = random_chain_source(&bitcoind, &electrsd); let (node_a, node_b) = setup_two_nodes(&chain_source, false, false); From fbaa8d4ffe763fc722651df9c0b86dbaa720657b Mon Sep 17 00:00:00 2001 From: Jeffrey Czyz Date: Wed, 14 Oct 2026 15:47:34 -0500 Subject: [PATCH 2/2] Build splice_in_with_all from manually selected inputs splice_in_with_all estimated the maximum amount with a wallet drain and then ran coin selection for that exact amount. The resulting contribution had at most the anchor reserve as a change output, so bump_channel_funding_fee had nothing else to pay a higher fee from. With anchor channels enabled, the bump shrank the change output and so spent the reserve on the splice fee. Without a reserve there was no change output at all; LDK keeps the value added fixed for coin-selected contributions, so the bump fell back to coin selection, which failed for want of further inputs. The drain estimate also ran at the funding feerate estimate while the contribution could be built at a pending splice's higher RBF minimum, so coin selection for that amount could eat into the reserve or fail. Instead, hand LDK the wallet's confirmed UTXOs as manually selected inputs. They are consumed in full with no change output, the amount added to the channel is whatever remains after fees at the template's feerate, and a later fee bump is paid by adding a little less to the channel. When an anchor reserve is required, it is kept as an explicit output back to one of the wallet's own addresses, which stays fixed across bumps. That address is marked used as soon as it is handed out, since the splice transaction only reaches the wallet once negotiation completes and until then BDK would offer the same address as change elsewhere. The mark is dropped again if the contribution cannot be built or LDK rejects it. Insufficient-funds checks stay in front of LDK, which would otherwise build a near-empty contribution. The drain-based splice estimate is removed along with the shared-input handling in the drain helpers that only it used. Fixes #1168. Co-Authored-By: Claude Fable 5.1 --- CHANGELOG.md | 5 + src/lib.rs | 204 +++++++++++++++++++++----------- src/wallet/mod.rs | 190 ++++++++++++++++++++++------- tests/integration_tests_rust.rs | 116 ++++++++++++++++++ 4 files changed, 402 insertions(+), 113 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4d7c631bb..b937501ad 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -70,6 +70,11 @@ transaction. ## Bug Fixes and Improvements +- `Node::bump_channel_funding_fee` can now fee-bump a splice made with + `Node::splice_in_with_all` without spending the anchor reserve or failing for lack of a change + output. The splice now spends the wallet's confirmed UTXOs in full, so a bump is paid by adding + less to the channel, and any required anchor reserve is kept as a fixed output back to the + wallet. (#1168) - Building a fresh node against a Bitcoin Core RPC or REST chain source that fails to return the current chain tip now aborts with a new `BuildError::ChainTipFetchFailed` variant instead of silently pinning the wallet birthday to genesis, which would have forced a full-history rescan diff --git a/src/lib.rs b/src/lib.rs index 83bcc6afa..c702d445a 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -153,16 +153,17 @@ use io::utils::update_and_persist_node_metrics; pub use lightning; use lightning::chain::BlockLocator; use lightning::impl_writeable_tlv_based; -use lightning::ln::chan_utils::FUNDING_TRANSACTION_WITNESS_WEIGHT; use lightning::ln::channel_state::ChannelDetails as LdkChannelDetails; pub use lightning::ln::channel_state::ChannelShutdownState; use lightning::ln::channelmanager::PaymentId; +use lightning::ln::funding::{FundingContribution, FundingTemplate}; use lightning::ln::msgs::{BaseMessageHandler, SocketAddress}; use lightning::ln::peer_handler::CustomMessageHandler; use lightning::routing::gossip::NodeAlias; + use lightning::sign::EntropySource; use lightning::util::persist::KVStore; -use lightning::util::wallet_utils::{Input, Wallet as LdkWallet}; +use lightning::util::wallet_utils::{ConfirmedUtxo, Wallet as LdkWallet}; use lightning_background_processor::process_events_async; pub use lightning_invoice; pub use lightning_liquidity; @@ -203,7 +204,7 @@ use crate::config::{LIQUIDITY_DISCOVERY_RETRY_INITIAL_DELAY, LIQUIDITY_DISCOVERY use crate::ffi::{maybe_deref, maybe_map_fee_rate_opt, maybe_wrap, FfiFeeRate}; use crate::liquidity::Liquidity; use crate::scoring::setup_background_pathfinding_scores_sync; -use crate::wallet::FundingAmount; +use crate::wallet::{FundingAmount, DUST_LIMIT_SATS}; #[cfg(not(feature = "uniffi"))] type NodeFeatures = LdkNodeFeatures; @@ -1813,61 +1814,9 @@ impl Node { }); let max_feerate = max_funding_feerate(min_feerate); - let splice_amount_sats = match splice_amount_sats { - FundingAmount::Exact { amount_sats } => amount_sats, - FundingAmount::Max => { - let cur_anchor_reserve_sats = - total_anchor_channels_reserve_sats(&self.channel_manager, &self.config); - - const EMPTY_SCRIPT_SIG_WEIGHT: u64 = - 1 /* empty script_sig */ * bitcoin::constants::WITNESS_SCALE_FACTOR as u64; - - let funding_txo = channel_details.funding_txo.ok_or_else(|| { - log_error!(self.logger, "Failed to splice channel: channel not yet ready",); - Error::ChannelSplicingFailed - })?; - - let funding_output = channel_details.get_funding_output().ok_or_else(|| { - log_error!(self.logger, "Failed to splice channel: channel not yet ready"); - Error::ChannelSplicingFailed - })?; - - let shared_input = Input { - outpoint: funding_txo.into_bitcoin_outpoint(), - previous_utxo: funding_output.clone(), - satisfaction_weight: EMPTY_SCRIPT_SIG_WEIGHT - + FUNDING_TRANSACTION_WITNESS_WEIGHT, - }; - - let amount = self - .wallet - .get_max_splice_in_amount( - shared_input, - funding_output.script_pubkey.clone(), - cur_anchor_reserve_sats, - min_feerate, - ) - .map_err(|e| { - log_error!( - self.logger, - "Failed to determine max splice-in amount: {e:?}" - ); - e - })?; - - log_info!( - self.logger, - "Splicing in with all balance: {}sats (fee rate: {} sat/kw, anchor reserve: {}sats)", - amount, - min_feerate.to_sat_per_kwu(), - cur_anchor_reserve_sats, - ); - - amount - }, - }; - - self.check_sufficient_onchain_funds(splice_amount_sats, &counterparty_node_id, false)?; + if let FundingAmount::Exact { amount_sats } = splice_amount_sats { + self.check_sufficient_onchain_funds(amount_sats, &counterparty_node_id, false)?; + } let funding_template = self .channel_manager @@ -1895,18 +1844,91 @@ impl Node { _ => min_feerate, }; - let contribution = self - .runtime - .block_on(funding_template.splice_in( - Amount::from_sat(splice_amount_sats), - feerate, - max_feerate, - Arc::clone(&self.wallet), - )) - .map_err(|e| { - log_error!(self.logger, "Failed to splice channel: {}", e); - Error::ChannelSplicingFailed - })?; + let contribution = match splice_amount_sats { + FundingAmount::Exact { amount_sats } => self + .runtime + .block_on(funding_template.splice_in( + Amount::from_sat(amount_sats), + feerate, + max_feerate, + Arc::clone(&self.wallet), + )) + .map_err(|e| { + log_error!(self.logger, "Failed to splice channel: {}", e); + Error::ChannelSplicingFailed + })?, + FundingAmount::Max => { + let cur_anchor_reserve_sats = + total_anchor_channels_reserve_sats(&self.channel_manager, &self.config); + + // Splicing in all funds spends every confirmed UTXO in full, so the inputs are + // chosen here rather than by coin selection against an amount. The fee is only + // known once the contribution is built, so first check that the inputs can at + // least retain the anchor reserve and still add a non-dust amount, rather than + // asking LDK to build a contribution from nothing. + let inputs = self.wallet.list_confirmed_funding_inputs().map_err(|e| { + log_error!(self.logger, "Failed to list confirmed UTXOs: {e:?}"); + Error::ChannelSplicingFailed + })?; + let input_total_sats: u64 = + inputs.iter().map(|input| input.output().value.to_sat()).sum(); + if input_total_sats.saturating_sub(cur_anchor_reserve_sats) < DUST_LIMIT_SATS { + log_error!( + self.logger, + "Unable to splice-in due to insufficient funds. Confirmed: {}sats, anchor reserve: {}sats", + input_total_sats, + cur_anchor_reserve_sats, + ); + return Err(Error::InsufficientFunds); + } + + // Manually selected inputs are consumed in full with no change output: the + // amount added to the channel is what remains after fees, and a later fee bump + // lowers that amount instead of needing further inputs. The anchor reserve is + // retained as an explicit output to one of our own addresses, which stays + // fixed across fee bumps. The address is reserved so that other change-address + // consumers skip it until the splice transaction reaches the wallet. + let reserve_output = if cur_anchor_reserve_sats > DUST_LIMIT_SATS { + let script_pubkey = self + .runtime + .block_on(self.wallet.reserve_internal_address())? + .script_pubkey(); + Some(bitcoin::TxOut { + value: Amount::from_sat(cur_anchor_reserve_sats), + script_pubkey, + }) + } else { + None + }; + + let built = self.build_splice_in_with_all_contribution( + funding_template, + inputs, + reserve_output.clone(), + feerate, + max_feerate, + ); + match built { + Ok(contribution) => contribution, + Err(e) => { + // LDK never took the contribution, so no `DiscardFunding` event will + // free the reserved address; release it here instead. + if let Some(reserve_output) = reserve_output { + if let Err(e) = self + .runtime + .block_on(self.wallet.release_outputs(vec![reserve_output])) + { + log_error!( + self.logger, + "Failed to release the anchor reserve address: {e:?}" + ); + } + } + return Err(e); + }, + } + }, + }; self.channel_manager .funding_contributed( @@ -1930,6 +1952,48 @@ impl Node { } } + /// Builds the contribution for splicing in all on-chain funds: `inputs` are consumed in full + /// and `reserve_output`, if any, keeps the anchor reserve on-chain. Fails with + /// [`Error::InsufficientFunds`] when fees would leave less than dust to add to the channel. + fn build_splice_in_with_all_contribution( + &self, funding_template: FundingTemplate, inputs: Vec, + reserve_output: Option, feerate: bitcoin::FeeRate, + max_feerate: bitcoin::FeeRate, + ) -> Result { + let cur_anchor_reserve_sats = + reserve_output.as_ref().map_or(0, |output| output.value.to_sat()); + + let contribution = funding_template + .with_prior_contribution(feerate, max_feerate) + .add_inputs(inputs) + .and_then(|builder| builder.add_outputs(reserve_output.into_iter().collect()).build()) + .map_err(|e| { + log_error!(self.logger, "Failed to splice channel: {}", e); + Error::ChannelSplicingFailed + })?; + + let value_added_sats = contribution.value_added().to_sat(); + if value_added_sats < DUST_LIMIT_SATS { + log_error!( + self.logger, + "Unable to splice-in: available funds would be consumed entirely by fees. Remaining: {}sats, fee: {}sats", + value_added_sats, + contribution.estimated_fee().to_sat(), + ); + return Err(Error::InsufficientFunds); + } + + log_info!( + self.logger, + "Splicing in with all balance: {}sats (fee rate: {} sat/kw, anchor reserve: {}sats)", + value_added_sats, + feerate.to_sat_per_kwu(), + cur_anchor_reserve_sats, + ); + + Ok(contribution) + } + /// Add funds to an existing channel from a transaction output you control. /// /// This provides for increasing a channel's outbound liquidity without re-balancing or closing diff --git a/src/wallet/mod.rs b/src/wallet/mod.rs index b883ea918..37ec61d03 100644 --- a/src/wallet/mod.rs +++ b/src/wallet/mod.rs @@ -19,7 +19,7 @@ use bdk_wallet::error::{BuildFeeBumpError, CreateTxError}; use bdk_wallet::SignOptions; use bdk_wallet::{Balance, KeychainKind, LocalOutput, PersistedWallet, Update, WalletEvent}; use bitcoin::address::NetworkUnchecked; -use bitcoin::blockdata::constants::WITNESS_SCALE_FACTOR; +use bitcoin::blockdata::constants::{COINBASE_MATURITY, WITNESS_SCALE_FACTOR}; use bitcoin::blockdata::locktime::absolute::LockTime; use bitcoin::hashes::Hash; use bitcoin::key::XOnlyPublicKey; @@ -96,7 +96,7 @@ pub(crate) mod persist; pub(crate) mod provenance; pub(crate) mod ser; -const DUST_LIMIT_SATS: u64 = 546; +pub(crate) const DUST_LIMIT_SATS: u64 = 546; /// The number of external addresses kept revealed, persisted, and ready for handout via /// [`Wallet::pop_pooled_address`] and [`Wallet::get_new_address`]. @@ -1934,10 +1934,29 @@ impl Wallet { } pub(crate) async fn get_new_internal_address(&self) -> Result { + self.next_unused_internal_address(false).await + } + + /// Like [`Self::get_new_internal_address`], but also marks the address used so that other + /// change-address consumers skip it while the transaction paying it is still being built or + /// negotiated. Otherwise the address only counts as used once that transaction reaches the + /// wallet. The mark is held in memory only and is derived again from the wallet's + /// transactions after a restart; [`Self::release_outputs`] clears it if the transaction is + /// abandoned. + pub(crate) async fn reserve_internal_address(&self) -> Result { + self.next_unused_internal_address(true).await + } + + async fn next_unused_internal_address( + &self, mark_used: bool, + ) -> Result { let mut locked_persister = self.persister.lock().await; let (address_info, change_set) = { let mut locked_wallet = self.inner.lock().expect("lock"); let address_info = locked_wallet.next_unused_address(KeychainKind::Internal); + if mark_used { + locked_wallet.mark_used(KeychainKind::Internal, address_info.index); + } (address_info, locked_wallet.take_staged().unwrap_or_default()) }; locked_persister.persist_changeset(change_set).await.map_err(|e| { @@ -1948,10 +1967,16 @@ impl Wallet { } pub(crate) async fn cancel_tx(&self, tx: Transaction) -> Result<(), Error> { + self.release_outputs(tx.output).await + } + + /// Frees the wallet addresses paid by `outputs` for reuse, for a transaction that will not be + /// made after all. + pub(crate) async fn release_outputs(&self, outputs: Vec) -> Result<(), Error> { let mut locked_persister = self.persister.lock().await; let change_set = { let mut locked_wallet = self.inner.lock().expect("lock"); - Self::cancel_tx_inner(&mut locked_wallet, tx); + Self::release_outputs_inner(&mut locked_wallet, outputs); locked_wallet.take_staged().unwrap_or_default() }; locked_persister.persist_changeset(change_set).await.map_err(|e| { @@ -1962,10 +1987,10 @@ impl Wallet { Ok(()) } - fn cancel_tx_inner( - locked_wallet: &mut PersistedWallet, tx: Transaction, + fn release_outputs_inner( + locked_wallet: &mut PersistedWallet, outputs: Vec, ) { - for txout in tx.output { + for txout in outputs { if let Some((keychain, index)) = locked_wallet.derivation_of_spk(txout.script_pubkey) { // This mirrors the removed BDK helper: it only frees superficial usage marks. locked_wallet.unmark_used(keychain, index); @@ -2011,7 +2036,6 @@ impl Wallet { fn build_drain_psbt( &self, locked_wallet: &mut PersistedWallet, drain_script: ScriptBuf, cur_anchor_reserve_sats: u64, fee_rate: FeeRate, - shared_input: Option<&Input>, ) -> Result { let anchor_address = if cur_anchor_reserve_sats > DUST_LIMIT_SATS { Some(locked_wallet.peek_address(KeychainKind::Internal, 0)) @@ -2029,19 +2053,6 @@ impl Wallet { ); } - if let Some(input) = shared_input { - let psbt_input = psbt::Input { - witness_utxo: Some(input.previous_utxo.clone()), - ..Default::default() - }; - let weight = ldk_to_bdk_satisfaction_weight(input.satisfaction_weight); - tx_builder.only_witness_utxo().exclude_unconfirmed(); - tx_builder.add_foreign_utxo(input.outpoint, psbt_input, weight).map_err(|e| { - log_error!(self.logger, "Failed to add shared input for fee estimation: {e}"); - Error::ChannelSplicingFailed - })?; - } - let psbt = tx_builder.finish().map_err(|err| { log_error!(self.logger, "Failed to create temporary drain transaction: {err}"); err @@ -2054,13 +2065,12 @@ impl Wallet { /// the drain output, along with the PSBT for further inspection. /// /// The returned PSBT needs no cleanup. Draining to a fixed script means BDK neither reserves a - /// change address nor locks inputs for it. Cancelling it via `cancel_tx_inner` would instead + /// change address nor locks inputs for it. Cancelling it via `release_outputs_inner` would instead /// clear the usage mark on the anchor reserve placeholder address, which a pending transaction /// may have reserved as its change address. fn get_max_drain_amount( &self, locked_wallet: &mut PersistedWallet, drain_script: ScriptBuf, cur_anchor_reserve_sats: u64, fee_rate: FeeRate, - shared_input: Option<&Input>, ) -> Result<(u64, Psbt), Error> { let balance = locked_wallet.balance(); let spendable_amount_sats = @@ -2079,7 +2089,6 @@ impl Wallet { drain_script.clone(), cur_anchor_reserve_sats, fee_rate, - shared_input, )?; let drain_output_value = tmp_psbt @@ -2093,9 +2102,7 @@ impl Wallet { Error::InsufficientFunds })?; - let shared_input_value = shared_input.map(|i| i.previous_utxo.value.to_sat()).unwrap_or(0); - - let max_amount = drain_output_value.to_sat().saturating_sub(shared_input_value); + let max_amount = drain_output_value.to_sat(); if max_amount < DUST_LIMIT_SATS { log_error!( @@ -2125,19 +2132,16 @@ impl Wallet { dummy_p2wsh_script, cur_anchor_reserve_sats, fee_rate, - None, )?; Ok(max_amount) } - /// Returns the maximum amount available for splicing into an existing channel, accounting for - /// on-chain fees and anchor reserves, along with the wallet UTXOs to use as inputs. - pub(crate) fn get_max_splice_in_amount( - &self, shared_input: Input, shared_output_script: ScriptBuf, cur_anchor_reserve_sats: u64, - fee_rate: FeeRate, - ) -> Result { - let mut locked_wallet = self.inner.lock().expect("lock"); + /// Returns the wallet's confirmed UTXOs as inputs for a funding contribution that spends them + /// in full, e.g. when splicing all on-chain funds into a channel. Like coin selection, this + /// excludes unconfirmed outputs and immature coinbase outputs. + pub(crate) fn list_confirmed_funding_inputs(&self) -> Result, Error> { + let locked_wallet = self.inner.lock().expect("lock"); debug_assert!(matches!( locked_wallet.public_descriptor(KeychainKind::External), @@ -2148,15 +2152,44 @@ impl Wallet { ExtendedDescriptor::Wpkh(_) )); - let (splice_amount, _) = self.get_max_drain_amount( - &mut locked_wallet, - shared_output_script, - cur_anchor_reserve_sats, - fee_rate, - Some(&shared_input), - )?; + let tip_height = locked_wallet.latest_checkpoint().height(); + let mut inputs = Vec::new(); + for utxo in locked_wallet.list_unspent() { + let Some(confirmation_height) = utxo.chain_position.confirmation_height_upper_bound() + else { + continue; + }; + + let prevtx = locked_wallet + .tx_details(utxo.outpoint.txid) + .map(|tx_details| tx_details.tx.deref().clone()) + .ok_or_else(|| { + log_error!( + self.logger, + "Failed to get previous transaction for {}", + utxo.outpoint.txid + ); + Error::WalletOperationFailed + })?; + + if prevtx.is_coinbase() + && tip_height.saturating_sub(confirmation_height) + 1 < COINBASE_MATURITY + { + continue; + } + + let input = ConfirmedUtxo::new_p2wpkh(prevtx, utxo.outpoint.vout).map_err(|()| { + log_error!( + self.logger, + "Failed to use {} as a funding input: not a P2WPKH output", + utxo.outpoint + ); + Error::WalletOperationFailed + })?; + inputs.push(input); + } - Ok(splice_amount) + Ok(inputs) } pub(crate) fn parse_and_validate_address(&self, address: &Address) -> Result { @@ -2198,7 +2231,6 @@ impl Wallet { address.script_pubkey(), cur_anchor_reserve_sats, fee_rate, - None, )?; let estimated_tx_fee = @@ -10215,4 +10247,76 @@ mod tests { payment.kind, ); } + + #[tokio::test] + async fn reserved_internal_address_is_skipped_until_released() { + let store: Arc = Arc::new(DynStoreWrapper(InMemoryStore::new())); + let wallet = new_test_wallet(store, false).await; + + // Handing out an address does not by itself make it used, so it is handed out again. + let address = wallet.get_new_internal_address().await.unwrap(); + assert_eq!(wallet.get_new_internal_address().await.unwrap(), address); + + // Reserving it does, so other change-address consumers move on to the next one. + let reserved = wallet.reserve_internal_address().await.unwrap(); + assert_eq!(reserved, address); + let next = wallet.get_new_internal_address().await.unwrap(); + assert_ne!(next, reserved); + + // Releasing the output paying it frees it again. + let output = TxOut { value: Amount::ZERO, script_pubkey: reserved.script_pubkey() }; + wallet.release_outputs(vec![output]).await.unwrap(); + assert_eq!(wallet.get_new_internal_address().await.unwrap(), reserved); + } + + #[tokio::test] + async fn confirmed_funding_inputs_exclude_unconfirmed_outputs() { + let store: Arc = Arc::new(DynStoreWrapper(InMemoryStore::new())); + let wallet = new_test_wallet(store, false).await; + let (confirmed_tx, unconfirmed_tx, block_id) = { + let mut locked_wallet = wallet.inner.lock().unwrap(); + let mut pay_to_wallet = |value_sats| Transaction { + version: bitcoin::transaction::Version::TWO, + lock_time: LockTime::ZERO, + input: Vec::new(), + output: vec![TxOut { + value: Amount::from_sat(value_sats), + script_pubkey: locked_wallet + .reveal_next_address(KeychainKind::External) + .address + .script_pubkey(), + }], + }; + let confirmed_tx = pay_to_wallet(200_000); + let unconfirmed_tx = pay_to_wallet(100_000); + let block_id = BlockId { + height: locked_wallet.latest_checkpoint().height() + 1, + hash: bitcoin::BlockHash::from_byte_array([42; 32]), + }; + (confirmed_tx, unconfirmed_tx, block_id) + }; + let confirmed_txid = confirmed_tx.compute_txid(); + let mut tx_update = TxUpdate::default(); + tx_update.txs = vec![Arc::new(confirmed_tx), Arc::new(unconfirmed_tx.clone())]; + tx_update.anchors = + [(ConfirmationBlockTime { block_id, confirmation_time: 1 }, confirmed_txid)].into(); + tx_update.seen_ats = [(unconfirmed_tx.compute_txid(), 1)].into(); + let chain = CheckPoint::from_block_ids([ + wallet.inner.lock().unwrap().latest_checkpoint().block_id(), + block_id, + ]) + .unwrap(); + wallet + .apply_update(Update { tx_update, chain: Some(chain), ..Default::default() }) + .await + .unwrap(); + + // Both outputs count towards the balance, but only the confirmed one is usable as a + // funding input. + assert_eq!(wallet.inner.lock().unwrap().balance().total(), Amount::from_sat(300_000)); + let inputs = wallet.list_confirmed_funding_inputs().unwrap(); + assert_eq!(inputs.len(), 1); + assert_eq!(inputs[0].outpoint(), OutPoint { txid: confirmed_txid, vout: 0 }); + assert_eq!(inputs[0].output().value, Amount::from_sat(200_000)); + } } diff --git a/tests/integration_tests_rust.rs b/tests/integration_tests_rust.rs index 1d4734836..f9a15ed66 100644 --- a/tests/integration_tests_rust.rs +++ b/tests/integration_tests_rust.rs @@ -3312,6 +3312,122 @@ async fn run_rbf_splice_channel_test(confirm_original: bool) { node_b.stop().unwrap(); } +#[tokio::test(flavor = "multi_thread", worker_threads = 1)] +async fn splice_in_with_all_bump_fee_retains_anchor_reserve() { + run_splice_in_with_all_bump_fee_test(false).await; +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 1)] +async fn splice_in_with_all_bump_fee_without_anchor_reserve() { + run_splice_in_with_all_bump_fee_test(true).await; +} + +/// Splices all of node_b's on-chain funds into a channel and then fee-bumps the splice. The +/// contribution spends every confirmed UTXO in full, so the bump is paid by adding less to the +/// channel: with an anchor reserve, the reserve stays on-chain untouched; without one, the bump +/// still succeeds even though there is no change output to pay it from. +async fn run_splice_in_with_all_bump_fee_test(anchors_trusted_no_reserve: bool) { + let (bitcoind, electrsd) = setup_bitcoind_and_electrsd_for_splice_rbf(); + let chain_source = random_chain_source(&bitcoind, &electrsd); + let (node_a, node_b) = setup_two_nodes(&chain_source, false, anchors_trusted_no_reserve); + + let address_a = node_a.onchain_payment().new_address().unwrap(); + let address_b = node_b.onchain_payment().new_address().unwrap(); + let premine_amount_sat = 5_000_000; + premine_and_distribute_funds( + &bitcoind.client, + &electrsd.client, + vec![address_a, address_b], + Amount::from_sat(premine_amount_sat), + ) + .await; + + node_a.sync_wallets().unwrap(); + node_b.sync_wallets().unwrap(); + + // node_a funds the channel, so node_b's premine stays untouched for the splice-in. + let channel_amount_sat = 1_000_000; + open_channel(&node_a, &node_b, channel_amount_sat, false, &electrsd).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()); + let user_channel_id_b = expect_channel_ready_event!(node_b, node_a.node_id()); + + let balances_before = node_b.list_balances(); + let anchor_reserve_sats = balances_before.total_anchor_channels_reserve_sats; + if anchors_trusted_no_reserve { + assert_eq!(anchor_reserve_sats, 0); + } else { + assert!(anchor_reserve_sats > 0, "Anchor reserve should be non-zero after channel open"); + } + assert_eq!(balances_before.total_onchain_balance_sats, premine_amount_sat); + + node_b.splice_in_with_all(&user_channel_id_b, node_a.node_id(), None).unwrap(); + + let original_txo = expect_splice_negotiated_event!(node_b, node_a.node_id()); + wait_for_tx(&electrsd.client, original_txo.txid).await; + wait_for_classified_funding_payment(&node_b, original_txo.txid).await; + node_a.sync_wallets().unwrap(); + node_b.sync_wallets().unwrap(); + + // Everything but the anchor reserve and the fee was added to the channel. + let payment_id = PaymentId(original_txo.txid.to_byte_array()); + let original = node_b.payment(&payment_id).unwrap().expect("splice payment exists"); + assert_eq!(original.direction, PaymentDirection::Outbound); + let original_amount_msat = original.amount_msat.expect("splice payment has an amount"); + let original_fee_msat = original.fee_paid_msat.expect("splice payment has a fee"); + assert_eq!( + original_amount_msat + original_fee_msat, + (premine_amount_sat - anchor_reserve_sats) * 1000, + ); + + node_b.bump_channel_funding_fee(&user_channel_id_b, node_a.node_id(), None).unwrap(); + + let rbf_txo = expect_splice_negotiated_event!(node_b, node_a.node_id()); + assert_ne!(original_txo, rbf_txo, "RBF should produce a different funding txo"); + wait_for_tx(&electrsd.client, rbf_txo.txid).await; + wait_for_classified_funding_payment(&node_b, rbf_txo.txid).await; + node_a.sync_wallets().unwrap(); + node_b.sync_wallets().unwrap(); + + // The higher fee is paid by adding less to the channel, not out of the anchor reserve. + let bumped = node_b.payment(&payment_id).unwrap().expect("splice payment exists"); + let bumped_amount_msat = bumped.amount_msat.expect("splice payment has an amount"); + let bumped_fee_msat = bumped.fee_paid_msat.expect("splice payment has a fee"); + assert!(bumped_fee_msat > original_fee_msat, "RBF should pay a higher fee"); + assert_eq!(original_amount_msat - bumped_amount_msat, bumped_fee_msat - original_fee_msat); + + generate_blocks_and_wait(&bitcoind.client, &electrsd.client, 6).await; + + node_a.sync_wallets().unwrap(); + node_b.sync_wallets().unwrap(); + + expect_channel_ready_event!(node_a, node_b.node_id()); + expect_channel_ready_event!(node_b, node_a.node_id()); + + let channels = node_b.list_channels(); + assert_eq!(channels.len(), 1); + assert_eq!(channels[0].funding_txo, Some(rbf_txo)); + assert_eq!(channels[0].channel_value_sats, channel_amount_sat + bumped_amount_msat / 1000); + + let payment = node_b.payment(&payment_id).unwrap().expect("splice payment exists"); + assert_eq!(payment.status, PaymentStatus::Succeeded); + assert_eq!(payment.amount_msat, Some(bumped_amount_msat)); + assert_eq!(payment.fee_paid_msat, Some(bumped_fee_msat)); + + // The anchor reserve is all that remains on-chain, in full. + let balances = node_b.list_balances(); + assert_eq!(balances.total_onchain_balance_sats, anchor_reserve_sats); + assert_eq!(balances.spendable_onchain_balance_sats, 0); + + node_a.stop().unwrap(); + node_b.stop().unwrap(); +} + #[tokio::test(flavor = "multi_thread", worker_threads = 1)] async fn funding_payment_graduates_without_channel_ready() { let (bitcoind, electrsd) = setup_bitcoind_and_electrsd();