From 3886eedcf09cadcb2c152599519e897cfde698a0 Mon Sep 17 00:00:00 2001 From: Leo Nash Date: Fri, 20 Feb 2026 22:42:39 +0000 Subject: [PATCH 1/2] Assert that a balance under a post-splice reserve did not budge Notably, if a party splices funds into the channel, their new balance must be above the new reserve. --- lightning/src/ln/channel.rs | 36 ++++++++++++++++++++++++++---------- 1 file changed, 26 insertions(+), 10 deletions(-) diff --git a/lightning/src/ln/channel.rs b/lightning/src/ln/channel.rs index 7943ed98719..c34d7dd0fb8 100644 --- a/lightning/src/ln/channel.rs +++ b/lightning/src/ln/channel.rs @@ -2834,7 +2834,7 @@ impl FundingScope { // New reserve values are based on the new channel value and are v2-specific let counterparty_selected_channel_reserve_satoshis = - Some(get_v2_channel_reserve_satoshis(post_channel_value, MIN_CHAN_DUST_LIMIT_SATOSHIS)); + get_v2_channel_reserve_satoshis(post_channel_value, MIN_CHAN_DUST_LIMIT_SATOSHIS); let holder_selected_channel_reserve_satoshis = get_v2_channel_reserve_satoshis( post_channel_value, context.counterparty_dust_limit_satoshis, @@ -2844,23 +2844,39 @@ impl FundingScope { channel_transaction_parameters: post_channel_transaction_parameters, value_to_self_msat: post_value_to_self_msat, funding_transaction: None, - counterparty_selected_channel_reserve_satoshis, + counterparty_selected_channel_reserve_satoshis: Some( + counterparty_selected_channel_reserve_satoshis, + ), holder_selected_channel_reserve_satoshis, #[cfg(debug_assertions)] holder_prev_commitment_tx_balance: { let prev = *prev_funding.holder_prev_commitment_tx_balance.lock().unwrap(); - Mutex::new(( - prev.0.saturating_add_signed(our_funding_contribution.to_sat() * 1000), - prev.1.saturating_add_signed(their_funding_contribution.to_sat() * 1000), - )) + let new_holder_balance_msat = + prev.0.saturating_add_signed(our_funding_contribution.to_sat() * 1000); + let new_counterparty_balance_msat = + prev.1.saturating_add_signed(their_funding_contribution.to_sat() * 1000); + if new_holder_balance_msat < counterparty_selected_channel_reserve_satoshis { + assert_eq!(new_holder_balance_msat, prev.0); + } + if new_counterparty_balance_msat < holder_selected_channel_reserve_satoshis { + assert_eq!(new_counterparty_balance_msat, prev.1); + } + Mutex::new((new_holder_balance_msat, new_counterparty_balance_msat)) }, #[cfg(debug_assertions)] counterparty_prev_commitment_tx_balance: { let prev = *prev_funding.counterparty_prev_commitment_tx_balance.lock().unwrap(); - Mutex::new(( - prev.0.saturating_add_signed(our_funding_contribution.to_sat() * 1000), - prev.1.saturating_add_signed(their_funding_contribution.to_sat() * 1000), - )) + let new_holder_balance_msat = + prev.0.saturating_add_signed(our_funding_contribution.to_sat() * 1000); + let new_counterparty_balance_msat = + prev.1.saturating_add_signed(their_funding_contribution.to_sat() * 1000); + if new_holder_balance_msat < counterparty_selected_channel_reserve_satoshis { + assert_eq!(new_holder_balance_msat, prev.0); + } + if new_counterparty_balance_msat < holder_selected_channel_reserve_satoshis { + assert_eq!(new_counterparty_balance_msat, prev.1); + } + Mutex::new((new_holder_balance_msat, new_counterparty_balance_msat)) }, #[cfg(any(test, fuzzing))] next_local_fee: Mutex::new(PredictedNextFee::default()), From 1959d529f47dccce5eabc18bd83702ed9391c86c Mon Sep 17 00:00:00 2001 From: Leo Nash Date: Sun, 22 Feb 2026 06:05:47 +0000 Subject: [PATCH 2/2] Check that funder covers the fee spike buffer multiple after a splice We do this for HTLCs, so we should also do this for splices. This applies to `only_static_remote_key` channels alone. --- lightning/src/ln/channel.rs | 28 +- lightning/src/ln/splicing_tests.rs | 438 ++++++++++++++++++++++++++++- 2 files changed, 446 insertions(+), 20 deletions(-) diff --git a/lightning/src/ln/channel.rs b/lightning/src/ln/channel.rs index c34d7dd0fb8..c926896008f 100644 --- a/lightning/src/ln/channel.rs +++ b/lightning/src/ln/channel.rs @@ -5958,7 +5958,9 @@ impl ChannelContext { max_reserved_commit_tx_fee_msat as i64; if capacity_minus_commitment_fee_msat < (real_dust_limit_timeout_sat as i64) * 1000 { let one_htlc_difference_msat = max_reserved_commit_tx_fee_msat - min_reserved_commit_tx_fee_msat; - debug_assert!(one_htlc_difference_msat != 0); + if !funding.get_channel_type().supports_anchor_zero_fee_commitments() { + debug_assert!(one_htlc_difference_msat != 0); + } capacity_minus_commitment_fee_msat += one_htlc_difference_msat as i64; capacity_minus_commitment_fee_msat = cmp::min(real_dust_limit_timeout_sat as i64 * 1000 - 1, capacity_minus_commitment_fee_msat); available_capacity_msat = cmp::max(0, cmp::min(capacity_minus_commitment_fee_msat, available_capacity_msat as i64)) as u64; @@ -12718,12 +12720,26 @@ where &self, funding: &FundingScope, ) -> Result<(Amount, Amount), String> { let include_counterparty_unknown_htlcs = true; - // Make sure that that the funder of the channel can pay the transaction fees for an additional - // nondust HTLC on the channel. - let addl_nondust_htlc_count = 1; // We are not interested in dust exposure let dust_exposure_limiting_feerate = None; + let addl_nondust_htlc_count = + if funding.get_channel_type().supports_anchor_zero_fee_commitments() { + 0 + } else { + // Require the channel opener to reserve enough funds to pay the fees for an + // additional non-dust HTLC in the channel. + 1 + }; + + let feerate_per_kw = if !funding.get_channel_type().supports_anchors_zero_fee_htlc_tx() { + // Similar to HTLC additions, require the funder to have enough funds reserved for + // fees such that the feerate can jump without rendering the channel useless. + self.context.feerate_per_kw * FEE_SPIKE_BUFFER_FEE_INCREASE_MULTIPLE as u32 + } else { + self.context.feerate_per_kw + }; + let local_commitment_stats = self .context .get_next_local_commitment_stats( @@ -12731,7 +12747,7 @@ where None, // htlc_candidate include_counterparty_unknown_htlcs, addl_nondust_htlc_count, - self.context.feerate_per_kw, + feerate_per_kw, dust_exposure_limiting_feerate, ) .map_err(|()| "Balance after HTLCs and anchors exhausted on local commitment")?; @@ -12747,7 +12763,7 @@ where None, // htlc_candidate include_counterparty_unknown_htlcs, addl_nondust_htlc_count, - self.context.feerate_per_kw, + feerate_per_kw, dust_exposure_limiting_feerate, ) .map_err(|()| "Balance after HTLCs and anchors exhausted on remote commitment")?; diff --git a/lightning/src/ln/splicing_tests.rs b/lightning/src/ln/splicing_tests.rs index 92a298f6ef1..da689e601e3 100644 --- a/lightning/src/ln/splicing_tests.rs +++ b/lightning/src/ln/splicing_tests.rs @@ -15,7 +15,9 @@ use crate::chain::transaction::OutPoint; use crate::chain::ChannelMonitorUpdateStatus; use crate::events::{ClosureReason, Event, FundingInfo, HTLCHandlingFailureType}; use crate::ln::chan_utils; -use crate::ln::channel::CHANNEL_ANNOUNCEMENT_PROPAGATION_DELAY; +use crate::ln::channel::{ + CHANNEL_ANNOUNCEMENT_PROPAGATION_DELAY, FEE_SPIKE_BUFFER_FEE_INCREASE_MULTIPLE, +}; use crate::ln::channelmanager::{provided_init_features, PaymentId, BREAKDOWN_TIMEOUT}; use crate::ln::functional_test_utils::*; use crate::ln::funding::FundingContribution; @@ -23,6 +25,8 @@ use crate::ln::msgs::{self, BaseMessageHandler, ChannelMessageHandler, MessageSe use crate::ln::outbound_payment::RecipientOnionFields; use crate::ln::types::ChannelId; use crate::routing::router::{PaymentParameters, RouteParameters}; +use crate::types::features::ChannelTypeFeatures; +use crate::util::config::UserConfig; use crate::util::errors::APIError; use crate::util::ser::Writeable; use crate::util::wallet_utils::{WalletSourceSync, WalletSync}; @@ -222,26 +226,29 @@ pub fn complete_interactive_funding_negotiation<'a, 'b, 'c, 'd>( let node_id_initiator = initiator.node.get_our_node_id(); let node_id_acceptor = acceptor.node.get_our_node_id(); - let funding_outpoint = initiator + let (funding_outpoint, channel_value_satoshis) = initiator .node .list_channels() .iter() .find(|channel| { channel.counterparty.node_id == node_id_acceptor && channel.channel_id == channel_id }) - .map(|channel| channel.funding_txo.unwrap()) + .map(|channel| (channel.funding_txo.unwrap(), channel.channel_value_satoshis)) .unwrap(); - let (initiator_inputs, initiator_outputs) = initiator_contribution.into_tx_parts(); - let mut expected_initiator_inputs = initiator_inputs + let new_channel_value = Amount::from_sat( + channel_value_satoshis + .checked_add_signed(initiator_contribution.net_value().to_sat()) + .unwrap(), + ); + let (initiator_funding_tx_inputs, mut expected_initiator_outputs) = + initiator_contribution.into_tx_parts(); + let mut expected_initiator_inputs = initiator_funding_tx_inputs .iter() .map(|input| input.utxo.outpoint) .chain(core::iter::once(funding_outpoint.into_bitcoin_outpoint())) .collect::>(); - let mut expected_initiator_scripts = initiator_outputs - .into_iter() - .map(|output| output.script_pubkey) - .chain(core::iter::once(new_funding_script)) - .collect::>(); + expected_initiator_outputs + .push(TxOut { script_pubkey: new_funding_script, value: new_channel_value }); let mut acceptor_sent_tx_complete = false; loop { @@ -261,13 +268,16 @@ pub fn complete_interactive_funding_negotiation<'a, 'b, 'c, 'd>( expected_initiator_inputs.iter().position(|input| *input == input_prevout).unwrap(), ); acceptor.node.handle_tx_add_input(node_id_initiator, &tx_add_input); - } else if !expected_initiator_scripts.is_empty() { + } else if !expected_initiator_outputs.is_empty() { let tx_add_output = get_event_msg!(initiator, MessageSendEvent::SendTxAddOutput, node_id_acceptor); - expected_initiator_scripts.remove( - expected_initiator_scripts + expected_initiator_outputs.remove( + expected_initiator_outputs .iter() - .position(|script| *script == tx_add_output.script) + .position(|output| { + *output.script_pubkey == tx_add_output.script + && output.value.to_sat() == tx_add_output.sats + }) .unwrap(), ); acceptor.node.handle_tx_add_output(node_id_initiator, &tx_add_output); @@ -2796,3 +2806,403 @@ fn test_splice_balance_falls_below_reserve() { // Final sanity check: send a payment using the new spliced capacity. let _ = send_payment(&nodes[0], &[&nodes[1]], 1_000_000); } + +#[test] +fn test_splice_pending_htlcs() { + let mut config = test_default_channel_config(); + config.channel_handshake_config.max_inbound_htlc_value_in_flight_percent_of_channel = 100; + config.channel_handshake_config.negotiate_anchors_zero_fee_htlc_tx = false; + config.channel_handshake_config.negotiate_anchor_zero_fee_commitments = false; + do_test_splice_pending_htlcs(config); + + let mut config = test_default_channel_config(); + config.channel_handshake_config.max_inbound_htlc_value_in_flight_percent_of_channel = 100; + config.channel_handshake_config.negotiate_anchors_zero_fee_htlc_tx = true; + config.channel_handshake_config.negotiate_anchor_zero_fee_commitments = false; + do_test_splice_pending_htlcs(config); + + let mut config = test_default_channel_config(); + config.channel_handshake_config.max_inbound_htlc_value_in_flight_percent_of_channel = 100; + config.channel_handshake_config.negotiate_anchors_zero_fee_htlc_tx = false; + config.channel_handshake_config.negotiate_anchor_zero_fee_commitments = true; + do_test_splice_pending_htlcs(config); +} + +#[cfg(test)] +fn do_test_splice_pending_htlcs(config: UserConfig) { + // Test balance checks for inbound and outbound splice-outs while there are pending HTLCs in the channel. + // The channel fundee requests unaffordable splice-outs in the first section, while the channel funder does so + // in the second section. + let anchors_features = ChannelTypeFeatures::anchors_zero_htlc_fee_and_dependencies(); + let zero_fee_commits_features = ChannelTypeFeatures::anchors_zero_fee_commitments(); + let legacy_features = ChannelTypeFeatures::only_static_remote_key(); + let initial_channel_value = Amount::from_sat(100_000); + let push_amount = Amount::from_sat(10_000); + + let chanmon_cfgs = create_chanmon_cfgs(2); + let node_cfgs = create_node_cfgs(2, &chanmon_cfgs); + let node_chanmgrs = create_node_chanmgrs(2, &node_cfgs, &[Some(config.clone()), Some(config)]); + let nodes = create_network(2, &node_cfgs, &node_chanmgrs); + + let node_id_0 = nodes[0].node.get_our_node_id(); + let node_id_1 = nodes[1].node.get_our_node_id(); + + let (_, _, channel_id, _) = create_announced_chan_between_nodes_with_value( + &nodes, + 0, + 1, + initial_channel_value.to_sat(), + push_amount.to_sat() * 1000, + ); + + let (channel_type, feerate_per_kw) = nodes[0] + .node + .list_channels() + .iter() + .find(|channel| { + channel.counterparty.node_id == node_id_1 && channel.channel_id == channel_id + }) + .map(|channel| { + (channel.channel_type.clone().unwrap(), channel.feerate_sat_per_1000_weight.unwrap()) + }) + .unwrap(); + + // Place some pending HTLCs in the channel, in both directions + let (preimage_1_to_0_a, _hash_1_to_0, ..) = route_payment(&nodes[1], &[&nodes[0]], 2_000_000); + let (preimage_1_to_0_b, _hash_1_to_0, ..) = route_payment(&nodes[1], &[&nodes[0]], 2_000_000); + let (preimage_1_to_0_c, _hash_1_to_0, ..) = route_payment(&nodes[1], &[&nodes[0]], 2_000_000); + let (preimage_0_to_1_a, _hash_0_to_1, ..) = route_payment(&nodes[0], &[&nodes[1]], 40_000_000); + let (preimage_0_to_1_b, _hash_0_to_1, ..) = route_payment(&nodes[0], &[&nodes[1]], 40_000_000); + + // Let the sender of the splice-out first be channel fundee, then the channel funder: + // 0) Set the channel up such that when the sender requests a splice-out, their balance is equal to their + // reserved funds. + // 1) Check that splicing out an additional satoshi fails validation on the sender's side, + // 2) Check that splicing out with the additional satoshi removed passes validation on the sender's side, + // 3) Overwrite the splice-out message to add an additional satoshi to the splice-out, and check that it fails + // validation on the receiver's side. + // 4) Try again with the additional satoshi removed from the splice-out message, and check that it passes + // validation on the receiver's side. + + let (preimage_1_to_0_d, node_1_real_splice_out) = { + // Step 0 + + let debit_htlcs = Amount::from_sat(2_000 * 3); + let node_1_balance = push_amount - debit_htlcs; + let node_1_splice_estimated_fees = Amount::from_sat(183); + let node_1_splice_out = Amount::from_sat(1000); + let node_1_real_splice_out = node_1_splice_out + node_1_splice_estimated_fees; + let post_splice_reserve = (initial_channel_value - node_1_real_splice_out) / 100; + let node_1_pre_splice_balance = post_splice_reserve + node_1_real_splice_out; + let (preimage_1_to_0_d, _hash_1_to_0, ..) = route_payment( + &nodes[1], + &[&nodes[0]], + (node_1_balance - node_1_pre_splice_balance).to_sat() * 1000, + ); + + // Step 1 + + let outputs = vec![TxOut { + value: node_1_splice_out + Amount::ONE_SAT, + script_pubkey: nodes[1].wallet_source.get_change_script().unwrap(), + }]; + let initiator_contribution = initiate_splice_out(&nodes[1], &nodes[0], channel_id, outputs); + assert_eq!( + initiator_contribution.net_value(), + (node_1_real_splice_out + Amount::ONE_SAT).to_signed().unwrap() * -1 + ); + let expected_message = format!( + "Channel {} cannot be funded: Channel {} cannot be spliced out; our post-splice channel balance {} is smaller than their selected v2 reserve {}", + channel_id, channel_id, post_splice_reserve - Amount::ONE_SAT, post_splice_reserve + ); + nodes[1].logger.assert_log("lightning::ln::channel", expected_message, 1); + let _event = get_event!(nodes[1], Event::SpliceFailed); + + // Step 2 + + let outputs = vec![TxOut { + value: node_1_splice_out, + script_pubkey: nodes[1].wallet_source.get_change_script().unwrap(), + }]; + let _initiator_contribution = + initiate_splice_out(&nodes[1], &nodes[0], channel_id, outputs.clone()); + + let stfu_init = get_event_msg!(nodes[1], MessageSendEvent::SendStfu, node_id_0); + nodes[0].node.handle_stfu(node_id_1, &stfu_init); + let stfu_ack = get_event_msg!(nodes[0], MessageSendEvent::SendStfu, node_id_1); + nodes[1].node.handle_stfu(node_id_0, &stfu_ack); + + // Step 3 + + let mut splice_init = get_event_msg!(nodes[1], MessageSendEvent::SendSpliceInit, node_id_0); + splice_init.funding_contribution_satoshis -= 1; + nodes[0].node.handle_splice_init(node_id_1, &splice_init); + + let msg_events = nodes[0].node.get_and_clear_pending_msg_events(); + assert_eq!(msg_events.len(), 1); + let expected_message = format!( + "Channel {} cannot be spliced out; their post-splice channel balance {} is smaller than our selected v2 reserve {}", + channel_id, post_splice_reserve - Amount::ONE_SAT, post_splice_reserve + ); + if let MessageSendEvent::HandleError { action, .. } = &msg_events[0] { + assert!(matches!( + action, + msgs::ErrorAction::DisconnectPeerWithWarning { + msg: msgs::WarningMessage { + data: got_data, + channel_id: got_channel_id + } + } if got_data == &expected_message && got_channel_id == &channel_id + )); + } else { + panic!("Expected MessageSendEvent::HandleError"); + } + nodes[0].node.peer_disconnected(node_id_1); + nodes[1].node.peer_disconnected(node_id_0); + + let reconnect_args = ReconnectArgs::new(&nodes[0], &nodes[1]); + reconnect_nodes(reconnect_args); + + let _event = get_event!(nodes[1], Event::SpliceFailed); + + // Step 4 + + let _initiator_contribution = + initiate_splice_out(&nodes[1], &nodes[0], channel_id, outputs); + + let _new_funding_script = complete_splice_handshake(&nodes[1], &nodes[0]); + + // Don't complete the splice, leave node 1's balance untouched such that its + // `next_outbound_htlc_limit_msat` is exactly equal to its pre-splice balance - its pre-splice reserve. + + nodes[0].node.peer_disconnected(node_id_1); + nodes[1].node.peer_disconnected(node_id_0); + + let reconnect_args = ReconnectArgs::new(&nodes[0], &nodes[1]); + reconnect_nodes(reconnect_args); + + let _event = get_event!(nodes[1], Event::SpliceFailed); + + let (node_1_next_outbound_htlc_limit_msat, node_1_pre_splice_reserve_sat) = nodes[1] + .node + .list_channels() + .iter() + .find(|channel| { + channel.counterparty.node_id == node_id_0 && channel.channel_id == channel_id + }) + .map(|channel| { + ( + channel.next_outbound_htlc_limit_msat, + channel.unspendable_punishment_reserve.unwrap(), + ) + }) + .unwrap(); + + assert_eq!( + node_1_next_outbound_htlc_limit_msat, + (node_1_pre_splice_balance.to_sat() - node_1_pre_splice_reserve_sat) * 1000 + ); + + // At the end of the show, we'll claim the HTLC we used to setup the channel's balances above so we + // return its preimage. + // We'll also send a HTLC with the exact remaining amount available in the channel, which will match + // the balance we were about to splice out here. + (preimage_1_to_0_d, node_1_real_splice_out) + }; + + let preimage_0_to_1_d = { + // Step 0 + + let debit_htlcs = Amount::from_sat(40_000 * 2); + let debit_anchors = + if channel_type == anchors_features { Amount::from_sat(330 * 2) } else { Amount::ZERO }; + let node_0_balance = initial_channel_value - push_amount - debit_htlcs - debit_anchors; + let node_0_splice_estimated_fees = Amount::from_sat(183); + let node_0_splice_out = Amount::from_sat(1000); + let node_0_real_splice_out = node_0_splice_out + node_0_splice_estimated_fees; + let post_splice_reserve = (initial_channel_value - node_0_real_splice_out) / 100; + let maybe_spiked_feerate = feerate_per_kw + * if channel_type == legacy_features { + FEE_SPIKE_BUFFER_FEE_INCREASE_MULTIPLE as u32 + } else { + 1 + }; + let reserved_commit_tx_fee = Amount::from_sat(chan_utils::commit_tx_fee_sat( + maybe_spiked_feerate, + // The 6 HTLCs we sent previously, the HTLC we send just below, and the fee spike buffer HTLC + 6 + 1 + if channel_type == zero_fee_commits_features { 0 } else { 1 }, + &channel_type, + )); + let node_0_pre_splice_balance = + post_splice_reserve + reserved_commit_tx_fee + node_0_real_splice_out; + let (preimage_0_to_1_d, _hash_1_to_0, ..) = route_payment( + &nodes[0], + &[&nodes[1]], + (node_0_balance - node_0_pre_splice_balance).to_sat() * 1000, + ); + + // Step 1 + + let outputs = vec![TxOut { + value: node_0_splice_out + Amount::ONE_SAT, + script_pubkey: nodes[0].wallet_source.get_change_script().unwrap(), + }]; + let initiator_contribution = initiate_splice_out(&nodes[0], &nodes[1], channel_id, outputs); + assert_eq!( + initiator_contribution.net_value(), + (node_0_real_splice_out + Amount::ONE_SAT).to_signed().unwrap() * -1 + ); + let expected_message = format!( + "Channel {} cannot be funded: Channel {} cannot be spliced out; our post-splice channel balance {} is smaller than their selected v2 reserve {}", + channel_id, channel_id, post_splice_reserve - Amount::ONE_SAT, post_splice_reserve + ); + nodes[0].logger.assert_log("lightning::ln::channel", expected_message, 1); + let _event = get_event!(nodes[0], Event::SpliceFailed); + + // Step 2 + + let outputs = vec![TxOut { + value: node_0_splice_out, + script_pubkey: nodes[0].wallet_source.get_change_script().unwrap(), + }]; + let _initiator_contribution = + initiate_splice_out(&nodes[0], &nodes[1], channel_id, outputs.clone()); + + let stfu_init = get_event_msg!(nodes[0], MessageSendEvent::SendStfu, node_id_1); + nodes[1].node.handle_stfu(node_id_0, &stfu_init); + let stfu_ack = get_event_msg!(nodes[1], MessageSendEvent::SendStfu, node_id_0); + nodes[0].node.handle_stfu(node_id_1, &stfu_ack); + + // Step 3 + + let mut splice_init = get_event_msg!(nodes[0], MessageSendEvent::SendSpliceInit, node_id_1); + splice_init.funding_contribution_satoshis -= 1; + nodes[1].node.handle_splice_init(node_id_0, &splice_init); + + let msg_events = nodes[1].node.get_and_clear_pending_msg_events(); + assert_eq!(msg_events.len(), 1); + let expected_message = format!( + "Channel {} cannot be spliced out; their post-splice channel balance {} is smaller than our selected v2 reserve {}", + channel_id, post_splice_reserve - Amount::ONE_SAT, post_splice_reserve + ); + if let MessageSendEvent::HandleError { action, .. } = &msg_events[0] { + assert!(matches!( + action, + msgs::ErrorAction::DisconnectPeerWithWarning { + msg: msgs::WarningMessage { + data: got_data, + channel_id: got_channel_id + } + } if got_data == &expected_message && got_channel_id == &channel_id + )); + } else { + panic!("Expected MessageSendEvent::HandleError"); + } + nodes[0].node.peer_disconnected(node_id_1); + nodes[1].node.peer_disconnected(node_id_0); + + let reconnect_args = ReconnectArgs::new(&nodes[0], &nodes[1]); + reconnect_nodes(reconnect_args); + + let _event = get_event!(nodes[0], Event::SpliceFailed); + + // Step 4 + + let initiator_contribution = initiate_splice_out(&nodes[0], &nodes[1], channel_id, outputs); + + // Now actually follow through on the splice + + let (splice_tx, _) = + splice_channel(&nodes[0], &nodes[1], channel_id, initiator_contribution); + + let node_0_channel_details = nodes[0] + .node + .list_channels() + .iter() + .find(|channel| { + channel.counterparty.node_id == node_id_1 && channel.channel_id == channel_id + }) + .cloned() + .unwrap(); + + // The funder's balance has exactly its reserve plus the fee for an inbound non-dust HTLC, + // so its `next_outbound_htlc_limit_msat` is exactly 0. We'll send that last inbound non-dust HTLC + // across further below to close the circle. + assert_eq!(node_0_channel_details.next_outbound_htlc_limit_msat, 0); + + // Confirm and lock the splice. + mine_transaction(&nodes[0], &splice_tx); + mine_transaction(&nodes[1], &splice_tx); + lock_splice_after_blocks(&nodes[0], &nodes[1], ANTI_REORG_DELAY - 1); + + // Return the preimage of the HTLC used to setup the balances so we can claim the HTLC below + preimage_0_to_1_d + }; + + let node_1_next_outbound_htlc_limit_msat = nodes[1] + .node + .list_channels() + .iter() + .find(|channel| { + channel.counterparty.node_id == node_id_0 && channel.channel_id == channel_id + }) + .map(|channel| channel.next_outbound_htlc_limit_msat) + .unwrap(); + + // Node 0 has now spliced the channel, so even though node 1 has not done anything, the max-size HTLC node 1 + // can send is now its pre-splice balance - its post-splice reserve. This matches the balance it was about to + // splice out above, but never did. + assert_eq!(node_1_next_outbound_htlc_limit_msat, node_1_real_splice_out.to_sat() * 1000); + + // Send the last max-size non-dust HTLC in the channel + let _ = send_payment(&nodes[1], &[&nodes[0]], node_1_real_splice_out.to_sat() * 1000); + + let node_1_next_outbound_htlc_limit_msat = nodes[1] + .node + .list_channels() + .iter() + .find(|channel| { + channel.counterparty.node_id == node_id_0 && channel.channel_id == channel_id + }) + .map(|channel| channel.next_outbound_htlc_limit_msat) + .unwrap(); + + // Node 1 is exactly at the V2 channel reserve, given that we just sent node 1's entire available balance + // across. + assert_eq!(node_1_next_outbound_htlc_limit_msat, 0); + + let node_0_next_outbound_htlc_limit_msat = nodes[0] + .node + .list_channels() + .iter() + .find(|channel| { + channel.counterparty.node_id == node_id_1 && channel.channel_id == channel_id + }) + .map(|channel| channel.next_outbound_htlc_limit_msat) + .unwrap(); + let spike_multiple = + if channel_type == legacy_features { FEE_SPIKE_BUFFER_FEE_INCREASE_MULTIPLE } else { 1 }; + let node_0_new_balance_sat = + chan_utils::commit_tx_fee_sat(spike_multiple as u32 * feerate_per_kw, 8, &channel_type) + + node_1_real_splice_out.to_sat() + - spike_multiple * chan_utils::commit_tx_fee_sat(feerate_per_kw, 9, &channel_type); + // Node 0's balance is its previous balance + the HTLC it just claimed - the reserved fee (the channel reserves + // cancel out). + assert_eq!(node_0_next_outbound_htlc_limit_msat, node_0_new_balance_sat * 1000); + + // Clean up the channel + claim_payment(&nodes[1], &[&nodes[0]], preimage_1_to_0_a); + claim_payment(&nodes[1], &[&nodes[0]], preimage_1_to_0_b); + claim_payment(&nodes[1], &[&nodes[0]], preimage_1_to_0_c); + + claim_payment(&nodes[1], &[&nodes[0]], preimage_1_to_0_d); + + claim_payment(&nodes[0], &[&nodes[1]], preimage_0_to_1_a); + claim_payment(&nodes[0], &[&nodes[1]], preimage_0_to_1_b); + + claim_payment(&nodes[0], &[&nodes[1]], preimage_0_to_1_d); + + // Check that the channel is still operational + let _ = send_payment(&nodes[0], &[&nodes[1]], 2_000 * 1000); + let _ = send_payment(&nodes[1], &[&nodes[0]], 2_000 * 1000); +}