diff --git a/fuzz/src/router.rs b/fuzz/src/router.rs index 2e5b15fc7f4..5bf9650ebad 100644 --- a/fuzz/src/router.rs +++ b/fuzz/src/router.rs @@ -248,6 +248,7 @@ pub fn do_test(data: &[u8], out: Out) { outbound_capacity_msat: capacity.saturating_mul(1000), next_outbound_htlc_limit_msat: capacity.saturating_mul(1000), next_outbound_htlc_minimum_msat: 0, + next_splice_out_maximum_sat: capacity, inbound_htlc_minimum_msat: None, inbound_htlc_maximum_msat: None, config: None, diff --git a/lightning/src/ln/channel.rs b/lightning/src/ln/channel.rs index 32c0e94bdc8..76acfc5cc22 100644 --- a/lightning/src/ln/channel.rs +++ b/lightning/src/ln/channel.rs @@ -122,6 +122,8 @@ pub struct AvailableBalances { pub next_outbound_htlc_limit_msat: u64, /// The minimum value we can assign to the next outbound HTLC pub next_outbound_htlc_minimum_msat: u64, + /// The maximum value of the next splice-out + pub next_splice_out_maximum_sat: u64, } #[derive(Debug, Clone, Copy, PartialEq)] @@ -5259,6 +5261,107 @@ impl ChannelContext { } } + /// The balances returned here should only be used to check that both parties still hold + /// their respective reserves *after* a splice. This function also checks that both local + /// and remote commitments still have at least one output after the splice, which is + /// particularly relevant for zero-reserve channels. + /// + /// Do NOT use this to determine how much the holder can splice out of the channel. The balance + /// of the holder after a splice is not necessarily equal to the funds they can splice out + /// of the channel due to the reserve, and the zero-reserve-at-least-one-output + /// requirements. Note you cannot simply subtract out the reserve, as splicing funds out + /// of the channel changes the reserve the holder must keep in the channel. + /// + /// See [`FundedChannel::get_next_splice_out_maximum`] for the maximum value of the next + /// splice out of the holder's balance. + fn get_post_splice_holder_counterparty_balances( + &self, funding: &FundingScope, our_contribution_candidate: SignedAmount, + their_contribution_candidate: SignedAmount, addl_nondust_htlc_count: usize, + feerate_per_kw: u32, + ) -> Result<(Amount, Amount), String> { + let htlc_candidate = None; + // There shouldn't be any pending HTLC adds at this point. We nonetheless choose to + // take the remote's view of the pending HTLCs to make this function consistent + // with `FundedChannel::get_next_splice_out_maximum`. In debug mode, we check that + // these two functions are consistent *before* clearing any HTLC updates from the + // channel, see `FundedChannel::funding_contributed`. + let include_counterparty_unknown_htlcs = true; + let post_splice_channel_value_satoshis = funding.compute_post_splice_value( + our_contribution_candidate.to_sat(), + their_contribution_candidate.to_sat(), + ); + let value_to_self_msat = self.get_next_commitment_value_to_self_msat( + false, // Take the remote's view of the pending HTLCs + funding, + ); + let our_contribution_candidate_msat = our_contribution_candidate.to_sat() * 1000; + let post_splice_value_to_self_msat = value_to_self_msat + .checked_add_signed(our_contribution_candidate_msat) + .ok_or(format!( + "Our contribution candidate {our_contribution_candidate_msat}msat is \ + greater than our total balance in the channel {value_to_self_msat}msat" + ))?; + + let commitment_htlcs = self.get_next_commitment_htlcs( + false, // Take the remote's view of the pending HTLCs + htlc_candidate, + include_counterparty_unknown_htlcs, + ); + + // We are not interested in dust exposure at this point, so use the 250sat/kw default + let max_dust_htlc_exposure_msat = self.get_max_dust_htlc_exposure_msat(None); + // Again, not interested in dust exposure, so no need to pass this feerate + let dust_exposure_limiting_feerate = None; + + let channel_constraints = self.get_channel_constraints(funding); + + let get_channel_stats = |local: bool| { + SpecTxBuilder {}.get_channel_stats( + local, + funding.is_outbound(), + post_splice_channel_value_satoshis, + post_splice_value_to_self_msat, + &commitment_htlcs, + addl_nondust_htlc_count, + feerate_per_kw, + dust_exposure_limiting_feerate, + max_dust_htlc_exposure_msat, + channel_constraints, + funding.get_channel_type(), + ) + }; + + // Different dust limits on the local and remote commitments cause the commitment + // transaction fee to be different depending on the commitment, so we grab the floor + // of both balances across both commitments here. + // + // `get_channel_stats` also checks for at least one output on the commitment given + // these parameters. This is particularly relevant for zero-reserve channels. + // + // This "at-least-one-output" check is why we still run both checks on + // zero-fee-commitment channels, even though those channels don't suffer from the + // commitment transaction fee asymmetry. + let local_stats = + get_channel_stats(true).map_err(|()| "Balance exhausted on local commitment")?; + let remote_stats = + get_channel_stats(false).map_err(|()| "Balance exhausted on remote commitment")?; + + let holder_balance_floor = Amount::from_sat( + cmp::min( + local_stats.commitment_stats.holder_balance_msat, + remote_stats.commitment_stats.holder_balance_msat, + ) / 1000, + ); + let counterparty_balance_floor = Amount::from_sat( + cmp::min( + local_stats.commitment_stats.counterparty_balance_msat, + remote_stats.commitment_stats.counterparty_balance_msat, + ) / 1000, + ); + + Ok((holder_balance_floor, counterparty_balance_floor)) + } + fn get_next_local_commitment_stats( &self, funding: &FundingScope, htlc_candidate: Option, include_counterparty_unknown_htlcs: bool, addl_nondust_htlc_count: usize, @@ -6758,7 +6861,7 @@ pub(crate) fn get_legacy_default_holder_selected_channel_reserve_satoshis( /// /// This is used both for outbound and inbound channels and has lower bound /// of `dust_limit_satoshis`. -fn get_v2_channel_reserve_satoshis( +pub(crate) fn get_v2_channel_reserve_satoshis( channel_value_satoshis: u64, dust_limit_satoshis: u64, is_0reserve: bool, ) -> u64 { if is_0reserve { @@ -12354,10 +12457,7 @@ where "build_prior_contribution requires pending_splice" ); let prior = self.pending_splice.as_ref()?.contributions.last()?; - let holder_balance = self - .get_holder_counterparty_balances_floor_incl_fee(&self.funding) - .map(|(h, _)| h) - .ok(); + let holder_balance = self.get_next_splice_out_maximum(&self.funding).ok(); Some(PriorContribution::new(prior.clone(), holder_balance)) } @@ -12432,10 +12532,7 @@ where return contribution; } - let holder_balance = match self - .get_holder_counterparty_balances_floor_incl_fee(&self.funding) - .map(|(holder, _)| holder) - { + let holder_balance = match self.get_next_splice_out_maximum(&self.funding) { Ok(balance) => balance, Err(_) => return contribution, }; @@ -12536,7 +12633,13 @@ where // For splice-out, our_funding_contribution is adjusted to cover fees if there // aren't any inputs. let our_funding_contribution = contribution.net_value(); - self.validate_splice_contributions(our_funding_contribution, SignedAmount::ZERO) + let next_splice_out_maximum = self.get_next_splice_out_maximum(&self.funding)?; + if our_funding_contribution.is_negative() && our_funding_contribution.unsigned_abs() > next_splice_out_maximum { + let unsigned_contribution = our_funding_contribution.unsigned_abs(); + Err(format!("Our splice-out value of {unsigned_contribution} is greater than the maximum {next_splice_out_maximum}")) + } else { + Ok(()) + } }) { log_error!(logger, "Channel {} cannot be funded: {}", self.context.channel_id(), e); @@ -12772,11 +12875,6 @@ where )); } - let (holder_balance_remaining, counterparty_balance_remaining) = - self.get_holder_counterparty_balances_floor_incl_fee(&self.funding).map_err(|e| { - format!("Channel {} cannot be spliced; {}", self.context.channel_id(), e) - })?; - let post_channel_value = self.funding.compute_post_splice_value( our_funding_contribution.to_sat(), their_funding_contribution.to_sat(), @@ -12796,20 +12894,35 @@ where self.funding.holder_selected_channel_reserve_satoshis == 0, )); - // We allow parties to draw from their previous reserve, as long as they satisfy their v2 reserve + // At all times, require that the channel funder have the fees for an additional + // inbound HTLC on the channel. + let addl_nondust_htlc_count = 1; + // Note that the feerate is 0 in zero-fee commitment channels, so this statement is a noop + let feerate_per_kw = if !self.funding.get_channel_type().supports_anchors_zero_fee_htlc_tx() + { + // Similar to HTLC-add validation, 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 + }; - if our_funding_contribution != SignedAmount::ZERO { - let post_splice_holder_balance = Amount::from_sat( - holder_balance_remaining.to_sat() - .checked_add_signed(our_funding_contribution.to_sat()) - .ok_or(format!( - "Channel {} cannot be spliced out; our remaining balance {} does not cover our negative funding contribution {}", - self.context.channel_id(), - holder_balance_remaining, - our_funding_contribution, - ))?, - ); + let (post_splice_holder_balance, post_splice_counterparty_balance) = self + .context + .get_post_splice_holder_counterparty_balances( + &self.funding, + our_funding_contribution, + their_funding_contribution, + addl_nondust_htlc_count, + feerate_per_kw, + ) + .map_err(|e| { + format!("Channel {} cannot be spliced; {}", self.context.channel_id(), e) + })?; + // We allow parties to draw from their previous reserve, as long as they satisfy their v2 reserve + if our_funding_contribution != SignedAmount::ZERO { post_splice_holder_balance.checked_sub(counterparty_selected_channel_reserve) .ok_or(format!( "Channel {} cannot be {}; our post-splice channel balance {} is smaller than their selected v2 reserve {}", @@ -12821,17 +12934,6 @@ where } if their_funding_contribution != SignedAmount::ZERO { - let post_splice_counterparty_balance = Amount::from_sat( - counterparty_balance_remaining.to_sat() - .checked_add_signed(their_funding_contribution.to_sat()) - .ok_or(format!( - "Channel {} cannot be spliced out; their remaining balance {} does not cover their negative funding contribution {}", - self.context.channel_id(), - counterparty_balance_remaining, - their_funding_contribution, - ))?, - ); - post_splice_counterparty_balance.checked_sub(holder_selected_channel_reserve) .ok_or(format!( "Channel {} cannot be {}; their post-splice channel balance {} is smaller than our selected v2 reserve {}", @@ -12849,8 +12951,7 @@ where &self, feerate: FeeRate, logger: &L, ) -> Result<(Option, Option), ChannelError> { let holder_balance = self - .get_holder_counterparty_balances_floor_incl_fee(&self.funding) - .map(|(holder, _)| holder) + .get_next_splice_out_maximum(&self.funding) .map_err(|e| { log_info!( logger, @@ -13282,63 +13383,49 @@ where )) } - fn get_holder_counterparty_balances_floor_incl_fee( - &self, funding: &FundingScope, - ) -> Result<(Amount, Amount), String> { + /// Determines the maximum value that the holder can splice out of the channel, accounting + /// for the updated reserves after said splice. This maximum also makes sure the local + /// commitment retains at least one output after the splice, which is particularly relevant + /// for zero-reserve channels. + fn get_next_splice_out_maximum(&self, funding: &FundingScope) -> Result { 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; - // Note that the feerate is 0 in zero-fee commitment channels, so this statement is a noop - 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( - funding, - None, // htlc_candidate - include_counterparty_unknown_htlcs, - addl_nondust_htlc_count, - feerate_per_kw, - dust_exposure_limiting_feerate, - ) - .map_err(|()| "Balance exhausted on local commitment")?; - + // When reading the available balances, we take the remote's view of the pending + // HTLCs, see `tx_builder` for further details let (remote_stats, _remote_htlcs) = self .context .get_next_remote_commitment_stats( funding, None, // htlc_candidate include_counterparty_unknown_htlcs, - addl_nondust_htlc_count, - feerate_per_kw, + 0, + self.context.feerate_per_kw, dust_exposure_limiting_feerate, ) .map_err(|()| "Balance exhausted on remote commitment")?; - let holder_balance_floor = Amount::from_sat( - cmp::min( - local_stats.commitment_stats.holder_balance_msat, - remote_stats.commitment_stats.holder_balance_msat, - ) / 1000, - ); - let counterparty_balance_floor = Amount::from_sat( - cmp::min( - local_stats.commitment_stats.counterparty_balance_msat, - remote_stats.commitment_stats.counterparty_balance_msat, - ) / 1000, - ); + let next_splice_out_maximum_sat = + remote_stats.available_balances.next_splice_out_maximum_sat; - Ok((holder_balance_floor, counterparty_balance_floor)) + #[cfg(debug_assertions)] + { + // After this max splice out, validation passes, accounting for the updated reserves + self.validate_splice_contributions( + SignedAmount::from_sat(-(next_splice_out_maximum_sat as i64)), + SignedAmount::ZERO, + ) + .unwrap(); + // Splice-out an additional satoshi, and validation fails! + self.validate_splice_contributions( + SignedAmount::from_sat(-((next_splice_out_maximum_sat + 1) as i64)), + SignedAmount::ZERO, + ) + .unwrap_err(); + } + + Ok(Amount::from_sat(next_splice_out_maximum_sat)) } pub fn splice_locked( @@ -13566,6 +13653,9 @@ where next_outbound_htlc_minimum_msat: acc .next_outbound_htlc_minimum_msat .max(e.next_outbound_htlc_minimum_msat), + next_splice_out_maximum_sat: acc + .next_splice_out_maximum_sat + .min(e.next_splice_out_maximum_sat), }) }) } diff --git a/lightning/src/ln/channel_state.rs b/lightning/src/ln/channel_state.rs index 5547bee8f4c..9fd0df4a1bf 100644 --- a/lightning/src/ln/channel_state.rs +++ b/lightning/src/ln/channel_state.rs @@ -399,6 +399,8 @@ pub struct ChannelDetails { /// an upper-bound. This is intended for use when routing, allowing us to ensure we pick a /// route which is valid. pub next_outbound_htlc_minimum_msat: u64, + /// The maximum value of the next splice out from our channel balance. + pub next_splice_out_maximum_sat: u64, /// The available inbound capacity for the remote peer to send HTLCs to us. This does not /// include any pending HTLCs which are not yet fully resolved (and, thus, whose balance is not /// available for inclusion in new inbound HTLCs). @@ -533,6 +535,7 @@ impl ChannelDetails { outbound_capacity_msat: 0, next_outbound_htlc_limit_msat: 0, next_outbound_htlc_minimum_msat: u64::MAX, + next_splice_out_maximum_sat: 0, } }); let (to_remote_reserve_satoshis, to_self_reserve_satoshis) = @@ -582,6 +585,7 @@ impl ChannelDetails { outbound_capacity_msat: balance.outbound_capacity_msat, next_outbound_htlc_limit_msat: balance.next_outbound_htlc_limit_msat, next_outbound_htlc_minimum_msat: balance.next_outbound_htlc_minimum_msat, + next_splice_out_maximum_sat: balance.next_splice_out_maximum_sat, user_channel_id: context.get_user_id(), confirmations_required: channel.minimum_depth(), confirmations: Some(funding.get_funding_tx_confirmations(best_block_height)), @@ -621,6 +625,7 @@ impl_writeable_tlv_based!(ChannelDetails, { (20, inbound_capacity_msat, required), (21, next_outbound_htlc_minimum_msat, (default_value, 0)), (22, confirmations_required, option), + (23, next_splice_out_maximum_sat, (default_value, u64::from(outbound_capacity_msat.0.unwrap()) / 1000)), (24, force_close_spend_delay, option), (26, is_outbound, required), (28, is_channel_ready, required), @@ -725,6 +730,7 @@ mod tests { outbound_capacity_msat: 24_300, next_outbound_htlc_limit_msat: 20_000, next_outbound_htlc_minimum_msat: 132, + next_splice_out_maximum_sat: 20, inbound_capacity_msat: 42, unspendable_punishment_reserve: Some(8273), confirmations_required: Some(5), diff --git a/lightning/src/ln/channelmanager.rs b/lightning/src/ln/channelmanager.rs index 660875400c7..283950eb216 100644 --- a/lightning/src/ln/channelmanager.rs +++ b/lightning/src/ln/channelmanager.rs @@ -8026,6 +8026,7 @@ impl< outbound_capacity_msat: 0, next_outbound_htlc_limit_msat: 0, next_outbound_htlc_minimum_msat: u64::MAX, + next_splice_out_maximum_sat: 0, } }); let is_in_range = (balances.next_outbound_htlc_minimum_msat diff --git a/lightning/src/ln/htlc_reserve_unit_tests.rs b/lightning/src/ln/htlc_reserve_unit_tests.rs index aaf81b87be7..45d3cf5950f 100644 --- a/lightning/src/ln/htlc_reserve_unit_tests.rs +++ b/lightning/src/ln/htlc_reserve_unit_tests.rs @@ -2581,7 +2581,7 @@ fn test_0reserve_no_outputs() { do_test_0reserve_no_outputs_p2a_anchor(); } -fn setup_0reserve_no_outputs_channels<'a, 'b, 'c, 'd>( +pub(crate) fn setup_0reserve_no_outputs_channels<'a, 'b, 'c, 'd>( nodes: &'a Vec>, channel_value_sat: u64, dust_limit_satoshis: u64, ) -> (ChannelId, Transaction) { let node_a_id = nodes[0].node.get_our_node_id(); diff --git a/lightning/src/ln/splicing_tests.rs b/lightning/src/ln/splicing_tests.rs index 9adccd17627..a654cfa6cd6 100644 --- a/lightning/src/ln/splicing_tests.rs +++ b/lightning/src/ln/splicing_tests.rs @@ -16,7 +16,8 @@ use crate::chain::ChannelMonitorUpdateStatus; use crate::events::{ClosureReason, Event, FundingInfo, HTLCHandlingFailureType}; use crate::ln::chan_utils; use crate::ln::channel::{ - CHANNEL_ANNOUNCEMENT_PROPAGATION_DELAY, FEE_SPIKE_BUFFER_FEE_INCREASE_MULTIPLE, + ANCHOR_OUTPUT_VALUE_SATOSHI, 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::*; @@ -4155,8 +4156,8 @@ fn do_test_splice_pending_htlcs(config: UserConfig) { format!("Channel {} cannot accept funding contribution", channel_id); assert_eq!(error, APIError::APIMisuseError { err: cannot_accept_contribution }); let 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 + "Channel {} cannot be funded: Our splice-out value of {} is greater than the maximum {}", + channel_id, splice_out_incl_fees + Amount::ONE_SAT, splice_out_incl_fees, ); initiator.logger.assert_log("lightning::ln::channel", cannot_be_funded, 1); @@ -6647,3 +6648,202 @@ fn test_splice_rbf_rejects_own_low_feerate_after_several_attempts() { other => panic!("Expected SpliceFailed, got {:?}", other), } } + +#[test] +fn test_0reserve_splice() { + let mut config = test_default_channel_config(); + config.channel_handshake_config.negotiate_anchors_zero_fee_htlc_tx = false; + config.channel_handshake_config.negotiate_anchor_zero_fee_commitments = false; + let a = do_test_0reserve_splice_holder_validation(false, config.clone()); + let b = do_test_0reserve_splice_holder_validation(true, config.clone()); + assert_eq!(a, b); + assert_eq!(a, ChannelTypeFeatures::only_static_remote_key()); + + config.channel_handshake_config.negotiate_anchors_zero_fee_htlc_tx = true; + config.channel_handshake_config.negotiate_anchor_zero_fee_commitments = false; + let a = do_test_0reserve_splice_holder_validation(false, config.clone()); + let b = do_test_0reserve_splice_holder_validation(true, config.clone()); + assert_eq!(a, b); + assert_eq!(a, ChannelTypeFeatures::anchors_zero_htlc_fee_and_dependencies()); + + let mut config = test_default_channel_config(); + config.channel_handshake_config.negotiate_anchors_zero_fee_htlc_tx = false; + config.channel_handshake_config.negotiate_anchor_zero_fee_commitments = false; + let a = do_test_0reserve_splice_counterparty_validation(false, config.clone()); + let b = do_test_0reserve_splice_counterparty_validation(true, config.clone()); + assert_eq!(a, b); + assert_eq!(a, ChannelTypeFeatures::only_static_remote_key()); + + config.channel_handshake_config.negotiate_anchors_zero_fee_htlc_tx = true; + config.channel_handshake_config.negotiate_anchor_zero_fee_commitments = false; + let a = do_test_0reserve_splice_counterparty_validation(false, config.clone()); + let b = do_test_0reserve_splice_counterparty_validation(true, config.clone()); + assert_eq!(a, b); + assert_eq!(a, ChannelTypeFeatures::anchors_zero_htlc_fee_and_dependencies()); + + // TODO: Skip 0FC channels for now as these always have an output on the commitment, the P2A + // output. We will be able to withdraw up to the dust limit of the funding script, which + // is checked in interactivetx. Still need to double check whether that's what we actually + // want. +} + +#[cfg(test)] +fn do_test_0reserve_splice_holder_validation( + splice_passes: bool, config: UserConfig, +) -> ChannelTypeFeatures { + 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 node_chanmgrs = + create_node_chanmgrs(2, &node_cfgs, &[Some(config.clone()), Some(config.clone())]); + 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_value_sat = 100_000; + // Some dust limit, does not matter + let dust_limit_satoshis = 546; + + let (channel_id, _tx) = + setup_0reserve_no_outputs_channels(&nodes, channel_value_sat, dust_limit_satoshis); + let details = &nodes[0].node.list_channels()[0]; + let channel_type = details.channel_type.clone().unwrap(); + + let feerate = if channel_type == ChannelTypeFeatures::only_static_remote_key() { + 253 * FEE_SPIKE_BUFFER_FEE_INCREASE_MULTIPLE as u32 + } else if channel_type == ChannelTypeFeatures::anchors_zero_htlc_fee_and_dependencies() { + 253 + } else { + panic!("Unexpected channel type"); + }; + let commit_tx_fee_sat = chan_utils::commit_tx_fee_sat(feerate, 0, &channel_type); + let anchors_sat = + if channel_type == ChannelTypeFeatures::anchors_zero_htlc_fee_and_dependencies() { + ANCHOR_OUTPUT_VALUE_SATOSHI * 2 + } else { + 0 + }; + + let estimated_fees = 183; + let splice_out_max_value = Amount::from_sat( + channel_value_sat - commit_tx_fee_sat - anchors_sat - estimated_fees - dust_limit_satoshis, + ); + let outputs = vec![TxOut { + value: splice_out_max_value + if splice_passes { Amount::ZERO } else { Amount::ONE_SAT }, + script_pubkey: nodes[0].wallet_source.get_change_script().unwrap(), + }]; + + if splice_passes { + let contribution = initiate_splice_out(&nodes[0], &nodes[1], channel_id, outputs).unwrap(); + + let (splice_tx, _) = splice_channel(&nodes[0], &nodes[1], channel_id, contribution); + mine_transaction(&nodes[0], &splice_tx); + mine_transaction(&nodes[1], &splice_tx); + lock_splice_after_blocks(&nodes[0], &nodes[1], ANTI_REORG_DELAY - 1); + } else { + assert!(initiate_splice_out(&nodes[0], &nodes[1], channel_id, outputs).is_err()); + let splice_out_value = + splice_out_max_value + Amount::from_sat(estimated_fees) + Amount::ONE_SAT; + let splice_out_max_value = splice_out_max_value + Amount::from_sat(estimated_fees); + let cannot_be_funded = format!( + "Channel {channel_id} cannot be funded: Our \ + splice-out value of {splice_out_value} is greater than the maximum \ + {splice_out_max_value}" + ); + nodes[0].logger.assert_log("lightning::ln::channel", cannot_be_funded, 1); + } + + channel_type +} + +#[cfg(test)] +fn do_test_0reserve_splice_counterparty_validation( + splice_passes: bool, config: UserConfig, +) -> ChannelTypeFeatures { + 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 node_chanmgrs = + create_node_chanmgrs(2, &node_cfgs, &[Some(config.clone()), Some(config.clone())]); + 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_value_sat = 100_000; + // Some dust limit, does not matter + let dust_limit_satoshis = 546; + + let (channel_id, _tx) = + setup_0reserve_no_outputs_channels(&nodes, channel_value_sat, dust_limit_satoshis); + let details = &nodes[0].node.list_channels()[0]; + let channel_type = details.channel_type.clone().unwrap(); + + let feerate = if channel_type == ChannelTypeFeatures::only_static_remote_key() { + 253 * FEE_SPIKE_BUFFER_FEE_INCREASE_MULTIPLE as u32 + } else if channel_type == ChannelTypeFeatures::anchors_zero_htlc_fee_and_dependencies() { + 253 + } else { + panic!("Unexpected channel type"); + }; + let commit_tx_fee_sat = chan_utils::commit_tx_fee_sat(feerate, 0, &channel_type); + let anchors_sat = + if channel_type == ChannelTypeFeatures::anchors_zero_htlc_fee_and_dependencies() { + ANCHOR_OUTPUT_VALUE_SATOSHI * 2 + } else { + 0 + }; + + let splice_out_value_incl_fees = + Amount::from_sat(channel_value_sat - commit_tx_fee_sat - anchors_sat - dust_limit_satoshis); + + let funding_contribution_sat = + -(splice_out_value_incl_fees.to_sat() as i64) - if splice_passes { 0 } else { 1 }; + let outputs = vec![TxOut { + // Splice out some dummy amount to get past the initiator's validation, + // we'll modify the message in-flight. + 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, outputs).unwrap(); + + let initiator = &nodes[0]; + let acceptor = &nodes[1]; + let node_id_initiator = initiator.node.get_our_node_id(); + let node_id_acceptor = acceptor.node.get_our_node_id(); + + let stfu_init = get_event_msg!(initiator, MessageSendEvent::SendStfu, node_id_acceptor); + acceptor.node.handle_stfu(node_id_initiator, &stfu_init); + let stfu_ack = get_event_msg!(acceptor, MessageSendEvent::SendStfu, node_id_initiator); + initiator.node.handle_stfu(node_id_acceptor, &stfu_ack); + + let mut splice_init = + get_event_msg!(initiator, MessageSendEvent::SendSpliceInit, node_id_acceptor); + // Make the modification here + splice_init.funding_contribution_satoshis = funding_contribution_sat; + + if splice_passes { + acceptor.node.handle_splice_init(node_id_initiator, &splice_init); + let _splice_ack = + get_event_msg!(acceptor, MessageSendEvent::SendSpliceAck, node_id_initiator); + } else { + acceptor.node.handle_splice_init(node_id_initiator, &splice_init); + let msg_events = acceptor.node.get_and_clear_pending_msg_events(); + assert_eq!(msg_events.len(), 1); + if let MessageSendEvent::HandleError { action, .. } = &msg_events[0] { + assert!(matches!(action, msgs::ErrorAction::DisconnectPeerWithWarning { .. })); + } else { + panic!("Expected MessageSendEvent::HandleError"); + } + let cannot_splice_out = format!( + "Got non-closing error: Channel {channel_id} cannot \ + be spliced; Balance exhausted on local commitment" + ); + acceptor.logger.assert_log("lightning::ln::channelmanager", cannot_splice_out, 1); + } + + channel_type +} diff --git a/lightning/src/routing/router.rs b/lightning/src/routing/router.rs index 0c0d14b43fd..e2ec6228d8c 100644 --- a/lightning/src/routing/router.rs +++ b/lightning/src/routing/router.rs @@ -4150,6 +4150,7 @@ mod tests { outbound_capacity_msat, next_outbound_htlc_limit_msat: outbound_capacity_msat, next_outbound_htlc_minimum_msat: 0, + next_splice_out_maximum_sat: outbound_capacity_msat / 1000, inbound_capacity_msat: 42, unspendable_punishment_reserve: None, confirmations_required: None, @@ -9649,6 +9650,7 @@ pub(crate) mod bench_utils { outbound_capacity_msat: 10_000_000_000, next_outbound_htlc_minimum_msat: 0, next_outbound_htlc_limit_msat: 10_000_000_000, + next_splice_out_maximum_sat: 10_000_000, inbound_capacity_msat: 0, unspendable_punishment_reserve: None, confirmations_required: None, diff --git a/lightning/src/sign/tx_builder.rs b/lightning/src/sign/tx_builder.rs index a54f8f70f8d..ae98ed14a22 100644 --- a/lightning/src/sign/tx_builder.rs +++ b/lightning/src/sign/tx_builder.rs @@ -9,7 +9,9 @@ use crate::ln::chan_utils::{ second_stage_tx_fees_sat, ChannelTransactionParameters, CommitmentTransaction, HTLCOutputInCommitment, }; -use crate::ln::channel::{CommitmentStats, ANCHOR_OUTPUT_VALUE_SATOSHI}; +use crate::ln::channel::{ + get_v2_channel_reserve_satoshis, CommitmentStats, ANCHOR_OUTPUT_VALUE_SATOSHI, +}; use crate::prelude::*; use crate::types::features::ChannelTypeFeatures; use crate::util::logger::Logger; @@ -315,6 +317,105 @@ fn get_next_commitment_stats( }) } +/// Determines the maximum value that the holder can splice out of the channel, accounting +/// for the updated reserves after said splice. This maximum also makes sure the local commitment +/// retains at least one output after the splice, which is particularly relevant for +/// zero-reserve channels. +// +// The equation to determine `percent_max_splice_out_sat` is: +// 1) floor((c - s) / 100) == h - s - p +// We want the maximum value of s that will satisfy this equation, therefore, we solve: +// 2) (c - s) / 100 < h - s - p + 1 +// where c: `channel_value_satoshis` +// s: `percent_max_splice_out_sat` +// h: `local_balance_before_fee_sat` +// p: `post_splice_min_balance_sat` +// This results in: +// 3) s < (100h + 100 - 100p - 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, spiked_feerate: u32, + spiked_feerate_nondust_htlc_count: usize, post_splice_min_balance_sat: u64, + channel_constraints: &ChannelConstraints, channel_type: &ChannelTypeFeatures, +) -> u64 { + 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 != 0 { + let dividend = local_balance_before_fee_sat + .saturating_mul(100) + .saturating_add(100) + .saturating_sub(post_splice_min_balance_sat.saturating_mul(100)) + .saturating_sub(channel_value_satoshis); + let percent_max_splice_out_sat = dividend.saturating_sub(1) / 99; + let dust_limit_max_splice_out_sat = local_balance_before_fee_sat + .saturating_sub(channel_constraints.holder_dust_limit_satoshis) + .saturating_sub(post_splice_min_balance_sat); + // Take whichever constraint you hit first as you increase `splice_out_max` + let max_splice_out_sat = + cmp::min(percent_max_splice_out_sat, dust_limit_max_splice_out_sat); + #[cfg(debug_assertions)] + if max_splice_out_sat == 0 { + let current_balance_sat = + local_balance_before_fee_sat.saturating_sub(post_splice_min_balance_sat); + let v2_reserve_sat = get_v2_channel_reserve_satoshis( + channel_value_satoshis, + channel_constraints.holder_dust_limit_satoshis, + false, + ); + // If the holder cannot splice out anything, they must be at or + // below the v2 reserve + debug_assert!(current_balance_sat <= v2_reserve_sat); + } else { + let post_splice_reserve_sat = get_v2_channel_reserve_satoshis( + channel_value_satoshis.saturating_sub(max_splice_out_sat), + channel_constraints.holder_dust_limit_satoshis, + false, + ); + // If the holder can splice out some maximum, splicing out that + // maximum lands them at exactly the new v2 reserve + the + // `post_splice_min_balance_sat` + debug_assert_eq!( + local_balance_before_fee_sat.saturating_sub(max_splice_out_sat), + post_splice_reserve_sat.saturating_add(post_splice_min_balance_sat) + ); + } + max_splice_out_sat + } else { + // In a zero-reserve channel, the holder is free to withdraw up to its `post_splice_min_balance_sat` + local_balance_before_fee_sat.saturating_sub(post_splice_min_balance_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_spiked_tx_fee_sat`. + // We don't include an additional non-dust inbound HTLC in the `current_spiked_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, + spiked_feerate, + spiked_feerate_nondust_htlc_count, + channel_constraints.holder_dust_limit_satoshis, + channel_type, + ) { + let dust_limit_satoshis = channel_constraints.holder_dust_limit_satoshis; + let current_spiked_tx_fee_sat = commit_tx_fee_sat(spiked_feerate, 0, channel_type); + let min_balance_sat = if is_outbound_from_holder { + dust_limit_satoshis.saturating_add(current_spiked_tx_fee_sat) + } else { + dust_limit_satoshis + }; + next_splice_out_maximum_sat = + (local_balance_before_fee_msat / 1000).saturating_sub(min_balance_sat); + } + + next_splice_out_maximum_sat +} + 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, @@ -410,6 +511,20 @@ fn get_available_balances( total_anchors_sat.saturating_mul(1000), ); + let next_splice_out_maximum_sat = get_next_splice_out_maximum_sat( + is_outbound_from_holder, + channel_value_satoshis, + local_balance_before_fee_msat, + remote_balance_before_fee_msat, + spiked_feerate, + // The number of non-dust HTLCs on the local commitment at the spiked 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 }, + &channel_constraints, + channel_type, + ); + let outbound_capacity_msat = local_balance_before_fee_msat .saturating_sub(channel_constraints.counterparty_selected_channel_reserve_satoshis * 1000); @@ -582,6 +697,7 @@ fn get_available_balances( outbound_capacity_msat, next_outbound_htlc_limit_msat: available_capacity_msat, next_outbound_htlc_minimum_msat, + next_splice_out_maximum_sat, } }