From 0ea26165cc985a63ae521acfbc47debbe1f68140 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 15aa1daecfe..2bea5aa19b9 100644 --- a/lightning/src/ln/channel.rs +++ b/lightning/src/ln/channel.rs @@ -2788,7 +2788,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, @@ -2798,23 +2798,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 260dcc5646119bcfb561cdf2478edea2f4dd9817 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. --- .../src/upgrade_downgrade_tests.rs | 3 +- lightning/src/ln/async_signer_tests.rs | 2 +- lightning/src/ln/channel.rs | 12 +- lightning/src/ln/splicing_tests.rs | 484 ++++++++++++++++-- 4 files changed, 463 insertions(+), 38 deletions(-) diff --git a/lightning-tests/src/upgrade_downgrade_tests.rs b/lightning-tests/src/upgrade_downgrade_tests.rs index 93d671b176d..f68615dbb87 100644 --- a/lightning-tests/src/upgrade_downgrade_tests.rs +++ b/lightning-tests/src/upgrade_downgrade_tests.rs @@ -457,7 +457,8 @@ fn do_test_0_1_htlc_forward_after_splice(fail_htlc: bool) { script_pubkey: nodes[0].wallet_source.get_change_script().unwrap(), }]; let channel_id = ChannelId(chan_id_bytes_a); - let funding_contribution = initiate_splice_out(&nodes[0], &nodes[1], channel_id, outputs); + let funding_contribution = + initiate_splice_out(&nodes[0], &nodes[1], channel_id, outputs).unwrap(); let (splice_tx, _) = splice_channel(&nodes[0], &nodes[1], channel_id, funding_contribution); for node in nodes.iter() { mine_transaction(node, &splice_tx); diff --git a/lightning/src/ln/async_signer_tests.rs b/lightning/src/ln/async_signer_tests.rs index e6cd197bf1e..451af3918bf 100644 --- a/lightning/src/ln/async_signer_tests.rs +++ b/lightning/src/ln/async_signer_tests.rs @@ -1576,7 +1576,7 @@ fn test_async_splice_initial_commit_sig() { value: Amount::from_sat(1_000), script_pubkey: nodes[0].wallet_source.get_change_script().unwrap(), }]; - let contribution = initiate_splice_out(initiator, acceptor, channel_id, outputs); + let contribution = initiate_splice_out(initiator, acceptor, channel_id, outputs).unwrap(); negotiate_splice_tx(initiator, acceptor, channel_id, contribution); assert!(initiator.node.get_and_clear_pending_msg_events().is_empty()); diff --git a/lightning/src/ln/channel.rs b/lightning/src/ln/channel.rs index 2bea5aa19b9..165b7525cd6 100644 --- a/lightning/src/ln/channel.rs +++ b/lightning/src/ln/channel.rs @@ -12401,6 +12401,14 @@ where // We are not interested in dust exposure let dust_exposure_limiting_feerate = None; + 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_stats, _local_htlcs) = self .context .get_next_local_commitment_stats( @@ -12408,7 +12416,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 exhausted on local commitment")?; @@ -12420,7 +12428,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 exhausted on remote commitment")?; diff --git a/lightning/src/ln/splicing_tests.rs b/lightning/src/ln/splicing_tests.rs index ab890fdbab7..d2c61c45b04 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}; @@ -154,18 +158,20 @@ pub fn do_initiate_splice_in<'a, 'b, 'c, 'd>( pub fn initiate_splice_out<'a, 'b, 'c, 'd>( initiator: &'a Node<'b, 'c, 'd>, acceptor: &'a Node<'b, 'c, 'd>, channel_id: ChannelId, outputs: Vec, -) -> FundingContribution { +) -> Result { let node_id_acceptor = acceptor.node.get_our_node_id(); let feerate = FeeRate::from_sat_per_kwu(FEERATE_FLOOR_SATS_PER_KW as u64); let funding_template = initiator.node.splice_channel(&channel_id, &node_id_acceptor, feerate).unwrap(); let wallet = WalletSync::new(Arc::clone(&initiator.wallet_source), initiator.logger); let funding_contribution = funding_template.splice_out_sync(outputs, &wallet).unwrap(); - initiator - .node - .funding_contributed(&channel_id, &node_id_acceptor, funding_contribution.clone(), None) - .unwrap(); - funding_contribution + initiator.node.funding_contributed( + &channel_id, + &node_id_acceptor, + funding_contribution.clone(), + None, + )?; + Ok(funding_contribution) } pub fn initiate_splice_in_and_out<'a, 'b, 'c, 'd>( @@ -225,26 +231,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 { @@ -264,13 +273,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); @@ -552,7 +564,7 @@ fn do_test_splice_state_reset_on_disconnect(reload: bool) { script_pubkey: nodes[0].wallet_source.get_change_script().unwrap(), }]; let funding_contribution = - initiate_splice_out(&nodes[0], &nodes[1], channel_id, outputs.clone()); + initiate_splice_out(&nodes[0], &nodes[1], channel_id, outputs.clone()).unwrap(); // Attempt a splice negotiation that only goes up to receiving `splice_init`. Reconnecting // should implicitly abort the negotiation and reset the splice state such that we're able to @@ -598,7 +610,7 @@ fn do_test_splice_state_reset_on_disconnect(reload: bool) { reconnect_nodes(reconnect_args); let funding_contribution = - initiate_splice_out(&nodes[0], &nodes[1], channel_id, outputs.clone()); + initiate_splice_out(&nodes[0], &nodes[1], channel_id, outputs.clone()).unwrap(); // Attempt a splice negotiation that ends mid-construction of the funding transaction. // Reconnecting should implicitly abort the negotiation and reset the splice state such that @@ -649,7 +661,7 @@ fn do_test_splice_state_reset_on_disconnect(reload: bool) { reconnect_nodes(reconnect_args); let funding_contribution = - initiate_splice_out(&nodes[0], &nodes[1], channel_id, outputs.clone()); + initiate_splice_out(&nodes[0], &nodes[1], channel_id, outputs.clone()).unwrap(); // Attempt a splice negotiation that ends before the initial `commitment_signed` messages are // exchanged. The node missing the other's `commitment_signed` upon reconnecting should @@ -727,7 +739,8 @@ fn do_test_splice_state_reset_on_disconnect(reload: bool) { // Attempt a splice negotiation that completes, (i.e. `tx_signatures` are exchanged). Reconnecting // should not abort the negotiation or reset the splice state. - let funding_contribution = initiate_splice_out(&nodes[0], &nodes[1], channel_id, outputs); + let funding_contribution = + initiate_splice_out(&nodes[0], &nodes[1], channel_id, outputs).unwrap(); let (splice_tx, _) = splice_channel(&nodes[0], &nodes[1], channel_id, funding_contribution); if reload { @@ -785,7 +798,7 @@ fn test_config_reject_inbound_splices() { script_pubkey: nodes[0].wallet_source.get_change_script().unwrap(), }]; let funding_contribution = - initiate_splice_out(&nodes[0], &nodes[1], channel_id, outputs.clone()); + initiate_splice_out(&nodes[0], &nodes[1], channel_id, outputs.clone()).unwrap(); let stfu = get_event_msg!(nodes[0], MessageSendEvent::SendStfu, node_id_1); nodes[1].node.handle_stfu(node_id_0, &stfu); @@ -813,7 +826,8 @@ fn test_config_reject_inbound_splices() { reconnect_args.send_announcement_sigs = (true, true); reconnect_nodes(reconnect_args); - let funding_contribution = initiate_splice_out(&nodes[1], &nodes[0], channel_id, outputs); + let funding_contribution = + initiate_splice_out(&nodes[1], &nodes[0], channel_id, outputs).unwrap(); let _ = splice_channel(&nodes[1], &nodes[0], channel_id, funding_contribution); } @@ -892,7 +906,8 @@ fn test_splice_out() { script_pubkey: nodes[1].wallet_source.get_change_script().unwrap(), }, ]; - let funding_contribution = initiate_splice_out(&nodes[0], &nodes[1], channel_id, outputs); + let funding_contribution = + initiate_splice_out(&nodes[0], &nodes[1], channel_id, outputs).unwrap(); let (splice_tx, _) = splice_channel(&nodes[0], &nodes[1], channel_id, funding_contribution); mine_transaction(&nodes[0], &splice_tx); @@ -1441,7 +1456,8 @@ fn do_test_splice_reestablish(reload: bool, async_monitor_update: bool) { script_pubkey: nodes[1].wallet_source.get_change_script().unwrap(), }, ]; - let initiator_contribution = initiate_splice_out(&nodes[0], &nodes[1], channel_id, outputs); + let initiator_contribution = + initiate_splice_out(&nodes[0], &nodes[1], channel_id, outputs).unwrap(); negotiate_splice_tx(&nodes[0], &nodes[1], channel_id, initiator_contribution); // Node 0 should have a signing event to handle since they had a contribution in the splice. @@ -2154,7 +2170,8 @@ fn fail_splice_on_tx_complete_error() { value: Amount::from_sat(1_000), script_pubkey: acceptor.wallet_source.get_change_script().unwrap(), }]; - let funding_contribution = initiate_splice_out(initiator, acceptor, channel_id, outputs); + let funding_contribution = + initiate_splice_out(initiator, acceptor, channel_id, outputs).unwrap(); let _ = complete_splice_handshake(initiator, acceptor); // Queue an outgoing HTLC to the holding cell. It should be freed once we exit quiescence. @@ -2239,7 +2256,7 @@ fn free_holding_cell_on_tx_signatures_quiescence_exit() { value: Amount::from_sat(1_000), script_pubkey: initiator.wallet_source.get_change_script().unwrap(), }]; - let contribution = initiate_splice_out(initiator, acceptor, channel_id, outputs); + let contribution = initiate_splice_out(initiator, acceptor, channel_id, outputs).unwrap(); negotiate_splice_tx(initiator, acceptor, channel_id, contribution); // Queue an outgoing HTLC to the holding cell. It should be freed once we exit quiescence. @@ -2518,7 +2535,8 @@ fn do_test_splice_with_inflight_htlc_forward_and_resolution(expire_scid_pre_forw value: Amount::from_sat(1_000), script_pubkey: nodes[0].wallet_source.get_change_script().unwrap(), }]; - let contribution = initiate_splice_out(&nodes[0], &nodes[1], channel_id_0_1, outputs_0_1); + let contribution = + initiate_splice_out(&nodes[0], &nodes[1], channel_id_0_1, outputs_0_1).unwrap(); let (splice_tx_0_1, _) = splice_channel(&nodes[0], &nodes[1], channel_id_0_1, contribution); for node in &nodes { mine_transaction(node, &splice_tx_0_1); @@ -2528,7 +2546,8 @@ fn do_test_splice_with_inflight_htlc_forward_and_resolution(expire_scid_pre_forw value: Amount::from_sat(1_000), script_pubkey: nodes[1].wallet_source.get_change_script().unwrap(), }]; - let contribution = initiate_splice_out(&nodes[1], &nodes[2], channel_id_1_2, outputs_1_2); + let contribution = + initiate_splice_out(&nodes[1], &nodes[2], channel_id_1_2, outputs_1_2).unwrap(); let (splice_tx_1_2, _) = splice_channel(&nodes[1], &nodes[2], channel_id_1_2, contribution); for node in &nodes { mine_transaction(node, &splice_tx_1_2); @@ -2636,7 +2655,8 @@ fn test_splice_buffer_commitment_signed_until_funding_tx_signed() { value: Amount::from_sat(1_000), script_pubkey: nodes[0].wallet_source.get_change_script().unwrap(), }]; - let initiator_contribution = initiate_splice_out(&nodes[0], &nodes[1], channel_id, outputs); + let initiator_contribution = + initiate_splice_out(&nodes[0], &nodes[1], channel_id, outputs).unwrap(); negotiate_splice_tx(&nodes[0], &nodes[1], channel_id, initiator_contribution); // Node 0 (initiator with contribution) should have a signing event to handle. @@ -2757,7 +2777,8 @@ fn test_splice_buffer_invalid_commitment_signed_closes_channel() { value: Amount::from_sat(1_000), script_pubkey: nodes[0].wallet_source.get_change_script().unwrap(), }]; - let initiator_contribution = initiate_splice_out(&nodes[0], &nodes[1], channel_id, outputs); + let initiator_contribution = + initiate_splice_out(&nodes[0], &nodes[1], channel_id, outputs).unwrap(); negotiate_splice_tx(&nodes[0], &nodes[1], channel_id, initiator_contribution); // Node 0 (initiator with contribution) should have a signing event to handle. @@ -3350,3 +3371,398 @@ fn test_funding_contributed_unfunded_channel() { expect_discard_funding_event(&nodes[0], &unfunded_channel_id, funding_contribution); } + +#[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_splice_out_incl_fees) = { + // 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_splice_out_incl_fees = node_1_splice_out + node_1_splice_estimated_fees; + let post_splice_reserve = (initial_channel_value - node_1_splice_out_incl_fees) / 100; + let node_1_pre_splice_balance = post_splice_reserve + node_1_splice_out_incl_fees; + 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, + ); + + do_clunky_splice_out_dance( + &nodes[1], + &nodes[0], + node_1_splice_out, + node_1_splice_out_incl_fees, + channel_id, + post_splice_reserve, + ); + + 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 mut events = nodes[1].node.get_and_clear_pending_events(); + assert!( + matches!(events.pop().unwrap(), Event::DiscardFunding { channel_id: chan_id, .. } if chan_id == channel_id ) + ); + assert!( + matches!(events.pop().unwrap(), Event::SpliceFailed { channel_id: chan_id, .. } if chan_id == channel_id) + ); + + 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_splice_out_incl_fees) + }; + + let preimage_0_to_1_d = { + // Step 0, set channel up + + 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_splice_out_incl_fees = node_0_splice_out + node_0_splice_estimated_fees; + let post_splice_reserve = (initial_channel_value - node_0_splice_out_incl_fees) / 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_splice_out_incl_fees; + 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, + ); + + let initiator_contribution = do_clunky_splice_out_dance( + &nodes[0], + &nodes[1], + node_0_splice_out, + node_0_splice_out_incl_fees, + channel_id, + post_splice_reserve, + ); + + // 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_splice_out_incl_fees.to_sat() * 1000); + + // Send the last max-size non-dust HTLC in the channel + let _ = send_payment(&nodes[1], &[&nodes[0]], node_1_splice_out_incl_fees.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_splice_out_incl_fees.to_sat() + - chan_utils::commit_tx_fee_sat( + spike_multiple as u32 * 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); +} + +#[cfg(test)] +fn do_clunky_splice_out_dance<'a, 'b, 'c>( + splice_initiator: &Node<'a, 'b, 'c>, splice_acceptor: &Node<'a, 'b, 'c>, + splice_out_value: Amount, splice_out_value_incl_fees: Amount, channel_id: ChannelId, + post_splice_reserve: Amount, +) -> FundingContribution { + let node_id_initiator = splice_initiator.node.get_our_node_id(); + let node_id_acceptor = splice_acceptor.node.get_our_node_id(); + + // Step 1, validation fails initiator side + + let outputs = vec![TxOut { + value: splice_out_value + Amount::ONE_SAT, + script_pubkey: splice_initiator.wallet_source.get_change_script().unwrap(), + }]; + let error = + initiate_splice_out(splice_initiator, splice_acceptor, channel_id, outputs).unwrap_err(); + let message_cannot_accept_funding_contribution = + format!("Channel {} cannot accept funding contribution", channel_id); + assert!( + matches!(error, APIError::APIMisuseError { err } if err == message_cannot_accept_funding_contribution) + ); + let message_cannot_be_funded = 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 + ); + splice_initiator.logger.assert_log("lightning::ln::channel", message_cannot_be_funded, 1); + let mut events = splice_initiator.node.get_and_clear_pending_events(); + assert!( + matches!(events.pop().unwrap(), Event::DiscardFunding { channel_id: chan_id, .. } if chan_id == channel_id ) + ); + assert!( + matches!(events.pop().unwrap(), Event::SpliceFailed { channel_id: chan_id, .. } if chan_id == channel_id) + ); + + // Step 2, validation passes initiator side + + let outputs = vec![TxOut { + value: splice_out_value, + script_pubkey: splice_initiator.wallet_source.get_change_script().unwrap(), + }]; + let initiator_contribution = + initiate_splice_out(splice_initiator, splice_acceptor, channel_id, outputs.clone()) + .unwrap(); + assert_eq!( + initiator_contribution.net_value(), + splice_out_value_incl_fees.to_signed().unwrap() * -1 + ); + + let stfu_init = get_event_msg!(splice_initiator, MessageSendEvent::SendStfu, node_id_acceptor); + splice_acceptor.node.handle_stfu(node_id_initiator, &stfu_init); + let stfu_ack = get_event_msg!(splice_acceptor, MessageSendEvent::SendStfu, node_id_initiator); + splice_initiator.node.handle_stfu(node_id_acceptor, &stfu_ack); + + // Step 3, validation fails acceptor side + + let mut splice_init = + get_event_msg!(splice_initiator, MessageSendEvent::SendSpliceInit, node_id_acceptor); + splice_init.funding_contribution_satoshis -= 1; + splice_acceptor.node.handle_splice_init(node_id_initiator, &splice_init); + + let msg_events = splice_acceptor.node.get_and_clear_pending_msg_events(); + assert_eq!(msg_events.len(), 1); + let message_cannot_be_spliced_out = 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 == &message_cannot_be_spliced_out && got_channel_id == &channel_id + )); + } else { + panic!("Expected MessageSendEvent::HandleError"); + } + splice_acceptor.node.peer_disconnected(node_id_initiator); + splice_initiator.node.peer_disconnected(node_id_acceptor); + + let reconnect_args = ReconnectArgs::new(splice_initiator, splice_acceptor); + reconnect_nodes(reconnect_args); + + let mut events = splice_initiator.node.get_and_clear_pending_events(); + assert!( + matches!(events.pop().unwrap(), Event::DiscardFunding { channel_id: chan_id, .. } if chan_id == channel_id ) + ); + assert!( + matches!(events.pop().unwrap(), Event::SpliceFailed { channel_id: chan_id, .. } if chan_id == channel_id) + ); + + // Step 4, validation passes acceptor side + + let initiator_contribution = + initiate_splice_out(splice_initiator, splice_acceptor, channel_id, outputs).unwrap(); + assert_eq!( + initiator_contribution.net_value(), + splice_out_value_incl_fees.to_signed().unwrap() * -1 + ); + + initiator_contribution +}