From d6a39252b3651d2a1a15853c2414967cbfd50149 Mon Sep 17 00:00:00 2001 From: Jeffrey Czyz Date: Wed, 14 Oct 2026 15:47:31 -0500 Subject: [PATCH 01/10] 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 2546b09db2..1ebd5b25bf 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 a48f38cc04..7c70a3eeef 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::{ @@ -3516,22 +3516,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 444d992833040e370f6847cb25b561331a78eac5 Mon Sep 17 00:00:00 2001 From: Jeffrey Czyz Date: Wed, 14 Oct 2026 15:47:34 -0500 Subject: [PATCH 02/10] 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 | 205 +++++++++++++++++++++----------- src/wallet/mod.rs | 190 ++++++++++++++++++++++------- tests/integration_tests_rust.rs | 116 ++++++++++++++++++ 4 files changed, 402 insertions(+), 114 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2261dbeadc..291d0c5ced 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -78,6 +78,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 da6bf38227..28052fcc08 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -153,20 +153,20 @@ 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, InboundHTLCDetails, InboundHTLCStateDetails, OutboundHTLCDetails, OutboundHTLCStateDetails, }; use lightning::ln::channelmanager::PaymentId; -use lightning::ln::funding::{FundingContributionError, FundingTemplate}; +use lightning::ln::funding::{FundingContribution, FundingContributionError, 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; @@ -208,7 +208,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; @@ -1818,61 +1818,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 @@ -1900,18 +1848,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( @@ -1935,6 +1956,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 b883ea918a..37ec61d03b 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 7c70a3eeef..45393d3799 100644 --- a/tests/integration_tests_rust.rs +++ b/tests/integration_tests_rust.rs @@ -3708,6 +3708,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(); From 72b02a3e363b9c8c568fa00e2b8ecf74f1aa2932 Mon Sep 17 00:00:00 2001 From: Jeffrey Czyz Date: Mon, 19 Oct 2026 15:20:39 -0500 Subject: [PATCH 03/10] f - Expect the fee bump to reuse the first round's inputs Fix splice_rounds_discarded_while_the_channel_is_listed_fail_at_close for the splice-in of manually selected inputs. The test funded a fresh coin so the bump had something to spend, since a no-reserve splice_in_with_all left no change; that was the bug being fixed. LDK adjusts a manually selected contribution in place, so the bump now keeps the first round's inputs and pays its fee by adding less to the channel. Expect those inputs, and a single reclaim, since the bump reserves no address of its own. Co-Authored-By: Claude Fable 5.1 --- tests/integration_tests_rust.rs | 38 +++++++++++++-------------------- 1 file changed, 15 insertions(+), 23 deletions(-) diff --git a/tests/integration_tests_rust.rs b/tests/integration_tests_rust.rs index 45393d3799..604a89e66e 100644 --- a/tests/integration_tests_rust.rs +++ b/tests/integration_tests_rust.rs @@ -4819,8 +4819,8 @@ async fn splice_rounds_discarded_while_the_channel_is_listed_fail_at_close() { let (bitcoind, electrsd) = setup_bitcoind_and_electrsd(); let chain_source = TestChainSource::Esplora(&electrsd); let node_b = setup_node(&chain_source, random_config()); - // Keeping no anchor reserve back from node B, node A's splice-in takes its whole balance and - // leaves no change for a fee bump to draw on. + // Keeping no anchor reserve back from node B, node A's splice-in takes its whole balance, so + // the fee bump has nothing to spend but the first round's own inputs. let mut config_a = random_config(); config_a.node_config.anchor_channels_config.trusted_peers_no_reserve.push(node_b.node_id()); let (node_a, store_a, logs_a) = @@ -4853,18 +4853,8 @@ async fn splice_rounds_discarded_while_the_channel_is_listed_fail_at_close() { let first_round = wait_for_transaction(&bitcoind, &electrsd, first_txo.txid).await; wait_for_classified_funding_payment(&node_a, first_txo.txid).await; assert_eq!(first_round.output.len(), 1, "the splice-in left change"); - // The wallet learns the round from the sync and gets a fresh coin for the bump, which then - // spends nothing of the first round's but the funding. - node_a.sync_wallets().unwrap(); - let coin_address = node_a.onchain_payment().new_address().unwrap(); - let coin_txid = distribute_funds_unconfirmed( - &bitcoind.client, - &electrsd.client, - vec![coin_address], - Amount::from_sat(3_000_000), - ) - .await; - mine_block_with(&bitcoind, &electrsd, &[raw_transaction_hex(&bitcoind, coin_txid)]).await; + // The wallet learns the round from the sync. The bump keeps the first round's inputs and pays + // its higher fee by adding less to the channel. node_a.sync_wallets().unwrap(); // The bump pays little more than the first round, which bitcoind may refuse to replace for it: @@ -4875,13 +4865,13 @@ async fn splice_rounds_discarded_while_the_channel_is_listed_fail_at_close() { assert_ne!(first_txo, bump_txo, "the bump produced the same funding"); wait_for_classified_funding_payment(&node_a, bump_txo.txid).await; let bump_round = wait_for_transaction(&bitcoind, &electrsd, bump_txo.txid).await; - let shared: Vec<_> = bump_round - .input - .iter() - .map(|input| input.previous_output) - .filter(|outpoint| spends(&first_round, *outpoint)) - .collect(); - assert_eq!(shared, vec![funding_txo], "the bump reused an input of the first round"); + let mut first_inputs: Vec<_> = + first_round.input.iter().map(|input| input.previous_output).collect(); + let mut bump_inputs: Vec<_> = + bump_round.input.iter().map(|input| input.previous_output).collect(); + first_inputs.sort(); + bump_inputs.sort(); + assert_eq!(bump_inputs, first_inputs, "the bump did not reuse the first round's inputs"); let payment_id = PaymentId(first_txo.txid.to_byte_array()); let payment = node_a.payment(&payment_id).unwrap().expect("the splice has a payment"); assert_eq!(payment.status, PaymentStatus::Pending); @@ -4929,10 +4919,12 @@ async fn splice_rounds_discarded_while_the_channel_is_listed_fail_at_close() { kept_before, "a discard while the channel was listed resolved the payment" ); + // The bump inherited the first round's inputs rather than reserving any of its own, so only + // the first round's discard carries a contribution to return. assert_eq!( logs_a.count(RECLAIMED_ADDRESSES), - 2, - "the monitor's events did not each return the round's contribution" + 1, + "the monitor's events did not return the first round's contribution exactly once" ); // The record points at the round the wallet last heard of: the sync that delivered the close // saw the mempool drop the first round, and moved the record from the bump to it. From 4dc83c1db4ec97be94110c2fb11ad6a4f2fc4530 Mon Sep 17 00:00:00 2001 From: Jeffrey Czyz Date: Mon, 19 Oct 2026 15:20:44 -0500 Subject: [PATCH 04/10] f - Keep a sub-dust anchor reserve at the dust limit A reserve of exactly 546 sats was dropped, since the reserve output was only kept when above the dust limit. Keep any positive reserve as an output, rounded up to the dust limit when below it, and check for insufficient funds against that amount. Cover it with splice_in_with_all_bump_fee_retains_dust_limit_anchor_reserve. Found in review by tnull and Codex. Co-Authored-By: Claude Fable 5.1 --- src/lib.rs | 15 +++++++++---- tests/integration_tests_rust.rs | 38 ++++++++++++++++++++++----------- 2 files changed, 36 insertions(+), 17 deletions(-) diff --git a/src/lib.rs b/src/lib.rs index 28052fcc08..646b7f3223 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1864,6 +1864,13 @@ impl Node { FundingAmount::Max => { let cur_anchor_reserve_sats = total_anchor_channels_reserve_sats(&self.channel_manager, &self.config); + // A reserve below the dust limit cannot be kept as an output of its own, so + // keep the dust limit instead. + let reserve_output_sats = if cur_anchor_reserve_sats == 0 { + 0 + } else { + cur_anchor_reserve_sats.max(DUST_LIMIT_SATS) + }; // 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 @@ -1876,12 +1883,12 @@ impl Node { })?; 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 { + if input_total_sats.saturating_sub(reserve_output_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, + reserve_output_sats, ); return Err(Error::InsufficientFunds); } @@ -1892,13 +1899,13 @@ impl Node { // 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 reserve_output = if reserve_output_sats > 0 { 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), + value: Amount::from_sat(reserve_output_sats), script_pubkey, }) } else { diff --git a/tests/integration_tests_rust.rs b/tests/integration_tests_rust.rs index 604a89e66e..88702eead6 100644 --- a/tests/integration_tests_rust.rs +++ b/tests/integration_tests_rust.rs @@ -40,8 +40,8 @@ use electrsd::corepc_node::Node as BitcoinD; use electrsd::electrum_client::ElectrumApi; use electrsd::ElectrsD; use ldk_node::config::{ - AsyncPaymentsRole, EsploraSyncConfig, ForwardedPaymentTrackingMode, ADDRESS_POOL_SIZE, - DEFAULT_FULL_SCAN_STOP_GAP, + AnchorChannelsConfig, AsyncPaymentsRole, EsploraSyncConfig, ForwardedPaymentTrackingMode, + ADDRESS_POOL_SIZE, DEFAULT_FULL_SCAN_STOP_GAP, }; use ldk_node::entropy::NodeEntropy; use ldk_node::liquidity::LSPS2ServiceConfig; @@ -3710,22 +3710,38 @@ async fn run_rbf_splice_channel_test(confirm_original: bool) { #[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; + let default_reserve_sats = AnchorChannelsConfig::default().per_channel_reserve_sats; + run_splice_in_with_all_bump_fee_test(Some(default_reserve_sats)).await; +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 1)] +async fn splice_in_with_all_bump_fee_retains_dust_limit_anchor_reserve() { + run_splice_in_with_all_bump_fee_test(Some(546)).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; + run_splice_in_with_all_bump_fee_test(None).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) { +/// channel: with an anchor reserve of `per_channel_reserve_sats`, 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(per_channel_reserve_sats: Option) { 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 (node_a, node_b) = match per_channel_reserve_sats { + Some(reserve_sats) => { + let node_a = setup_node(&chain_source, random_config()); + let mut config_b = random_config(); + config_b.node_config.anchor_channels_config.per_channel_reserve_sats = reserve_sats; + let node_b = setup_node(&chain_source, config_b); + (node_a, node_b) + }, + None => setup_two_nodes(&chain_source, false, true), + }; let address_a = node_a.onchain_payment().new_address().unwrap(); let address_b = node_b.onchain_payment().new_address().unwrap(); @@ -3755,11 +3771,7 @@ async fn run_splice_in_with_all_bump_fee_test(anchors_trusted_no_reserve: bool) 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!(anchor_reserve_sats, per_channel_reserve_sats.unwrap_or(0)); 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(); From 742d144228853da07229e57f6fbad2383d688706 Mon Sep 17 00:00:00 2001 From: Jeffrey Czyz Date: Mon, 19 Oct 2026 15:20:48 -0500 Subject: [PATCH 05/10] f - Report inputs that cannot cover the fee as InsufficientFunds Building the contribution fails with ManuallySelectedInputsInsufficient when the inputs cannot cover the reserve and the fee, which is a lack of funds rather than a splicing failure. Map it to Error::InsufficientFunds like the amount checks around it. Found in review by tnull and Codex. Co-Authored-By: Claude Fable 5.1 --- src/lib.rs | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/src/lib.rs b/src/lib.rs index 646b7f3223..18d23cc4f2 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1965,7 +1965,8 @@ 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. + /// [`Error::InsufficientFunds`] when the inputs cannot cover the reserve and the fee, or 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, @@ -1980,7 +1981,12 @@ impl Node { .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 + match e { + FundingContributionError::ManuallySelectedInputsInsufficient => { + Error::InsufficientFunds + }, + _ => Error::ChannelSplicingFailed, + } })?; let value_added_sats = contribution.value_added().to_sat(); From 7cf48cb91ff14587f02c4d5525f604153ef29896 Mon Sep 17 00:00:00 2001 From: Jeffrey Czyz Date: Mon, 19 Oct 2026 16:23:51 -0500 Subject: [PATCH 06/10] f - Document how splice_in_with_all's amount is determined Say what the amount spliced in is: what the confirmed funds leave after the anchor reserve and the fee, at the higher of min_fee_rate, the estimate, and a pending splice's RBF minimum. Note that a fee bump pays from the same funds and so reduces the amount, since that follows from the manually selected inputs and is not obvious to callers. Suggested in review by shubhamkmr04. Co-Authored-By: Claude Fable 5.1 --- src/lib.rs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/lib.rs b/src/lib.rs index 18d23cc4f2..0dc9fd75d6 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -2058,7 +2058,10 @@ impl Node { /// pending splice that requires a higher feerate to be replaced, or when the counterparty /// initiates a splice concurrently and our contribution is added to theirs. /// - /// The amount spliced in is determined at `min_fee_rate`, or at the estimate if unset. + /// The amount spliced in is what the confirmed on-chain funds leave after the anchor reserve + /// and the transaction fee at the feerate used: `min_fee_rate`, or the estimate if unset, or a + /// pending splice's RBF minimum if that is higher. A later [`Node::bump_channel_funding_fee`] + /// pays its higher fee from the same funds and so reduces the amount spliced in. /// /// # Experimental API /// From a76d258b1dc4533d80de18ca4a6ffc8420469ac6 Mon Sep 17 00:00:00 2001 From: Jeffrey Czyz Date: Mon, 19 Oct 2026 16:37:13 -0500 Subject: [PATCH 07/10] Rebuild a splice-in-with-all when its fee bump exceeds the maximum `splice_in_with_all` now hands LDK manually selected inputs, which LDK adjusts for a fee bump only up to the contribution's maximum feerate and never supplements by coin selection. A bump past that maximum, as when fees rise by more than half since the splice, failed even though the wallet may have received coins since. Instead, `bump_channel_funding_fee` now replaces such a contribution with one built the same way from all confirmed funds: the pending round's inputs plus any coins confirmed in the meantime. The replacement keeps the anchor reserve as its own output, paid to a fresh reserved address. Suggested by shubhamkmr04 in #1176. Co-Authored-By: Claude Fable 5.1 --- CHANGELOG.md | 3 +- src/lib.rs | 221 ++++++++++++++++++-------------- tests/integration_tests_rust.rs | 136 ++++++++++++++++++++ 3 files changed, 265 insertions(+), 95 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 291d0c5ced..2682bb7137 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -82,7 +82,8 @@ `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) + wallet. A bump past the splice's maximum feerate rebuilds it from all confirmed funds, + including coins confirmed since. (#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 0dc9fd75d6..501615c0d2 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -159,7 +159,9 @@ pub use lightning::ln::channel_state::{ OutboundHTLCStateDetails, }; use lightning::ln::channelmanager::PaymentId; -use lightning::ln::funding::{FundingContribution, FundingContributionError, FundingTemplate}; +use lightning::ln::funding::{ + FundingBuilder, FundingContribution, FundingContributionError, FundingTemplate, +}; use lightning::ln::msgs::{BaseMessageHandler, SocketAddress}; use lightning::ln::peer_handler::CustomMessageHandler; use lightning::routing::gossip::NodeAlias; @@ -1861,84 +1863,12 @@ impl Node { 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); - // A reserve below the dust limit cannot be kept as an output of its own, so - // keep the dust limit instead. - let reserve_output_sats = if cur_anchor_reserve_sats == 0 { - 0 - } else { - cur_anchor_reserve_sats.max(DUST_LIMIT_SATS) - }; - - // 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(reserve_output_sats) < DUST_LIMIT_SATS { - log_error!( - self.logger, - "Unable to splice-in due to insufficient funds. Confirmed: {}sats, anchor reserve: {}sats", - input_total_sats, - reserve_output_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 reserve_output_sats > 0 { - let script_pubkey = self - .runtime - .block_on(self.wallet.reserve_internal_address())? - .script_pubkey(); - Some(bitcoin::TxOut { - value: Amount::from_sat(reserve_output_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); - }, - } - }, + FundingAmount::Max => self.splice_in_with_all_contribution( + funding_template, + Vec::new(), + feerate, + max_feerate, + )?, }; self.channel_manager @@ -1963,20 +1893,102 @@ impl Node { } } + /// Builds a contribution splicing in all confirmed on-chain funds, consumed in full, with any + /// anchor reserve retained as an output to one of our own addresses. `pending_inputs` are the + /// inputs of a pending splice round to spend as well, which the wallet may already count as + /// spent by that round's transaction. The contribution replaces any prior one of the template. + fn splice_in_with_all_contribution( + &self, funding_template: FundingTemplate, pending_inputs: Vec, + feerate: bitcoin::FeeRate, max_feerate: bitcoin::FeeRate, + ) -> Result { + let cur_anchor_reserve_sats = + total_anchor_channels_reserve_sats(&self.channel_manager, &self.config); + // A reserve below the dust limit cannot be kept as an output of its own, so keep the dust + // limit instead. + let reserve_output_sats = if cur_anchor_reserve_sats == 0 { + 0 + } else { + cur_anchor_reserve_sats.max(DUST_LIMIT_SATS) + }; + + // 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 mut inputs = pending_inputs; + for input in self.wallet.list_confirmed_funding_inputs().map_err(|e| { + log_error!(self.logger, "Failed to list confirmed UTXOs: {e:?}"); + Error::ChannelSplicingFailed + })? { + if !inputs.iter().any(|pending| pending.outpoint() == input.outpoint()) { + inputs.push(input); + } + } + let input_total_sats: u64 = inputs.iter().map(|input| input.output().value.to_sat()).sum(); + if input_total_sats.saturating_sub(reserve_output_sats) < DUST_LIMIT_SATS { + log_error!( + self.logger, + "Unable to splice-in due to insufficient funds. Confirmed: {}sats, anchor reserve: {}sats", + input_total_sats, + reserve_output_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 reserve_output_sats > 0 { + let script_pubkey = + self.runtime.block_on(self.wallet.reserve_internal_address())?.script_pubkey(); + Some(bitcoin::TxOut { value: Amount::from_sat(reserve_output_sats), script_pubkey }) + } else { + None + }; + + let builder = funding_template.without_prior_contribution(feerate, max_feerate); + let built = self.build_splice_in_with_all_contribution( + builder, + inputs, + reserve_output.clone(), + feerate, + ); + match built { + Ok(contribution) => Ok(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:?}" + ); + } + } + Err(e) + }, + } + } + /// 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 the inputs cannot cover the reserve and the fee, or when - /// fees would leave less than dust to add to the channel. + /// fees would leave less than dust to add to the channel. `feerate` is the builder's, for + /// logging. fn build_splice_in_with_all_contribution( - &self, funding_template: FundingTemplate, inputs: Vec, + &self, builder: FundingBuilder, 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) + let contribution = builder .add_inputs(inputs) .and_then(|builder| builder.add_outputs(reserve_output.into_iter().collect()).build()) .map_err(|e| { @@ -2269,6 +2281,11 @@ impl Node { /// fees, leaving the channel balance unwithdrawn. This also applies to withdrawals created /// with [`Node::splice_out_max`]; the output is not reduced to cover the higher fee. /// + /// A splice of all on-chain funds (see [`Node::splice_in_with_all`]) has no amount to preserve: + /// the bump is paid by adding less to the channel, or, if the pending splice's maximum feerate + /// does not allow that, the splice is rebuilt from all confirmed funds, including any confirmed + /// since. + /// /// If `min_fee_rate` is set, it will be used as the minimum feerate for the replacement instead /// of a feerate estimate retrieved from the configured chain source. As with the estimate, we /// pay the feerate required to replace the pending splice if it is higher, but no more than 1.5 @@ -2315,17 +2332,33 @@ impl Node { return Err(Error::ChannelSplicingFailed); }; - let contribution = self - .runtime - .block_on(funding_template.rbf_prior_contribution( - Some(target_feerate), - max_feerate, - Arc::clone(&self.wallet), - )) - .map_err(|e| { + let rbf_template = funding_template.clone(); + let contribution = match self.runtime.block_on(rbf_template.rbf_prior_contribution( + Some(target_feerate), + max_feerate, + Arc::clone(&self.wallet), + )) { + Ok(contribution) => contribution, + // The pending splice spends manually selected inputs, as a splice-in of all funds + // does. LDK adjusts such a contribution only up to its maximum feerate and never + // supplements it by coin selection, so replace it with one built the same way + // from all confirmed funds: its inputs and any coins confirmed since. + Err(FundingContributionError::ManuallySelectedInputsInsufficient) => { + let pending_inputs = funding_template + .prior_contribution() + .map_or(Vec::new(), |contribution| contribution.inputs().to_vec()); + self.splice_in_with_all_contribution( + funding_template, + pending_inputs, + target_feerate, + max_feerate, + )? + }, + Err(e) => { log_error!(self.logger, "Failed to RBF channel: {}", e); - Error::ChannelSplicingFailed - })?; + return Err(Error::ChannelSplicingFailed); + }, + }; self.channel_manager .funding_contributed( diff --git a/tests/integration_tests_rust.rs b/tests/integration_tests_rust.rs index 88702eead6..241c790bf2 100644 --- a/tests/integration_tests_rust.rs +++ b/tests/integration_tests_rust.rs @@ -3836,6 +3836,142 @@ async fn run_splice_in_with_all_bump_fee_test(per_channel_reserve_sats: Option 0); + + // Well above the regtest estimate, so the bump below exceeds 1.5 times this feerate without + // the estimate having a say. + let fee_rate = FeeRate::from_sat_per_kwu(1_000); + node_b.splice_in_with_all(&user_channel_id_b, node_a.node_id(), Some(fee_rate.into())).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; + + // A new coin confirms while the splice transaction stays in the mempool. + let coin_amount_sat = 200_000; + let coin_address = node_b.onchain_payment().new_address().unwrap(); + let coin_txid = distribute_funds_unconfirmed( + &bitcoind.client, + &electrsd.client, + vec![coin_address], + Amount::from_sat(coin_amount_sat), + ) + .await; + let coin_tx_hex: String = bitcoind + .client + .call::("getrawtransaction", &[json!(coin_txid.to_string())]) + .unwrap() + .as_str() + .unwrap() + .to_string(); + mine_block_with(&bitcoind, &electrsd, &[coin_tx_hex]).await; + node_a.sync_wallets().unwrap(); + node_b.sync_wallets().unwrap(); + + let payment_id = PaymentId(original_txo.txid.to_byte_array()); + let original = node_b.payment(&payment_id).unwrap().expect("splice payment exists"); + 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, + ); + + // Above the first round's maximum feerate. + let bump_fee_rate = FeeRate::from_sat_per_kwu(2_000); + node_b + .bump_channel_funding_fee(&user_channel_id_b, node_a.node_id(), Some(bump_fee_rate.into())) + .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 bump spends the new coin on top of the first round's inputs, so it adds more to the + // channel despite the higher fee. + let rbf_tx = electrsd.client.transaction_get(&rbf_txo.txid).unwrap(); + assert!( + rbf_tx.input.iter().any(|input| input.previous_output.txid == coin_txid), + "the bump did not spend the new coin" + ); + 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!(bumped_amount_msat > original_amount_msat, "the new coin was not added"); + assert_eq!( + bumped_amount_msat + bumped_fee_msat, + (premine_amount_sat + coin_amount_sat - anchor_reserve_sats) * 1000, + ); + + 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); + assert_splice_pays_fee_rate(&node_b, &electrsd.client, rbf_txo.txid, bump_fee_rate); + + 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(); From d82f68fee33dea229048f33ef904c3f3f6bc2c9d Mon Sep 17 00:00:00 2001 From: Jeffrey Czyz Date: Mon, 26 Oct 2026 15:27:43 -0500 Subject: [PATCH 08/10] f - Restore two contributions in the discard-at-close test Once the splice-in spent manually selected inputs, its bump in splice_rounds_discarded_while_the_channel_is_listed_fail_at_close inherited the first round's inputs, so only one discard returned a contribution and the test's doc no longer matched what it checked. With the rebuild available, the test bumps past the first round's maximum feerate after a new coin confirms, which gives the bump an input of its own again, and checks that both discards return a contribution. Found in review by tnull. Co-Authored-By: Claude Fable 5.1 --- tests/integration_tests_rust.rs | 61 ++++++++++++++++++++------------- 1 file changed, 38 insertions(+), 23 deletions(-) diff --git a/tests/integration_tests_rust.rs b/tests/integration_tests_rust.rs index 241c790bf2..614cca160b 100644 --- a/tests/integration_tests_rust.rs +++ b/tests/integration_tests_rust.rs @@ -4956,19 +4956,20 @@ async fn superseded_zero_conf_splice_round_keeps_its_payment_at_close() { /// pass and on peer traffic, and the monitor's own events are handled right after the manager's. /// Each event then finds the channel listed and, both rounds having been broadcast, only returns /// the round's contribution, leaving the payment to the `ChannelClosed` that follows, which fails -/// it, no round of ours being watched anymore. Node A splices into its channel with node B and -/// bumps the round's fee from another coin, so the two rounds are contributions of their own; node -/// B closes while node A's event handler sits in a blocked event-queue write — for a channel node C -/// opened to it — until the close and its maturity are synced. Pinned to Esplora so the wallet -/// syncs only on demand. +/// it, no round of ours being watched anymore. Node A splices into its channel with node B and, +/// once another coin has confirmed, bumps the round's fee past its maximum feerate, so the bump is +/// rebuilt with that coin and the two rounds are contributions of their own; node B closes while +/// node A's event handler sits in a blocked event-queue write — for a channel node C opened to it — +/// until the close and its maturity are synced. Pinned to Esplora so the wallet syncs only on +/// demand. #[cfg(feature = "chain-esplora")] #[tokio::test(flavor = "multi_thread", worker_threads = 1)] async fn splice_rounds_discarded_while_the_channel_is_listed_fail_at_close() { let (bitcoind, electrsd) = setup_bitcoind_and_electrsd(); let chain_source = TestChainSource::Esplora(&electrsd); let node_b = setup_node(&chain_source, random_config()); - // Keeping no anchor reserve back from node B, node A's splice-in takes its whole balance, so - // the fee bump has nothing to spend but the first round's own inputs. + // Keeping no anchor reserve back from node B, node A's splice-in takes its whole balance into + // the funding, with no output of its own. let mut config_a = random_config(); config_a.node_config.anchor_channels_config.trusted_peers_no_reserve.push(node_b.node_id()); let (node_a, store_a, logs_a) = @@ -5001,25 +5002,39 @@ async fn splice_rounds_discarded_while_the_channel_is_listed_fail_at_close() { let first_round = wait_for_transaction(&bitcoind, &electrsd, first_txo.txid).await; wait_for_classified_funding_payment(&node_a, first_txo.txid).await; assert_eq!(first_round.output.len(), 1, "the splice-in left change"); - // The wallet learns the round from the sync. The bump keeps the first round's inputs and pays - // its higher fee by adding less to the channel. + // The wallet learns the round from the sync and gets a fresh coin. The bump asks for more than + // the round's maximum feerate allows, so it is rebuilt from the round's inputs and the coin. + node_a.sync_wallets().unwrap(); + let coin_address = node_a.onchain_payment().new_address().unwrap(); + let coin_txid = distribute_funds_unconfirmed( + &bitcoind.client, + &electrsd.client, + vec![coin_address], + Amount::from_sat(3_000_000), + ) + .await; + mine_block_with(&bitcoind, &electrsd, &[raw_transaction_hex(&bitcoind, coin_txid)]).await; node_a.sync_wallets().unwrap(); - // The bump pays little more than the first round, which bitcoind may refuse to replace for it: - // have it replaced, so the mempool serves the bump. - deprioritise_transaction(&bitcoind, first_txo.txid); - node_a.bump_channel_funding_fee(&user_channel_id_a, node_b.node_id(), None).unwrap(); + let bump_fee_rate = FeeRate::from_sat_per_kwu(1_000); + node_a + .bump_channel_funding_fee(&user_channel_id_a, node_b.node_id(), Some(bump_fee_rate.into())) + .unwrap(); let bump_txo = expect_splice_negotiated_event!(node_a, node_b.node_id()); assert_ne!(first_txo, bump_txo, "the bump produced the same funding"); wait_for_classified_funding_payment(&node_a, bump_txo.txid).await; let bump_round = wait_for_transaction(&bitcoind, &electrsd, bump_txo.txid).await; - let mut first_inputs: Vec<_> = + let first_inputs: Vec<_> = first_round.input.iter().map(|input| input.previous_output).collect(); - let mut bump_inputs: Vec<_> = - bump_round.input.iter().map(|input| input.previous_output).collect(); - first_inputs.sort(); - bump_inputs.sort(); - assert_eq!(bump_inputs, first_inputs, "the bump did not reuse the first round's inputs"); + let bump_inputs: Vec<_> = bump_round.input.iter().map(|input| input.previous_output).collect(); + assert!( + first_inputs.iter().all(|outpoint| bump_inputs.contains(outpoint)), + "the bump did not reuse the first round's inputs" + ); + let added_inputs: Vec<_> = + bump_inputs.iter().filter(|outpoint| !first_inputs.contains(outpoint)).collect(); + assert_eq!(added_inputs.len(), 1, "the bump added more than the coin"); + assert_eq!(added_inputs[0].txid, coin_txid, "the bump did not spend the coin"); let payment_id = PaymentId(first_txo.txid.to_byte_array()); let payment = node_a.payment(&payment_id).unwrap().expect("the splice has a payment"); assert_eq!(payment.status, PaymentStatus::Pending); @@ -5067,12 +5082,12 @@ async fn splice_rounds_discarded_while_the_channel_is_listed_fail_at_close() { kept_before, "a discard while the channel was listed resolved the payment" ); - // The bump inherited the first round's inputs rather than reserving any of its own, so only - // the first round's discard carries a contribution to return. + // The bump added the coin to the first round's inputs, so each round's discard carries a + // contribution of its own to return. assert_eq!( logs_a.count(RECLAIMED_ADDRESSES), - 1, - "the monitor's events did not return the first round's contribution exactly once" + 2, + "the monitor's events did not each return the round's contribution" ); // The record points at the round the wallet last heard of: the sync that delivered the close // saw the mempool drop the first round, and moved the record from the bump to it. From 283aa550eb3a9e93edee0b35a89ea050d2403fe4 Mon Sep 17 00:00:00 2001 From: Jeffrey Czyz Date: Mon, 26 Oct 2026 15:27:47 -0500 Subject: [PATCH 09/10] f - Rebuild a fee bump that would leave less than dust to add LDK adjusts a contribution of manually selected inputs to a higher feerate by adding less to the channel and only checks that the fee is covered, and submitting it only checks the splice-out maximum, so a bump could go through adding a few sats. Taking it through the rebuild applies the same dust check as the initial splice: without new coins to cover the fee, the bump fails with InsufficientFunds and the pending splice stands. Found in review by tnull. Co-Authored-By: Claude Fable 5.1 --- src/lib.rs | 41 +++++++---- tests/integration_tests_rust.rs | 116 ++++++++++++++++++++++++++++++++ 2 files changed, 145 insertions(+), 12 deletions(-) diff --git a/src/lib.rs b/src/lib.rs index 501615c0d2..2211695803 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -2283,8 +2283,8 @@ impl Node { /// /// A splice of all on-chain funds (see [`Node::splice_in_with_all`]) has no amount to preserve: /// the bump is paid by adding less to the channel, or, if the pending splice's maximum feerate - /// does not allow that, the splice is rebuilt from all confirmed funds, including any confirmed - /// since. + /// does not allow that or less than dust would be left to add, the splice is rebuilt from all + /// confirmed funds, including any confirmed since. /// /// If `min_fee_rate` is set, it will be used as the minimum feerate for the replacement instead /// of a feerate estimate retrieved from the configured chain source. As with the estimate, we @@ -2333,17 +2333,38 @@ impl Node { }; let rbf_template = funding_template.clone(); - let contribution = match self.runtime.block_on(rbf_template.rbf_prior_contribution( + let prior_value_added_sats = funding_template + .prior_contribution() + .map_or(0, |contribution| contribution.value_added().to_sat()); + let adjusted = match self.runtime.block_on(rbf_template.rbf_prior_contribution( Some(target_feerate), max_feerate, Arc::clone(&self.wallet), )) { - Ok(contribution) => contribution, + // LDK pays the bump of a contribution of manually selected inputs by adding less + // to the channel, with no floor on the amount. Rebuild one that would add less + // than dust below, where the amount is checked. + Ok(contribution) + if contribution.value_added().to_sat() < prior_value_added_sats + && contribution.value_added().to_sat() < DUST_LIMIT_SATS => + { + None + }, + Ok(contribution) => Some(contribution), + // LDK adjusts a contribution of manually selected inputs only up to its maximum + // feerate and never supplements it by coin selection. + Err(FundingContributionError::ManuallySelectedInputsInsufficient) => None, + Err(e) => { + log_error!(self.logger, "Failed to RBF channel: {}", e); + return Err(Error::ChannelSplicingFailed); + }, + }; + let contribution = match adjusted { + Some(contribution) => contribution, // The pending splice spends manually selected inputs, as a splice-in of all funds - // does. LDK adjusts such a contribution only up to its maximum feerate and never - // supplements it by coin selection, so replace it with one built the same way - // from all confirmed funds: its inputs and any coins confirmed since. - Err(FundingContributionError::ManuallySelectedInputsInsufficient) => { + // does, so replace it with one built the same way from all confirmed funds: its + // inputs and any coins confirmed since. + None => { let pending_inputs = funding_template .prior_contribution() .map_or(Vec::new(), |contribution| contribution.inputs().to_vec()); @@ -2354,10 +2375,6 @@ impl Node { max_feerate, )? }, - Err(e) => { - log_error!(self.logger, "Failed to RBF channel: {}", e); - return Err(Error::ChannelSplicingFailed); - }, }; self.channel_manager diff --git a/tests/integration_tests_rust.rs b/tests/integration_tests_rust.rs index 614cca160b..4c503774ad 100644 --- a/tests/integration_tests_rust.rs +++ b/tests/integration_tests_rust.rs @@ -3972,6 +3972,122 @@ async fn splice_in_with_all_bump_fee_above_max_feerate_adds_new_coins() { node_b.stop().unwrap(); } +/// Splices all of node_b's on-chain funds into a channel from a coin that covers the fee with +/// little more than dust to spare, then fee-bumps the splice to its maximum feerate. Paid from the +/// same inputs, the bump would leave less than dust to add to the channel, which LDK does not +/// check for: the bump fails instead, and the pending splice confirms as it is. +#[tokio::test(flavor = "multi_thread", worker_threads = 1)] +async fn splice_in_with_all_bump_fee_leaving_dust_fails() { + 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); + + // The splice transaction weighs about 1_000 weight units, so at `fee_rate` it pays about + // 2_000 sats in fees. Beyond the anchor reserve for the one channel, node_b's coin then leaves + // about 1_000 sats to splice in, above dust, while a bump to 1.5 times `fee_rate`, the most a + // bump may pay, would leave only a few. + let anchor_reserve_sats = node_b.config().anchor_channels_config.per_channel_reserve_sats; + let fee_rate = FeeRate::from_sat_per_kwu(2_000); + let coin_amount_sat = anchor_reserve_sats + 3_050; + + let address_a = node_a.onchain_payment().new_address().unwrap(); + let address_b = node_b.onchain_payment().new_address().unwrap(); + premine_and_distribute_funds( + &bitcoind.client, + &electrsd.client, + vec![address_a], + Amount::from_sat(5_000_000), + ) + .await; + distribute_funds_unconfirmed( + &bitcoind.client, + &electrsd.client, + vec![address_b], + Amount::from_sat(coin_amount_sat), + ) + .await; + generate_blocks_and_wait(&bitcoind.client, &electrsd.client, 1).await; + node_a.sync_wallets().unwrap(); + node_b.sync_wallets().unwrap(); + + // node_a funds the channel, so node_b's coin stays untouched for the splice-in, and pushes + // node_b a balance above the channel reserve, which the little the splice adds would not reach. + let channel_amount_sat = 1_000_000; + open_channel_push_amt( + &node_a, + &node_b, + channel_amount_sat, + Some(100_000_000), + false, + &electrsd, + ) + .await; + 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()); + let user_channel_id_b = expect_channel_ready_event!(node_b, node_a.node_id()); + + let balances = node_b.list_balances(); + assert_eq!(balances.total_onchain_balance_sats, coin_amount_sat); + assert_eq!(balances.total_anchor_channels_reserve_sats, anchor_reserve_sats); + + node_b.splice_in_with_all(&user_channel_id_b, node_a.node_id(), Some(fee_rate.into())).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; + + let payment_id = PaymentId(original_txo.txid.to_byte_array()); + let original = node_b.payment(&payment_id).unwrap().expect("splice payment exists"); + let original_amount_sat = original.amount_msat.expect("splice payment has an amount") / 1000; + let original_fee_sat = original.fee_paid_msat.expect("splice payment has a fee") / 1000; + assert_eq!(original_amount_sat + original_fee_sat, coin_amount_sat - anchor_reserve_sats); + // The same inputs can pay half the fee more only by adding less than dust to the channel. + assert!(original_amount_sat >= 546, "the splice added dust: {original_amount_sat} sats"); + assert!( + original_amount_sat < 546 + original_fee_sat / 2, + "the bump could still add dust: {original_amount_sat} sats after a fee of {original_fee_sat}" + ); + + // At the pending splice's maximum feerate, LDK pays the bump from the same inputs. + let bump_fee_rate = FeeRate::from_sat_per_kwu(3_000); + assert_eq!( + node_b.bump_channel_funding_fee( + &user_channel_id_b, + node_a.node_id(), + Some(bump_fee_rate.into()) + ), + Err(NodeError::InsufficientFunds) + ); + let payment = node_b.payment(&payment_id).unwrap().expect("splice payment exists"); + assert_eq!(payment.status, PaymentStatus::Pending); + + 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(original_txo)); + assert_eq!(channels[0].channel_value_sats, channel_amount_sat + original_amount_sat); + + let payment = node_b.payment(&payment_id).unwrap().expect("splice payment exists"); + assert_eq!(payment.status, PaymentStatus::Succeeded); + assert_eq!(payment.amount_msat, Some(original_amount_sat * 1000)); + + // 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(); From 120a068f39b239c1acc5b476d89d1491d3692436 Mon Sep 17 00:00:00 2001 From: Jeffrey Czyz Date: Mon, 26 Oct 2026 16:13:32 -0500 Subject: [PATCH 10/10] f - Note the two bump tests that rely on LDK's stale maximum check splice_in_with_all_bump_fee_above_max_feerate_adds_new_coins and splice_rounds_discarded_while_the_channel_is_listed_fail_at_close reach the rebuild only because LDK bounds a prior contribution's adjustment by the first round's maximum feerate rather than the bump's, which https://git.rust-bitcoin.org/lightningdevkit/rust-lightning/pulls/5297 corrects. Once the pin includes it, the bumps adjust in place and both tests fail. Say so where each bump is made, with the change that keeps the rebuild under test. Co-Authored-By: Claude Fable 5.1 --- tests/integration_tests_rust.rs | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/tests/integration_tests_rust.rs b/tests/integration_tests_rust.rs index 4c503774ad..5fe512ed3e 100644 --- a/tests/integration_tests_rust.rs +++ b/tests/integration_tests_rust.rs @@ -3915,6 +3915,12 @@ async fn splice_in_with_all_bump_fee_above_max_feerate_adds_new_coins() { ); // Above the first round's maximum feerate. + // TODO: Once the LDK pin includes + // https://git.rust-bitcoin.org/lightningdevkit/rust-lightning/pulls/5297, the adjustment is + // bounded by the bump's own maximum and succeeds in place, so the bump keeps the first round's + // inputs and the coin assertion below fails. Make the first round's inputs unable to cover the + // bump's fee instead, sized as in `splice_in_with_all_bump_fee_leaving_dust_fails`, so the + // rebuild is still what adds the coin. let bump_fee_rate = FeeRate::from_sat_per_kwu(2_000); node_b .bump_channel_funding_fee(&user_channel_id_b, node_a.node_id(), Some(bump_fee_rate.into())) @@ -5132,6 +5138,12 @@ async fn splice_rounds_discarded_while_the_channel_is_listed_fail_at_close() { mine_block_with(&bitcoind, &electrsd, &[raw_transaction_hex(&bitcoind, coin_txid)]).await; node_a.sync_wallets().unwrap(); + // Past the first round's maximum feerate, so the bump is rebuilt with the coin. + // TODO: Once the LDK pin includes + // https://git.rust-bitcoin.org/lightningdevkit/rust-lightning/pulls/5297, the adjustment is + // bounded by the bump's own maximum and succeeds in place, so the bump keeps the first round's + // inputs and the input assertions below fail. Make the first round a fixed-amount `splice_in` + // that leaves no change instead, with coin selection then drawing the bump's fee from the coin. let bump_fee_rate = FeeRate::from_sat_per_kwu(1_000); node_a .bump_channel_funding_fee(&user_channel_id_a, node_b.node_id(), Some(bump_fee_rate.into()))