From 89e9b75b7deeb89911ce5538b854b6bbea779a06 Mon Sep 17 00:00:00 2001 From: Leo Nash Date: Wed, 13 May 2026 21:41:18 +0000 Subject: [PATCH 1/3] Validate `next_splice_out_maximum_sat` on both commitments Wilmer's fuzzing runs caught a case where an advertised splice out maximum hit the debug assertions in `get_next_splice_out_maximum`. These debug assertions ensure that any adverstised splice out maximum passes the validation of splice contributions. The core issue is that we only read the local commitment when calculating the splice out maximum, but our splice validation requires that any splice out maximum is covered by the minimum of the holder's balances on the local and the remote commitments. Therefore, if a HTLC is dust on the local commitment, but non-dust on the remote commitment, and the holder is the funder of the channel, we advertise a splice out maximum that is not covered by the holder's balance on the remote commitment, and fails our validation of splice contributions. We now read both commitments when calculating the next splice out maximum, which fixes this issue. --- lightning/src/ln/channel.rs | 3 + lightning/src/ln/splicing_tests.rs | 172 +++++++++++++++++++++++++++++ lightning/src/sign/tx_builder.rs | 73 +++++++----- 3 files changed, 220 insertions(+), 28 deletions(-) diff --git a/lightning/src/ln/channel.rs b/lightning/src/ln/channel.rs index 50c395df658..bb721c74d1f 100644 --- a/lightning/src/ln/channel.rs +++ b/lightning/src/ln/channel.rs @@ -2554,6 +2554,9 @@ pub(super) struct FundingScope { value_to_self_msat: u64, // Excluding all pending_htlcs, fees, and anchor outputs /// minimum channel reserve for self to maintain - set by them. + #[cfg(any(test, feature = "_externalize_tests"))] + pub(super) counterparty_selected_channel_reserve_satoshis: Option, + #[cfg(not(any(test, feature = "_externalize_tests")))] counterparty_selected_channel_reserve_satoshis: Option, #[cfg(any(test, feature = "_externalize_tests"))] diff --git a/lightning/src/ln/splicing_tests.rs b/lightning/src/ln/splicing_tests.rs index 35c72509d0b..5e02c42b19b 100644 --- a/lightning/src/ln/splicing_tests.rs +++ b/lightning/src/ln/splicing_tests.rs @@ -9304,3 +9304,175 @@ fn do_test_splice_out_initiator_reserve_breach_zero_fee_commitments( acceptor.logger.assert_log("lightning::ln::channelmanager", cannot_splice_out, 1); } } + +#[test] +fn test_splice_out_maximum_on_both_commitments_dust_on_fundee_commitment() { + use crate::ln::htlc_reserve_unit_tests::setup_0reserve_no_outputs_channels; + + let chanmon_cfgs = create_chanmon_cfgs(2); + let node_cfgs = create_node_cfgs(2, &chanmon_cfgs); + let mut config = test_default_channel_config(); + config.channel_handshake_config.announced_channel_max_inbound_htlc_value_in_flight_percentage = + 100; + let node_chanmgrs = create_node_chanmgrs(2, &node_cfgs, &[Some(config.clone()), Some(config)]); + let nodes = create_network(2, &node_cfgs, &node_chanmgrs); + + const CHANNEL_VALUE_SAT: u64 = 100_000; + const FEERATE: u32 = 253; + const TOTAL_ANCHORS_SAT: u64 = 2 * 330; + const NODE_0_DUST_LIMIT_SAT: u64 = 354; + const NODE_1_DUST_LIMIT_SAT: u64 = 10_000; + + let (channel_id, _transaction) = + setup_0reserve_no_outputs_channels(&nodes, CHANNEL_VALUE_SAT, NODE_0_DUST_LIMIT_SAT); + + { + let per_peer_state_lock; + let mut peer_state_lock; + let chan = + get_channel_ref!(nodes[0], nodes[1], per_peer_state_lock, peer_state_lock, channel_id); + chan.context_mut().counterparty_dust_limit_satoshis = NODE_1_DUST_LIMIT_SAT; + assert_eq!(chan.context().holder_dust_limit_satoshis, NODE_0_DUST_LIMIT_SAT); + assert_eq!(chan.funding().holder_selected_channel_reserve_satoshis, 0); + assert_eq!(chan.funding().counterparty_selected_channel_reserve_satoshis, Some(0)); + } + + { + let per_peer_state_lock; + let mut peer_state_lock; + let chan = + get_channel_ref!(nodes[1], nodes[0], per_peer_state_lock, peer_state_lock, channel_id); + chan.context_mut().holder_dust_limit_satoshis = NODE_1_DUST_LIMIT_SAT; + assert_eq!(chan.context().counterparty_dust_limit_satoshis, NODE_0_DUST_LIMIT_SAT); + assert_eq!(chan.funding().holder_selected_channel_reserve_satoshis, 0); + assert_eq!(chan.funding().counterparty_selected_channel_reserve_satoshis, Some(0)); + } + + let details = &nodes[0].node.list_channels()[0]; + let channel_type = details.channel_type.clone().unwrap(); + assert_eq!(channel_type, ChannelTypeFeatures::anchors_zero_htlc_fee_and_dependencies()); + + // This HTLC is only present on node 0's commitment + const SNEAKY_HTLC_SAT: u64 = 5_000; + + let (_, payment_hash, ..) = route_payment(&nodes[0], &[&nodes[1]], SNEAKY_HTLC_SAT * 1000); + + let node_0_details = &nodes[0].node.list_channels()[0]; + let reserved_fee_sat = chan_utils::commit_tx_fee_sat(FEERATE, 0, &channel_type); + let expected_next_splice_out_maximum_sat = CHANNEL_VALUE_SAT + - SNEAKY_HTLC_SAT + - TOTAL_ANCHORS_SAT + - reserved_fee_sat + - NODE_1_DUST_LIMIT_SAT; + assert_eq!(node_0_details.next_splice_out_maximum_sat, expected_next_splice_out_maximum_sat); + let node_1_details = &nodes[1].node.list_channels()[0]; + assert_eq!(node_1_details.next_splice_out_maximum_sat, 0); + + fail_payment(&nodes[0], &[&nodes[1]], payment_hash); + + let details = &nodes[0].node.list_channels()[0]; + let reserved_fee_sat = chan_utils::commit_tx_fee_sat(FEERATE, 2, &channel_type); + let expected_available_capacity_sat = CHANNEL_VALUE_SAT - TOTAL_ANCHORS_SAT - reserved_fee_sat; + assert_eq!(details.next_outbound_htlc_limit_msat, expected_available_capacity_sat * 1000); + let node_0_payment_sat = expected_available_capacity_sat; + send_payment(&nodes[0], &[&nodes[1]], node_0_payment_sat * 1000); + + // Make sure the local output is now gone from node 1's commitment + assert!(TOTAL_ANCHORS_SAT + reserved_fee_sat < NODE_1_DUST_LIMIT_SAT); + + let details = &nodes[1].node.list_channels()[0]; + let expected_next_splice_out_maximum_sat = node_0_payment_sat - NODE_1_DUST_LIMIT_SAT; + assert_eq!(details.next_splice_out_maximum_sat, expected_next_splice_out_maximum_sat); + + let details = &nodes[0].node.list_channels()[0]; + let reserved_fee_sat = chan_utils::commit_tx_fee_sat(FEERATE, 1, &channel_type); + let expected_next_splice_out_maximum_sat = + CHANNEL_VALUE_SAT - node_0_payment_sat - TOTAL_ANCHORS_SAT - reserved_fee_sat; + assert_eq!(details.next_splice_out_maximum_sat, expected_next_splice_out_maximum_sat); +} + +#[test] +fn test_splice_out_maximum_on_both_commitments_dust_on_funder_commitment() { + use crate::ln::htlc_reserve_unit_tests::setup_0reserve_no_outputs_channels; + + let chanmon_cfgs = create_chanmon_cfgs(2); + let node_cfgs = create_node_cfgs(2, &chanmon_cfgs); + let mut config = test_default_channel_config(); + config.channel_handshake_config.announced_channel_max_inbound_htlc_value_in_flight_percentage = + 100; + let node_chanmgrs = create_node_chanmgrs(2, &node_cfgs, &[Some(config.clone()), Some(config)]); + let nodes = create_network(2, &node_cfgs, &node_chanmgrs); + + const CHANNEL_VALUE_SAT: u64 = 100_000; + const FEERATE: u32 = 253; + const TOTAL_ANCHORS_SAT: u64 = 2 * 330; + const NODE_0_DUST_LIMIT_SAT: u64 = 10_000; + const NODE_1_DUST_LIMIT_SAT: u64 = 354; + + let (channel_id, _transaction) = + setup_0reserve_no_outputs_channels(&nodes, CHANNEL_VALUE_SAT, NODE_1_DUST_LIMIT_SAT); + + { + let per_peer_state_lock; + let mut peer_state_lock; + let chan = + get_channel_ref!(nodes[0], nodes[1], per_peer_state_lock, peer_state_lock, channel_id); + chan.context_mut().holder_dust_limit_satoshis = NODE_0_DUST_LIMIT_SAT; + assert_eq!(chan.context().counterparty_dust_limit_satoshis, NODE_1_DUST_LIMIT_SAT); + assert_eq!(chan.funding().holder_selected_channel_reserve_satoshis, 0); + assert_eq!(chan.funding().counterparty_selected_channel_reserve_satoshis, Some(0)); + } + + { + let per_peer_state_lock; + let mut peer_state_lock; + let chan = + get_channel_ref!(nodes[1], nodes[0], per_peer_state_lock, peer_state_lock, channel_id); + chan.context_mut().counterparty_dust_limit_satoshis = NODE_0_DUST_LIMIT_SAT; + assert_eq!(chan.context().holder_dust_limit_satoshis, NODE_1_DUST_LIMIT_SAT); + assert_eq!(chan.funding().holder_selected_channel_reserve_satoshis, 0); + assert_eq!(chan.funding().counterparty_selected_channel_reserve_satoshis, Some(0)); + } + + let details = &nodes[0].node.list_channels()[0]; + let channel_type = details.channel_type.clone().unwrap(); + assert_eq!(channel_type, ChannelTypeFeatures::anchors_zero_htlc_fee_and_dependencies()); + + // This HTLC is only present on node 1's commitment + const SNEAKY_HTLC_SAT: u64 = 5_000; + + let (_, payment_hash, ..) = route_payment(&nodes[0], &[&nodes[1]], SNEAKY_HTLC_SAT * 1000); + + let node_0_details = &nodes[0].node.list_channels()[0]; + let reserved_fee_sat = chan_utils::commit_tx_fee_sat(FEERATE, 0, &channel_type); + let expected_next_splice_out_maximum_sat = CHANNEL_VALUE_SAT + - SNEAKY_HTLC_SAT + - TOTAL_ANCHORS_SAT + - reserved_fee_sat + - NODE_0_DUST_LIMIT_SAT; + assert_eq!(node_0_details.next_splice_out_maximum_sat, expected_next_splice_out_maximum_sat); + let node_1_details = &nodes[1].node.list_channels()[0]; + assert_eq!(node_1_details.next_splice_out_maximum_sat, 0); + + fail_payment(&nodes[0], &[&nodes[1]], payment_hash); + + let details = &nodes[0].node.list_channels()[0]; + let reserved_fee_sat = chan_utils::commit_tx_fee_sat(FEERATE, 2, &channel_type); + let expected_available_capacity_sat = CHANNEL_VALUE_SAT - TOTAL_ANCHORS_SAT - reserved_fee_sat; + assert_eq!(details.next_outbound_htlc_limit_msat, expected_available_capacity_sat * 1000); + let node_0_payment_sat = expected_available_capacity_sat; + send_payment(&nodes[0], &[&nodes[1]], node_0_payment_sat * 1000); + + // Make sure the local output is now gone from node 0's commitment + assert!(TOTAL_ANCHORS_SAT + reserved_fee_sat < NODE_0_DUST_LIMIT_SAT); + + let details = &nodes[1].node.list_channels()[0]; + let expected_next_splice_out_maximum_sat = node_0_payment_sat - NODE_0_DUST_LIMIT_SAT; + assert_eq!(details.next_splice_out_maximum_sat, expected_next_splice_out_maximum_sat); + + let details = &nodes[0].node.list_channels()[0]; + let reserved_fee_sat = chan_utils::commit_tx_fee_sat(FEERATE, 1, &channel_type); + let expected_next_splice_out_maximum_sat = + CHANNEL_VALUE_SAT - node_0_payment_sat - TOTAL_ANCHORS_SAT - reserved_fee_sat; + assert_eq!(details.next_splice_out_maximum_sat, expected_next_splice_out_maximum_sat); +} diff --git a/lightning/src/sign/tx_builder.rs b/lightning/src/sign/tx_builder.rs index 6859d4dcf62..3a67eec1389 100644 --- a/lightning/src/sign/tx_builder.rs +++ b/lightning/src/sign/tx_builder.rs @@ -358,10 +358,18 @@ fn get_next_commitment_stats( // 3) s < (100h + 100 - 100d - c) / 99 fn get_next_splice_out_maximum_sat( is_outbound_from_holder: bool, channel_value_satoshis: u64, local_balance_before_fee_msat: u64, - remote_balance_before_fee_msat: u64, feerate_per_kw: u32, nondust_htlc_count: usize, - post_splice_delta_above_reserve_sat: u64, channel_constraints: &ChannelConstraints, - channel_type: &ChannelTypeFeatures, + remote_balance_before_fee_msat: u64, local_nondust_htlc_count: usize, + remote_nondust_htlc_count: usize, feerate_per_kw: u32, spiked_feerate: u32, + channel_constraints: &ChannelConstraints, channel_type: &ChannelTypeFeatures, ) -> u64 { + let post_splice_delta_above_reserve_sat = if is_outbound_from_holder { + let nondust_htlc_count = cmp::max(local_nondust_htlc_count, remote_nondust_htlc_count); + let commit_tx_fee_sat = + commit_tx_fee_sat(spiked_feerate, nondust_htlc_count + 1, channel_type); + commit_tx_fee_sat + } else { + 0 + }; let local_balance_before_fee_sat = local_balance_before_fee_msat / 1000; let mut next_splice_out_maximum_sat = if channel_constraints .counterparty_selected_channel_reserve_satoshis @@ -424,37 +432,47 @@ fn get_next_splice_out_maximum_sat( } max_splice_out_sat } else { - // In a zero-reserve channel, the holder is free to withdraw up to its `post_splice_delta_above_reserve_sat` + // In a zero-reserve channel, the holder is free to withdraw up to its `post_splice_delta_above_reserve_sat`. local_balance_before_fee_sat.saturating_sub(post_splice_delta_above_reserve_sat) }; - // We only bother to check the local commitment here, the counterparty will check its own commitment. - // // If the current `next_splice_out_maximum_sat` would produce a local commitment with no // outputs, bump this maximum such that, after the splice, the holder's balance covers at // least `dust_limit_satoshis` and, if they are the funder, `current_tx_fee_sat`. // We don't include an additional non-dust inbound HTLC in the `current_tx_fee_sat`, // because we don't mind if the holder dips below their dust limit to cover the fee for that // inbound non-dust HTLC. - if !has_output( - is_outbound_from_holder, - local_balance_before_fee_msat.saturating_sub(next_splice_out_maximum_sat * 1000), - remote_balance_before_fee_msat, - feerate_per_kw, - nondust_htlc_count, + // + // We use the regular feerate instead of the spiked feerate here as zero-reserve is not + // allowed on legacy channels. + let current_tx_fee_sat = commit_tx_fee_sat(feerate_per_kw, 0, channel_type); + let mut trim_splice_out_max_if_no_outputs = |nondust_htlc_count, dust_limit_satoshis| { + if !has_output( + is_outbound_from_holder, + local_balance_before_fee_msat.saturating_sub(next_splice_out_maximum_sat * 1000), + remote_balance_before_fee_msat, + feerate_per_kw, + nondust_htlc_count, + dust_limit_satoshis, + channel_type, + ) { + let min_balance_sat = if is_outbound_from_holder { + dust_limit_satoshis.saturating_add(current_tx_fee_sat) + } else { + dust_limit_satoshis + }; + next_splice_out_maximum_sat = + (local_balance_before_fee_msat / 1000).saturating_sub(min_balance_sat); + } + }; + trim_splice_out_max_if_no_outputs( + local_nondust_htlc_count, channel_constraints.holder_dust_limit_satoshis, - channel_type, - ) { - let dust_limit_satoshis = channel_constraints.holder_dust_limit_satoshis; - let current_tx_fee_sat = commit_tx_fee_sat(feerate_per_kw, 0, channel_type); - let min_balance_sat = if is_outbound_from_holder { - dust_limit_satoshis.saturating_add(current_tx_fee_sat) - } else { - dust_limit_satoshis - }; - next_splice_out_maximum_sat = - (local_balance_before_fee_msat / 1000).saturating_sub(min_balance_sat); - } + ); + trim_splice_out_max_if_no_outputs( + remote_nondust_htlc_count, + channel_constraints.counterparty_dust_limit_satoshis, + ); if channel_value_satoshis < next_splice_out_maximum_sat + MIN_CHANNEL_VALUE_SATOSHIS { next_splice_out_maximum_sat = @@ -568,11 +586,10 @@ fn get_available_balances( channel_value_satoshis, local_balance_before_fee_msat, remote_balance_before_fee_msat, - feerate_per_kw, - // The number of non-dust HTLCs on the local commitment at the current feerate local_nondust_htlc_count, - // The post-splice minimum balance of the holder - if is_outbound_from_holder { local_min_commit_tx_fee_sat } else { 0 }, + remote_nondust_htlc_count, + feerate_per_kw, + spiked_feerate, &channel_constraints, channel_type, ); From 69d2f08cfb189df16eaf7d8c78bd976163c3c500 Mon Sep 17 00:00:00 2001 From: Leo Nash Date: Wed, 13 May 2026 21:57:07 +0000 Subject: [PATCH 2/3] Break `get_available_balances` into small helper functions Most diffs here are code moves. --- lightning/src/sign/tx_builder.rs | 368 ++++++++++++++++++------------- 1 file changed, 220 insertions(+), 148 deletions(-) diff --git a/lightning/src/sign/tx_builder.rs b/lightning/src/sign/tx_builder.rs index 3a67eec1389..746f6d32b2e 100644 --- a/lightning/src/sign/tx_builder.rs +++ b/lightning/src/sign/tx_builder.rs @@ -482,6 +482,169 @@ fn get_next_splice_out_maximum_sat( next_splice_out_maximum_sat } +fn adjust_capacity_for_holder_reserved_fee(mut available_capacity_msat: u64, + local_nondust_htlc_count: usize, feerate_per_kw: u32, spiked_feerate: u32, + channel_constraints: &ChannelConstraints, channel_type: &ChannelTypeFeatures, +) -> u64 { + let (_real_htlc_success_tx_fee_sat, real_htlc_timeout_tx_fee_sat) = + second_stage_tx_fees_sat(channel_type, feerate_per_kw); + let fee_spike_buffer_htlc = 1; + // Note here we use the htlc count at the current feerate together with the spiked feerate; + // this makes sure that the holder can afford any fee bump between 1x to 2x from the current + // feerate. + let local_max_commit_tx_fee_sat = commit_tx_fee_sat( + spiked_feerate, + local_nondust_htlc_count + fee_spike_buffer_htlc + 1, + channel_type, + ); + let local_min_commit_tx_fee_sat = commit_tx_fee_sat( + spiked_feerate, + local_nondust_htlc_count + fee_spike_buffer_htlc, + channel_type, + ); + // We should mind channel commit tx fee when computing how much of the available capacity + // can be used in the next htlc. Mirrors the logic in send_htlc. + // + // The fee depends on whether the amount we will be sending is above dust or not, + // and the answer will in turn change the amount itself — making it a circular + // dependency. + // This complicates the computation around dust-values, up to the one-htlc-value. + + let real_dust_limit_timeout_sat = + real_htlc_timeout_tx_fee_sat + channel_constraints.holder_dust_limit_satoshis; + let max_reserved_commit_tx_fee_msat = local_max_commit_tx_fee_sat * 1000; + let min_reserved_commit_tx_fee_msat = local_min_commit_tx_fee_sat * 1000; + + // We will first subtract the fee as if we were above-dust. Then, if the resulting + // value ends up being below dust, we have this fee available again. In that case, + // match the value to right-below-dust. + let capacity_minus_max_commitment_fee_msat = + available_capacity_msat.saturating_sub(max_reserved_commit_tx_fee_msat); + if capacity_minus_max_commitment_fee_msat < real_dust_limit_timeout_sat * 1000 { + let capacity_minus_min_commitment_fee_msat = + available_capacity_msat.saturating_sub(min_reserved_commit_tx_fee_msat); + available_capacity_msat = cmp::min( + real_dust_limit_timeout_sat * 1000 - 1, + capacity_minus_min_commitment_fee_msat, + ); + } else { + available_capacity_msat = capacity_minus_max_commitment_fee_msat; + } + available_capacity_msat +} + +fn adjust_capacity_for_counterparty_reserved_fee(mut available_capacity_msat: u64, + remote_balance_before_fee_msat: u64, remote_nondust_htlc_count: usize, feerate_per_kw: u32, + channel_constraints: &ChannelConstraints, channel_type: &ChannelTypeFeatures +) -> u64 { + let (real_htlc_success_tx_fee_sat, _real_htlc_timeout_tx_fee_sat) = + second_stage_tx_fees_sat(channel_type, feerate_per_kw); + let remote_commit_tx_fee_sat = + commit_tx_fee_sat(feerate_per_kw, remote_nondust_htlc_count + 1, channel_type); + // If the channel is inbound (i.e. counterparty pays the fee), we need to make sure + // sending a new HTLC won't reduce their balance below our reserve threshold. + let real_dust_limit_success_sat = + real_htlc_success_tx_fee_sat + channel_constraints.counterparty_dust_limit_satoshis; + let max_reserved_commit_tx_fee_msat = remote_commit_tx_fee_sat * 1000; + + let holder_selected_chan_reserve_msat = + channel_constraints.holder_selected_channel_reserve_satoshis * 1000; + if remote_balance_before_fee_msat + < max_reserved_commit_tx_fee_msat + holder_selected_chan_reserve_msat + { + // If another HTLC's fee would reduce the remote's balance below the reserve limit + // we've selected for them, we can only send dust HTLCs. + available_capacity_msat = + cmp::min(available_capacity_msat, real_dust_limit_success_sat * 1000 - 1); + } + available_capacity_msat +} + +fn adjust_min_max_htlc_for_dust_exposure( + pending_htlcs: &[HTLCAmountDirection], feerate_per_kw: u32, + dust_exposure_limiting_feerate: Option, max_dust_htlc_exposure_msat: u64, + channel_constraints: &ChannelConstraints, channel_type: &ChannelTypeFeatures, + mut available_capacity_msat: u64, +) -> (u64, u64, u64) { + let mut next_outbound_htlc_minimum_msat = channel_constraints.counterparty_htlc_minimum_msat; + + let (local_dust_exposure_msat, _) = get_dust_exposure_stats( + true, + pending_htlcs, + feerate_per_kw, + dust_exposure_limiting_feerate, + channel_constraints.holder_dust_limit_satoshis, + channel_type, + ); + let (remote_dust_exposure_msat, extra_htlc_remote_dust_exposure_msat) = get_dust_exposure_stats( + false, + pending_htlcs, + feerate_per_kw, + dust_exposure_limiting_feerate, + channel_constraints.counterparty_dust_limit_satoshis, + channel_type, + ); + + // If we get close to our maximum dust exposure, we end up in a situation where we can send + // between zero and the remaining dust exposure limit remaining OR above the dust limit. + // Because we cannot express this as a simple min/max, we prefer to tell the user they can + // send above the dust limit (as the router can always overpay to meet the dust limit). + let mut remaining_msat_below_dust_exposure_limit = None; + let mut dust_exposure_dust_limit_msat = 0; + + let dust_buffer_feerate = get_dust_buffer_feerate(feerate_per_kw); + let (buffer_htlc_success_tx_fee_sat, buffer_htlc_timeout_tx_fee_sat) = + second_stage_tx_fees_sat(channel_type, dust_buffer_feerate); + let buffer_dust_limit_success_sat = + buffer_htlc_success_tx_fee_sat + channel_constraints.counterparty_dust_limit_satoshis; + let buffer_dust_limit_timeout_sat = + buffer_htlc_timeout_tx_fee_sat + channel_constraints.holder_dust_limit_satoshis; + + if let Some(extra_htlc_remote_dust_exposure) = extra_htlc_remote_dust_exposure_msat { + if extra_htlc_remote_dust_exposure > max_dust_htlc_exposure_msat { + // If adding an extra HTLC would put us over the dust limit in total fees, we cannot + // send any non-dust HTLCs. + available_capacity_msat = + cmp::min(available_capacity_msat, buffer_dust_limit_success_sat * 1000); + } + } + + if remote_dust_exposure_msat.saturating_add(buffer_dust_limit_success_sat * 1000) + > max_dust_htlc_exposure_msat.saturating_add(1) + { + // Note that we don't use the `counterparty_tx_dust_exposure` (with + // `htlc_dust_exposure_msat`) here as it only applies to non-dust HTLCs. + remaining_msat_below_dust_exposure_limit = + Some(max_dust_htlc_exposure_msat.saturating_sub(remote_dust_exposure_msat)); + dust_exposure_dust_limit_msat = + cmp::max(dust_exposure_dust_limit_msat, buffer_dust_limit_success_sat * 1000); + } + + if local_dust_exposure_msat as i64 + buffer_dust_limit_timeout_sat as i64 * 1000 - 1 + > max_dust_htlc_exposure_msat.try_into().unwrap_or(i64::max_value()) + { + remaining_msat_below_dust_exposure_limit = Some(cmp::min( + remaining_msat_below_dust_exposure_limit.unwrap_or(u64::max_value()), + max_dust_htlc_exposure_msat.saturating_sub(local_dust_exposure_msat), + )); + dust_exposure_dust_limit_msat = + cmp::max(dust_exposure_dust_limit_msat, buffer_dust_limit_timeout_sat * 1000); + } + + if let Some(remaining_limit_msat) = remaining_msat_below_dust_exposure_limit { + if available_capacity_msat < dust_exposure_dust_limit_msat { + available_capacity_msat = cmp::min(available_capacity_msat, remaining_limit_msat); + } else { + next_outbound_htlc_minimum_msat = + cmp::max(next_outbound_htlc_minimum_msat, dust_exposure_dust_limit_msat); + } + } + + let dust_exposure_msat = cmp::max(local_dust_exposure_msat, remote_dust_exposure_msat); + + (next_outbound_htlc_minimum_msat, available_capacity_msat, dust_exposure_msat) +} + fn get_available_balances( is_outbound_from_holder: bool, channel_value_satoshis: u64, value_to_holder_msat: u64, pending_htlcs: &[HTLCAmountDirection], feerate_per_kw: u32, @@ -499,9 +662,6 @@ fn get_available_balances( // commitment, we have not ack'ed these removals yet, so we expect the counterparty to count them when // validating our own HTLC add. These HTLCs would also revert to `Committed` upon a disconnection. - let fee_spike_buffer_htlc = - if channel_type.supports_anchor_zero_fee_commitments() { 0 } else { 1 }; - // Note that the feerate is 0 in zero-fee commitment channels, so this statement is a noop let spiked_feerate = feerate_per_kw.saturating_mul(if !channel_type.supports_anchors_zero_fee_htlc_tx() { @@ -522,27 +682,6 @@ fn get_available_balances( }) .count(); - // Note here we use the htlc count at the current feerate together with the spiked feerate; - // this makes sure that the holder can afford any fee bump between 1x to 2x from the current - // feerate. - let local_max_commit_tx_fee_sat = commit_tx_fee_sat( - spiked_feerate, - local_nondust_htlc_count + fee_spike_buffer_htlc + 1, - channel_type, - ); - let local_min_commit_tx_fee_sat = commit_tx_fee_sat( - spiked_feerate, - local_nondust_htlc_count + fee_spike_buffer_htlc, - channel_type, - ); - let (local_dust_exposure_msat, _) = get_dust_exposure_stats( - true, - pending_htlcs, - feerate_per_kw, - dust_exposure_limiting_feerate, - channel_constraints.holder_dust_limit_satoshis, - channel_type, - ); let remote_nondust_htlc_count = pending_htlcs .iter() .filter(|htlc| { @@ -554,16 +693,6 @@ fn get_available_balances( ) }) .count(); - let remote_commit_tx_fee_sat = - commit_tx_fee_sat(feerate_per_kw, remote_nondust_htlc_count + 1, channel_type); - let (remote_dust_exposure_msat, extra_htlc_remote_dust_exposure_msat) = get_dust_exposure_stats( - false, - pending_htlcs, - feerate_per_kw, - dust_exposure_limiting_feerate, - channel_constraints.counterparty_dust_limit_satoshis, - channel_type, - ); let outbound_htlcs_value_msat: u64 = pending_htlcs.iter().filter_map(|htlc| htlc.outbound.then_some(htlc.amount_msat)).sum(); @@ -598,117 +727,39 @@ fn get_available_balances( .saturating_sub(channel_constraints.counterparty_selected_channel_reserve_satoshis * 1000); let mut available_capacity_msat = outbound_capacity_msat; - let (real_htlc_success_tx_fee_sat, real_htlc_timeout_tx_fee_sat) = - second_stage_tx_fees_sat(channel_type, feerate_per_kw); if is_outbound_from_holder { - // We should mind channel commit tx fee when computing how much of the available capacity - // can be used in the next htlc. Mirrors the logic in send_htlc. - // - // The fee depends on whether the amount we will be sending is above dust or not, - // and the answer will in turn change the amount itself — making it a circular - // dependency. - // This complicates the computation around dust-values, up to the one-htlc-value. - - let real_dust_limit_timeout_sat = - real_htlc_timeout_tx_fee_sat + channel_constraints.holder_dust_limit_satoshis; - let max_reserved_commit_tx_fee_msat = local_max_commit_tx_fee_sat * 1000; - let min_reserved_commit_tx_fee_msat = local_min_commit_tx_fee_sat * 1000; - - // We will first subtract the fee as if we were above-dust. Then, if the resulting - // value ends up being below dust, we have this fee available again. In that case, - // match the value to right-below-dust. - let capacity_minus_max_commitment_fee_msat = - available_capacity_msat.saturating_sub(max_reserved_commit_tx_fee_msat); - if capacity_minus_max_commitment_fee_msat < real_dust_limit_timeout_sat * 1000 { - let capacity_minus_min_commitment_fee_msat = - available_capacity_msat.saturating_sub(min_reserved_commit_tx_fee_msat); - available_capacity_msat = cmp::min( - real_dust_limit_timeout_sat * 1000 - 1, - capacity_minus_min_commitment_fee_msat, - ); - } else { - available_capacity_msat = capacity_minus_max_commitment_fee_msat; - } + available_capacity_msat = adjust_capacity_for_holder_reserved_fee( + available_capacity_msat, local_nondust_htlc_count, feerate_per_kw, + spiked_feerate, &channel_constraints, channel_type + ); } else { - // If the channel is inbound (i.e. counterparty pays the fee), we need to make sure - // sending a new HTLC won't reduce their balance below our reserve threshold. - let real_dust_limit_success_sat = - real_htlc_success_tx_fee_sat + channel_constraints.counterparty_dust_limit_satoshis; - let max_reserved_commit_tx_fee_msat = remote_commit_tx_fee_sat * 1000; - - let holder_selected_chan_reserve_msat = - channel_constraints.holder_selected_channel_reserve_satoshis * 1000; - if remote_balance_before_fee_msat - < max_reserved_commit_tx_fee_msat + holder_selected_chan_reserve_msat - { - // If another HTLC's fee would reduce the remote's balance below the reserve limit - // we've selected for them, we can only send dust HTLCs. - available_capacity_msat = - cmp::min(available_capacity_msat, real_dust_limit_success_sat * 1000 - 1); - } - } - - let mut next_outbound_htlc_minimum_msat = channel_constraints.counterparty_htlc_minimum_msat; - - // If we get close to our maximum dust exposure, we end up in a situation where we can send - // between zero and the remaining dust exposure limit remaining OR above the dust limit. - // Because we cannot express this as a simple min/max, we prefer to tell the user they can - // send above the dust limit (as the router can always overpay to meet the dust limit). - let mut remaining_msat_below_dust_exposure_limit = None; - let mut dust_exposure_dust_limit_msat = 0; - - let dust_buffer_feerate = get_dust_buffer_feerate(feerate_per_kw); - let (buffer_htlc_success_tx_fee_sat, buffer_htlc_timeout_tx_fee_sat) = - second_stage_tx_fees_sat(channel_type, dust_buffer_feerate); - let buffer_dust_limit_success_sat = - buffer_htlc_success_tx_fee_sat + channel_constraints.counterparty_dust_limit_satoshis; - let buffer_dust_limit_timeout_sat = - buffer_htlc_timeout_tx_fee_sat + channel_constraints.holder_dust_limit_satoshis; - - if let Some(extra_htlc_remote_dust_exposure) = extra_htlc_remote_dust_exposure_msat { - if extra_htlc_remote_dust_exposure > max_dust_htlc_exposure_msat { - // If adding an extra HTLC would put us over the dust limit in total fees, we cannot - // send any non-dust HTLCs. - available_capacity_msat = - cmp::min(available_capacity_msat, buffer_dust_limit_success_sat * 1000); - } - } - - if remote_dust_exposure_msat.saturating_add(buffer_dust_limit_success_sat * 1000) - > max_dust_htlc_exposure_msat.saturating_add(1) - { - // Note that we don't use the `counterparty_tx_dust_exposure` (with - // `htlc_dust_exposure_msat`) here as it only applies to non-dust HTLCs. - remaining_msat_below_dust_exposure_limit = - Some(max_dust_htlc_exposure_msat.saturating_sub(remote_dust_exposure_msat)); - dust_exposure_dust_limit_msat = - cmp::max(dust_exposure_dust_limit_msat, buffer_dust_limit_success_sat * 1000); - } - - if local_dust_exposure_msat as i64 + buffer_dust_limit_timeout_sat as i64 * 1000 - 1 - > max_dust_htlc_exposure_msat.try_into().unwrap_or(i64::max_value()) - { - remaining_msat_below_dust_exposure_limit = Some(cmp::min( - remaining_msat_below_dust_exposure_limit.unwrap_or(u64::max_value()), - max_dust_htlc_exposure_msat.saturating_sub(local_dust_exposure_msat), - )); - dust_exposure_dust_limit_msat = - cmp::max(dust_exposure_dust_limit_msat, buffer_dust_limit_timeout_sat * 1000); + available_capacity_msat = adjust_capacity_for_counterparty_reserved_fee( + available_capacity_msat, + remote_balance_before_fee_msat, + remote_nondust_htlc_count, + feerate_per_kw, + &channel_constraints, + channel_type + ) } - if let Some(remaining_limit_msat) = remaining_msat_below_dust_exposure_limit { - if available_capacity_msat < dust_exposure_dust_limit_msat { - available_capacity_msat = cmp::min(available_capacity_msat, remaining_limit_msat); - } else { - next_outbound_htlc_minimum_msat = - cmp::max(next_outbound_htlc_minimum_msat, dust_exposure_dust_limit_msat); - } - } + let (next_outbound_htlc_minimum_msat, mut available_capacity_msat, dust_exposure_msat) = + adjust_min_max_htlc_for_dust_exposure( + pending_htlcs, + feerate_per_kw, + dust_exposure_limiting_feerate, + max_dust_htlc_exposure_msat, + &channel_constraints, + channel_type, + available_capacity_msat, + ); available_capacity_msat = cmp::min( available_capacity_msat, - channel_constraints.counterparty_max_htlc_value_in_flight_msat - outbound_htlcs_value_msat, + channel_constraints + .counterparty_max_htlc_value_in_flight_msat + .saturating_sub(outbound_htlcs_value_msat), ); if pending_htlcs.iter().filter(|htlc| htlc.outbound).count() + 1 @@ -719,7 +770,38 @@ fn get_available_balances( // Now adjust our min and max size HTLC to make sure both the local and the remote commitments still have // at least one output at the current feerate. + let (next_outbound_htlc_minimum_msat, available_capacity_msat) = + adjust_min_max_htlc_if_max_dust_htlc_produces_no_output( + is_outbound_from_holder, + local_balance_before_fee_msat, + remote_balance_before_fee_msat, + local_nondust_htlc_count, + remote_nondust_htlc_count, + feerate_per_kw, + &channel_constraints, + channel_type, + next_outbound_htlc_minimum_msat, + available_capacity_msat, + ); + + crate::ln::channel::AvailableBalances { + inbound_capacity_msat: remote_balance_before_fee_msat + .saturating_sub(channel_constraints.holder_selected_channel_reserve_satoshis * 1000), + outbound_capacity_msat, + next_outbound_htlc_limit_msat: available_capacity_msat, + next_outbound_htlc_minimum_msat, + dust_exposure_msat, + next_splice_out_maximum_sat, + } +} +fn adjust_min_max_htlc_if_max_dust_htlc_produces_no_output( + is_outbound_from_holder: bool, local_balance_before_fee_msat: u64, + remote_balance_before_fee_msat: u64, local_nondust_htlc_count: usize, + remote_nondust_htlc_count: usize, feerate_per_kw: u32, + channel_constraints: &ChannelConstraints, channel_type: &ChannelTypeFeatures, + next_outbound_htlc_minimum_msat: u64, available_capacity_msat: u64, +) -> (u64, u64) { let (next_outbound_htlc_minimum_msat, available_capacity_msat) = adjust_boundaries_if_max_dust_htlc_produces_no_output( true, @@ -747,17 +829,7 @@ fn get_available_balances( next_outbound_htlc_minimum_msat, available_capacity_msat, ); - let dust_exposure_msat = cmp::max(local_dust_exposure_msat, remote_dust_exposure_msat); - - crate::ln::channel::AvailableBalances { - inbound_capacity_msat: remote_balance_before_fee_msat - .saturating_sub(channel_constraints.holder_selected_channel_reserve_satoshis * 1000), - outbound_capacity_msat, - next_outbound_htlc_limit_msat: available_capacity_msat, - next_outbound_htlc_minimum_msat, - dust_exposure_msat, - next_splice_out_maximum_sat, - } + (next_outbound_htlc_minimum_msat, available_capacity_msat) } fn adjust_boundaries_if_max_dust_htlc_produces_no_output( From 9fe1a362295c680d512fb324fc12f629a73bb176 Mon Sep 17 00:00:00 2001 From: Leo Nash Date: Sat, 16 May 2026 02:52:27 +0000 Subject: [PATCH 3/3] Validate reserved fees on both commitments The local and remote commitments may have different dust limits, which can cause each commitment to have a different transaction fee. Therefore when we reserve commitment transaction fees in `get_available_balances`, we must ensure that we read the maximum of the transaction fees on the local and the remote commitments. Otherwise, we may have not reserved enough fees to ensure that our next proposed channel state update is onside. --- lightning/src/ln/htlc_reserve_unit_tests.rs | 269 +++++++++++++++++++- lightning/src/sign/tx_builder.rs | 167 ++++++------ 2 files changed, 355 insertions(+), 81 deletions(-) diff --git a/lightning/src/ln/htlc_reserve_unit_tests.rs b/lightning/src/ln/htlc_reserve_unit_tests.rs index 86d98b78826..290ae18f6fc 100644 --- a/lightning/src/ln/htlc_reserve_unit_tests.rs +++ b/lightning/src/ln/htlc_reserve_unit_tests.rs @@ -1053,10 +1053,10 @@ pub fn test_chan_reserve_dust_inbound_htlcs_outbound_chan() { * 1000; create_announced_chan_between_nodes_with_value(&nodes, 0, 1, 100000, push_amt); - let (htlc_success_tx_fee_sat, _) = + let (_htlc_success_tx_fee_sat, htlc_timeout_tx_fee_sat) = second_stage_tx_fees_sat(&channel_type_features, feerate_per_kw); let dust_amt = crate::ln::channel::MIN_CHAN_DUST_LIMIT_SATOSHIS * 1000 - + htlc_success_tx_fee_sat * 1000 + + htlc_timeout_tx_fee_sat * 1000 - 1; // In the previous code, routing this dust payment would cause nodes[0] to perceive a channel // reserve violation even though it's a dust HTLC and therefore shouldn't count towards the @@ -3528,3 +3528,268 @@ fn test_fail_cannot_afford_dust_htlcs_at_spike_multiple_if_nondust_at_base_feera true, ); } + +#[xtest(feature = "_externalize_tests")] +fn test_available_balances_both_commitments_dust_on_funder_commitment() { + let mut config = test_default_channel_config(); + + let chanmon_cfgs = create_chanmon_cfgs(2); + let node_cfgs = create_node_cfgs(2, &chanmon_cfgs); + config.channel_handshake_config.announced_channel_max_inbound_htlc_value_in_flight_percentage = + 100; + + let channel_type = ChannelTypeFeatures::anchors_zero_htlc_fee_and_dependencies(); + + let node_chanmgrs = create_node_chanmgrs(2, &node_cfgs, &[Some(config.clone()), Some(config)]); + let nodes = create_network(2, &node_cfgs, &node_chanmgrs); + + const FEERATE: u32 = 253; + const TOTAL_ANCHORS_MSAT: u64 = 2 * 330_000; + const NODE_0_DUST_LIMIT_MSAT: u64 = 10_000 * 1000; + const NODE_1_DUST_LIMIT_MSAT: u64 = 354 * 1000; + const CHANNEL_VALUE_MSAT: u64 = 50_000 * 1000; + const NODE_0_VALUE_TO_SELF_MSAT: u64 = 25_000 * 1000; + const NODE_1_VALUE_TO_SELF_MSAT: u64 = 25_000 * 1000; + const NODE_0_SELECTED_CHANNEL_RESERVE_MSAT: u64 = 1_000 * 1_000; + const NODE_1_SELECTED_CHANNEL_RESERVE_MSAT: u64 = 10_000 * 1_000; + + let channel_id = create_announced_chan_between_nodes_with_value( + &nodes, + 0, + 1, + CHANNEL_VALUE_MSAT / 1000, + NODE_1_VALUE_TO_SELF_MSAT, + ) + .2; + assert_eq!(nodes[0].node.list_channels()[0].channel_type.as_ref().unwrap(), &channel_type); + + { + let per_peer_state_lock; + let mut peer_state_lock; + let chan = + get_channel_ref!(nodes[0], nodes[1], per_peer_state_lock, peer_state_lock, channel_id); + chan.context_mut().holder_dust_limit_satoshis = NODE_0_DUST_LIMIT_MSAT / 1000; + chan.funding_mut().counterparty_selected_channel_reserve_satoshis = + Some(NODE_1_SELECTED_CHANNEL_RESERVE_MSAT / 1000); + assert_eq!(chan.context().counterparty_dust_limit_satoshis, NODE_1_DUST_LIMIT_MSAT / 1000); + assert_eq!( + chan.funding().holder_selected_channel_reserve_satoshis, + NODE_0_SELECTED_CHANNEL_RESERVE_MSAT / 1000 + ); + } + + { + let per_peer_state_lock; + let mut peer_state_lock; + let chan = + get_channel_ref!(nodes[1], nodes[0], per_peer_state_lock, peer_state_lock, channel_id); + chan.context_mut().counterparty_dust_limit_satoshis = NODE_0_DUST_LIMIT_MSAT / 1000; + chan.funding_mut().holder_selected_channel_reserve_satoshis = + NODE_1_SELECTED_CHANNEL_RESERVE_MSAT / 1000; + assert_eq!(chan.context().holder_dust_limit_satoshis, NODE_1_DUST_LIMIT_MSAT / 1000); + assert_eq!( + chan.funding().counterparty_selected_channel_reserve_satoshis, + Some(NODE_0_SELECTED_CHANNEL_RESERVE_MSAT / 1000) + ); + } + + // This HTLC is only present on node 1's commitment + const SNEAKY_HTLC_MSAT: u64 = 5_000_000; + + route_payment(&nodes[1], &[&nodes[0]], SNEAKY_HTLC_MSAT); + + let node_1_details = &nodes[1].node.list_channels()[0]; + let expected_outbound_capacity_msat = + NODE_1_VALUE_TO_SELF_MSAT - SNEAKY_HTLC_MSAT - NODE_0_SELECTED_CHANNEL_RESERVE_MSAT; + assert_eq!(node_1_details.outbound_capacity_msat, expected_outbound_capacity_msat); + let expected_available_capacity_msat = expected_outbound_capacity_msat; + assert_eq!(node_1_details.next_outbound_htlc_limit_msat, expected_available_capacity_msat); + let expected_splice_out_max = + NODE_1_VALUE_TO_SELF_MSAT / 1000 - SNEAKY_HTLC_MSAT / 1000 - NODE_1_DUST_LIMIT_MSAT / 1000; + assert_eq!(node_1_details.next_splice_out_maximum_sat, expected_splice_out_max); + + let node_0_details = &nodes[0].node.list_channels()[0]; + let expected_outbound_capacity_msat = + NODE_0_VALUE_TO_SELF_MSAT - NODE_1_SELECTED_CHANNEL_RESERVE_MSAT - TOTAL_ANCHORS_MSAT; + assert_eq!(node_0_details.outbound_capacity_msat, expected_outbound_capacity_msat); + let expected_available_capacity_msat = + expected_outbound_capacity_msat - commit_tx_fee_sat(FEERATE, 3, &channel_type) * 1000; + assert_eq!(node_0_details.next_outbound_htlc_limit_msat, expected_available_capacity_msat); + let expected_splice_out_max = NODE_0_VALUE_TO_SELF_MSAT / 1000 + - TOTAL_ANCHORS_MSAT / 1000 + - commit_tx_fee_sat(FEERATE, 2, &channel_type) + - NODE_0_DUST_LIMIT_MSAT / 1000; + assert_eq!(node_0_details.next_splice_out_maximum_sat, expected_splice_out_max); + + let node_0_payment_msat = expected_available_capacity_msat; + send_payment(&nodes[0], &[&nodes[1]], node_0_payment_msat); + + route_payment(&nodes[1], &[&nodes[0]], SNEAKY_HTLC_MSAT); + route_payment(&nodes[1], &[&nodes[0]], SNEAKY_HTLC_MSAT); + + let node_0_details = &nodes[0].node.list_channels()[0]; + let expected_outbound_capacity_msat = NODE_0_VALUE_TO_SELF_MSAT + - node_0_payment_msat + - NODE_1_SELECTED_CHANNEL_RESERVE_MSAT + - TOTAL_ANCHORS_MSAT; + assert_eq!(node_0_details.outbound_capacity_msat, expected_outbound_capacity_msat); + assert_eq!( + node_0_details.outbound_capacity_msat, + commit_tx_fee_sat(FEERATE, 3, &channel_type) * 1000 + ); + assert_eq!(node_0_details.next_outbound_htlc_limit_msat, 0); + assert_eq!(node_0_details.next_splice_out_maximum_sat, 0); + + let node_1_details = &nodes[1].node.list_channels()[0]; + let expected_outbound_capacity_msat = NODE_1_VALUE_TO_SELF_MSAT + node_0_payment_msat + - 3 * SNEAKY_HTLC_MSAT + - NODE_0_SELECTED_CHANNEL_RESERVE_MSAT; + assert_eq!(node_1_details.outbound_capacity_msat, expected_outbound_capacity_msat); + let (_htlc_success_tx_fee_sat, htlc_timeout_tx_fee_sat) = + second_stage_tx_fees_sat(&channel_type, FEERATE); + let expected_available_capacity_msat = + (NODE_1_DUST_LIMIT_MSAT / 1000 + htlc_timeout_tx_fee_sat) * 1000 - 1; + assert_eq!(node_1_details.next_outbound_htlc_limit_msat, expected_available_capacity_msat); + let expected_splice_out_max = NODE_1_VALUE_TO_SELF_MSAT / 1000 + node_0_payment_msat / 1000 + - 3 * SNEAKY_HTLC_MSAT / 1000 + - NODE_1_DUST_LIMIT_MSAT / 1000; + assert_eq!(node_1_details.next_splice_out_maximum_sat, expected_splice_out_max); +} + +#[xtest(feature = "_externalize_tests")] +fn test_available_balances_both_commitments_dust_on_fundee_commitment() { + let mut config = test_default_channel_config(); + + let chanmon_cfgs = create_chanmon_cfgs(2); + let node_cfgs = create_node_cfgs(2, &chanmon_cfgs); + config.channel_handshake_config.announced_channel_max_inbound_htlc_value_in_flight_percentage = + 100; + + let channel_type = ChannelTypeFeatures::anchors_zero_htlc_fee_and_dependencies(); + + let node_chanmgrs = create_node_chanmgrs(2, &node_cfgs, &[Some(config.clone()), Some(config)]); + let nodes = create_network(2, &node_cfgs, &node_chanmgrs); + + const FEERATE: u32 = 253; + const TOTAL_ANCHORS_MSAT: u64 = 2 * 330_000; + const NODE_0_DUST_LIMIT_MSAT: u64 = 354 * 1000; + const NODE_1_DUST_LIMIT_MSAT: u64 = 10_000 * 1000; + const CHANNEL_VALUE_MSAT: u64 = 50_000 * 1000; + const NODE_0_VALUE_TO_SELF_MSAT: u64 = 25_000 * 1000; + const NODE_1_VALUE_TO_SELF_MSAT: u64 = 25_000 * 1000; + const NODE_0_SELECTED_CHANNEL_RESERVE_MSAT: u64 = 10_000 * 1_000; + const NODE_1_SELECTED_CHANNEL_RESERVE_MSAT: u64 = 1_000 * 1_000; + + let channel_id = create_announced_chan_between_nodes_with_value( + &nodes, + 0, + 1, + CHANNEL_VALUE_MSAT / 1000, + NODE_1_VALUE_TO_SELF_MSAT, + ) + .2; + assert_eq!(nodes[0].node.list_channels()[0].channel_type.as_ref().unwrap(), &channel_type); + + { + let per_peer_state_lock; + let mut peer_state_lock; + let chan = + get_channel_ref!(nodes[0], nodes[1], per_peer_state_lock, peer_state_lock, channel_id); + chan.context_mut().counterparty_dust_limit_satoshis = NODE_1_DUST_LIMIT_MSAT / 1000; + chan.funding_mut().holder_selected_channel_reserve_satoshis = + NODE_0_SELECTED_CHANNEL_RESERVE_MSAT / 1000; + assert_eq!(chan.context().holder_dust_limit_satoshis, NODE_0_DUST_LIMIT_MSAT / 1000); + assert_eq!( + chan.funding().counterparty_selected_channel_reserve_satoshis, + Some(NODE_1_SELECTED_CHANNEL_RESERVE_MSAT / 1000) + ); + } + + { + let per_peer_state_lock; + let mut peer_state_lock; + let chan = + get_channel_ref!(nodes[1], nodes[0], per_peer_state_lock, peer_state_lock, channel_id); + chan.context_mut().holder_dust_limit_satoshis = NODE_1_DUST_LIMIT_MSAT / 1000; + chan.funding_mut().counterparty_selected_channel_reserve_satoshis = + Some(NODE_0_SELECTED_CHANNEL_RESERVE_MSAT / 1000); + assert_eq!(chan.context().counterparty_dust_limit_satoshis, NODE_0_DUST_LIMIT_MSAT / 1000); + assert_eq!( + chan.funding().holder_selected_channel_reserve_satoshis, + NODE_1_SELECTED_CHANNEL_RESERVE_MSAT / 1000 + ); + } + + // This HTLC is only present on node 0's commitment + const SNEAKY_HTLC_MSAT: u64 = 5_000_000; + + route_payment(&nodes[1], &[&nodes[0]], SNEAKY_HTLC_MSAT); + + let node_1_details = &nodes[1].node.list_channels()[0]; + let expected_outbound_capacity_msat = + NODE_1_VALUE_TO_SELF_MSAT - SNEAKY_HTLC_MSAT - NODE_0_SELECTED_CHANNEL_RESERVE_MSAT; + assert_eq!(node_1_details.outbound_capacity_msat, expected_outbound_capacity_msat); + let expected_available_capacity_msat = expected_outbound_capacity_msat; + assert_eq!(node_1_details.next_outbound_htlc_limit_msat, expected_available_capacity_msat); + let expected_splice_out_max = + NODE_1_VALUE_TO_SELF_MSAT / 1000 - SNEAKY_HTLC_MSAT / 1000 - NODE_1_DUST_LIMIT_MSAT / 1000; + assert_eq!(node_1_details.next_splice_out_maximum_sat, expected_splice_out_max); + + let node_0_details = &nodes[0].node.list_channels()[0]; + + let expected_outbound_capacity_msat = + NODE_0_VALUE_TO_SELF_MSAT - NODE_1_SELECTED_CHANNEL_RESERVE_MSAT - TOTAL_ANCHORS_MSAT; + assert_eq!(node_0_details.outbound_capacity_msat, expected_outbound_capacity_msat); + + let expected_splice_out_max = NODE_0_VALUE_TO_SELF_MSAT / 1000 + - TOTAL_ANCHORS_MSAT / 1000 + - commit_tx_fee_sat(FEERATE, 2, &channel_type) + - NODE_0_DUST_LIMIT_MSAT / 1000; + assert_eq!(node_0_details.next_splice_out_maximum_sat, expected_splice_out_max); + + let expected_available_capacity_msat = + expected_outbound_capacity_msat - commit_tx_fee_sat(FEERATE, 3, &channel_type) * 1000; + assert_eq!(node_0_details.next_outbound_htlc_limit_msat, expected_available_capacity_msat); + + let node_0_payment_msat = expected_available_capacity_msat; + send_payment(&nodes[0], &[&nodes[1]], node_0_payment_msat); + + route_payment(&nodes[1], &[&nodes[0]], SNEAKY_HTLC_MSAT); + route_payment(&nodes[1], &[&nodes[0]], SNEAKY_HTLC_MSAT); + + let node_0_details = &nodes[0].node.list_channels()[0]; + let expected_outbound_capacity_msat = NODE_0_VALUE_TO_SELF_MSAT + - node_0_payment_msat + - NODE_1_SELECTED_CHANNEL_RESERVE_MSAT + - TOTAL_ANCHORS_MSAT; + assert_eq!(node_0_details.outbound_capacity_msat, expected_outbound_capacity_msat); + assert_eq!( + node_0_details.outbound_capacity_msat, + commit_tx_fee_sat(FEERATE, 3, &channel_type) * 1000 + ); + assert_eq!(node_0_details.next_outbound_htlc_limit_msat, 0); + + let local_balance_before_fee_sat = + NODE_0_VALUE_TO_SELF_MSAT / 1000 - node_0_payment_msat / 1000 - TOTAL_ANCHORS_MSAT / 1000; + let post_splice_delta_above_reserve = commit_tx_fee_sat(FEERATE, 4, &channel_type); + let divident_sat = local_balance_before_fee_sat * 100 + 100 + - (post_splice_delta_above_reserve * 100) + - CHANNEL_VALUE_MSAT / 1000; + let expected_splice_out_max = (divident_sat - 1) / 99; + assert_eq!(node_0_details.next_splice_out_maximum_sat, expected_splice_out_max); + + let node_1_details = &nodes[1].node.list_channels()[0]; + let expected_outbound_capacity_msat = NODE_1_VALUE_TO_SELF_MSAT + node_0_payment_msat + - 3 * SNEAKY_HTLC_MSAT + - NODE_0_SELECTED_CHANNEL_RESERVE_MSAT; + assert_eq!(node_1_details.outbound_capacity_msat, expected_outbound_capacity_msat); + let (htlc_success_tx_fee_sat, _htlc_timeout_tx_fee_sat) = + second_stage_tx_fees_sat(&channel_type, FEERATE); + let expected_available_capacity_msat = + (NODE_0_DUST_LIMIT_MSAT / 1000 + htlc_success_tx_fee_sat) * 1000 - 1; + assert_eq!(node_1_details.next_outbound_htlc_limit_msat, expected_available_capacity_msat); + let expected_splice_out_max = NODE_1_VALUE_TO_SELF_MSAT / 1000 + node_0_payment_msat / 1000 + - 3 * SNEAKY_HTLC_MSAT / 1000 + - NODE_1_DUST_LIMIT_MSAT / 1000; + assert_eq!(node_1_details.next_splice_out_maximum_sat, expected_splice_out_max); +} diff --git a/lightning/src/sign/tx_builder.rs b/lightning/src/sign/tx_builder.rs index 746f6d32b2e..8f699fc85aa 100644 --- a/lightning/src/sign/tx_builder.rs +++ b/lightning/src/sign/tx_builder.rs @@ -482,82 +482,87 @@ fn get_next_splice_out_maximum_sat( next_splice_out_maximum_sat } -fn adjust_capacity_for_holder_reserved_fee(mut available_capacity_msat: u64, - local_nondust_htlc_count: usize, feerate_per_kw: u32, spiked_feerate: u32, - channel_constraints: &ChannelConstraints, channel_type: &ChannelTypeFeatures, +fn adjust_capacity_for_holder_reserved_fee( + outbound_capacity_msat: u64, local_nondust_htlc_count: usize, remote_nondust_htlc_count: usize, + feerate_per_kw: u32, spiked_feerate: u32, channel_constraints: &ChannelConstraints, + channel_type: &ChannelTypeFeatures, ) -> u64 { - let (_real_htlc_success_tx_fee_sat, real_htlc_timeout_tx_fee_sat) = + let read_available_capacity = |nondust_htlc_count, htlc_dust_limit_sat| { + // Note here we use the htlc count at the current feerate together with the spiked feerate; + // this makes sure that the holder can afford any fee bump between 1x to 2x from the current + // feerate. + let max_commit_tx_fee_sat = + commit_tx_fee_sat(spiked_feerate, nondust_htlc_count + 2, channel_type); + let min_commit_tx_fee_sat = + commit_tx_fee_sat(spiked_feerate, nondust_htlc_count + 1, channel_type); + + // We should mind channel commit tx fee when computing how much of the available capacity + // can be used in the next htlc. Mirrors the logic in send_htlc. + // + // The fee depends on whether the amount we will be sending is above dust or not, + // and the answer will in turn change the amount itself — making it a circular + // dependency. + // This complicates the computation around dust-values, up to the one-htlc-value. + + // We will first subtract the fee as if we were above-dust. Then, if the resulting + // value ends up being below dust, we have this fee available again. In that case, + // match the value to right-below-dust. + let capacity_minus_max_commitment_fee_msat = + outbound_capacity_msat.saturating_sub(max_commit_tx_fee_sat * 1000); + if capacity_minus_max_commitment_fee_msat < htlc_dust_limit_sat * 1000 { + let capacity_minus_min_commitment_fee_msat = + outbound_capacity_msat.saturating_sub(min_commit_tx_fee_sat * 1000); + cmp::min(htlc_dust_limit_sat * 1000 - 1, capacity_minus_min_commitment_fee_msat) + } else { + capacity_minus_max_commitment_fee_msat + } + }; + + let (real_htlc_success_tx_fee_sat, real_htlc_timeout_tx_fee_sat) = second_stage_tx_fees_sat(channel_type, feerate_per_kw); - let fee_spike_buffer_htlc = 1; - // Note here we use the htlc count at the current feerate together with the spiked feerate; - // this makes sure that the holder can afford any fee bump between 1x to 2x from the current - // feerate. - let local_max_commit_tx_fee_sat = commit_tx_fee_sat( - spiked_feerate, - local_nondust_htlc_count + fee_spike_buffer_htlc + 1, - channel_type, + let available_capacity_on_local_commitment = read_available_capacity( + local_nondust_htlc_count, + channel_constraints.holder_dust_limit_satoshis + real_htlc_timeout_tx_fee_sat, ); - let local_min_commit_tx_fee_sat = commit_tx_fee_sat( - spiked_feerate, - local_nondust_htlc_count + fee_spike_buffer_htlc, - channel_type, + let available_capacity_on_remote_commitment = read_available_capacity( + remote_nondust_htlc_count, + channel_constraints.counterparty_dust_limit_satoshis + real_htlc_success_tx_fee_sat, ); - // We should mind channel commit tx fee when computing how much of the available capacity - // can be used in the next htlc. Mirrors the logic in send_htlc. - // - // The fee depends on whether the amount we will be sending is above dust or not, - // and the answer will in turn change the amount itself — making it a circular - // dependency. - // This complicates the computation around dust-values, up to the one-htlc-value. - - let real_dust_limit_timeout_sat = - real_htlc_timeout_tx_fee_sat + channel_constraints.holder_dust_limit_satoshis; - let max_reserved_commit_tx_fee_msat = local_max_commit_tx_fee_sat * 1000; - let min_reserved_commit_tx_fee_msat = local_min_commit_tx_fee_sat * 1000; - - // We will first subtract the fee as if we were above-dust. Then, if the resulting - // value ends up being below dust, we have this fee available again. In that case, - // match the value to right-below-dust. - let capacity_minus_max_commitment_fee_msat = - available_capacity_msat.saturating_sub(max_reserved_commit_tx_fee_msat); - if capacity_minus_max_commitment_fee_msat < real_dust_limit_timeout_sat * 1000 { - let capacity_minus_min_commitment_fee_msat = - available_capacity_msat.saturating_sub(min_reserved_commit_tx_fee_msat); - available_capacity_msat = cmp::min( - real_dust_limit_timeout_sat * 1000 - 1, - capacity_minus_min_commitment_fee_msat, - ); - } else { - available_capacity_msat = capacity_minus_max_commitment_fee_msat; - } - available_capacity_msat + cmp::min(available_capacity_on_local_commitment, available_capacity_on_remote_commitment) } -fn adjust_capacity_for_counterparty_reserved_fee(mut available_capacity_msat: u64, - remote_balance_before_fee_msat: u64, remote_nondust_htlc_count: usize, feerate_per_kw: u32, - channel_constraints: &ChannelConstraints, channel_type: &ChannelTypeFeatures +fn adjust_capacity_for_counterparty_reserved_fee( + outbound_capacity_msat: u64, remote_balance_before_fee_msat: u64, + local_nondust_htlc_count: usize, remote_nondust_htlc_count: usize, feerate_per_kw: u32, + channel_constraints: &ChannelConstraints, channel_type: &ChannelTypeFeatures, ) -> u64 { - let (real_htlc_success_tx_fee_sat, _real_htlc_timeout_tx_fee_sat) = + let read_available_capacity = |nondust_htlc_count, htlc_dust_limit_sat| { + let commit_tx_fee_sat = + commit_tx_fee_sat(feerate_per_kw, nondust_htlc_count + 1, channel_type); + // If the channel is inbound (i.e. counterparty pays the fee), we need to make sure + // sending a new HTLC won't reduce their balance below our reserve threshold. + if remote_balance_before_fee_msat + < commit_tx_fee_sat * 1000 + + channel_constraints.holder_selected_channel_reserve_satoshis * 1000 + { + // If another HTLC's fee would reduce the remote's balance below the reserve limit + // we've selected for them, we can only send dust HTLCs. + cmp::min(outbound_capacity_msat, htlc_dust_limit_sat * 1000 - 1) + } else { + outbound_capacity_msat + } + }; + let (real_htlc_success_tx_fee_sat, real_htlc_timeout_tx_fee_sat) = second_stage_tx_fees_sat(channel_type, feerate_per_kw); - let remote_commit_tx_fee_sat = - commit_tx_fee_sat(feerate_per_kw, remote_nondust_htlc_count + 1, channel_type); - // If the channel is inbound (i.e. counterparty pays the fee), we need to make sure - // sending a new HTLC won't reduce their balance below our reserve threshold. - let real_dust_limit_success_sat = - real_htlc_success_tx_fee_sat + channel_constraints.counterparty_dust_limit_satoshis; - let max_reserved_commit_tx_fee_msat = remote_commit_tx_fee_sat * 1000; - - let holder_selected_chan_reserve_msat = - channel_constraints.holder_selected_channel_reserve_satoshis * 1000; - if remote_balance_before_fee_msat - < max_reserved_commit_tx_fee_msat + holder_selected_chan_reserve_msat - { - // If another HTLC's fee would reduce the remote's balance below the reserve limit - // we've selected for them, we can only send dust HTLCs. - available_capacity_msat = - cmp::min(available_capacity_msat, real_dust_limit_success_sat * 1000 - 1); - } - available_capacity_msat + let available_capacity_on_local_commitment = read_available_capacity( + local_nondust_htlc_count, + channel_constraints.holder_dust_limit_satoshis + real_htlc_timeout_tx_fee_sat, + ); + let available_capacity_on_remote_commitment = read_available_capacity( + remote_nondust_htlc_count, + channel_constraints.counterparty_dust_limit_satoshis + real_htlc_success_tx_fee_sat, + ); + cmp::min(available_capacity_on_local_commitment, available_capacity_on_remote_commitment) } fn adjust_min_max_htlc_for_dust_exposure( @@ -726,23 +731,27 @@ fn get_available_balances( let outbound_capacity_msat = local_balance_before_fee_msat .saturating_sub(channel_constraints.counterparty_selected_channel_reserve_satoshis * 1000); - let mut available_capacity_msat = outbound_capacity_msat; - - if is_outbound_from_holder { - available_capacity_msat = adjust_capacity_for_holder_reserved_fee( - available_capacity_msat, local_nondust_htlc_count, feerate_per_kw, - spiked_feerate, &channel_constraints, channel_type - ); + let available_capacity_msat = if is_outbound_from_holder { + adjust_capacity_for_holder_reserved_fee( + outbound_capacity_msat, + local_nondust_htlc_count, + remote_nondust_htlc_count, + feerate_per_kw, + spiked_feerate, + &channel_constraints, + channel_type, + ) } else { - available_capacity_msat = adjust_capacity_for_counterparty_reserved_fee( - available_capacity_msat, + adjust_capacity_for_counterparty_reserved_fee( + outbound_capacity_msat, remote_balance_before_fee_msat, + local_nondust_htlc_count, remote_nondust_htlc_count, feerate_per_kw, &channel_constraints, - channel_type + channel_type, ) - } + }; let (next_outbound_htlc_minimum_msat, mut available_capacity_msat, dust_exposure_msat) = adjust_min_max_htlc_for_dust_exposure(