diff --git a/CHANGELOG.md b/CHANGELOG.md index 4d7c631bb3..b937501adc 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 83bcc6afa4..c702d445a2 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 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/common/mod.rs b/tests/common/mod.rs index 93bab690f4..3257540507 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 2c94803499..f9a15ed660 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); @@ -3327,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();