Skip to content

[Splicing] Add reserve check to splicing #3641

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Draft
wants to merge 9 commits into
base: main
Choose a base branch
from
270 changes: 242 additions & 28 deletions lightning/src/ln/channel.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2477,6 +2477,28 @@ impl<'a> From<&'a Transaction> for ConfirmedTransaction<'a> {
}
}

#[cfg(splicing)]
impl PendingSplice {
#[inline]
fn add_checked(base: u64, delta: i64) -> u64 {
if delta >= 0 {
base.saturating_add(delta as u64)
} else {
base.saturating_sub(delta.abs() as u64)
}
}

// /// Compute the post-splice channel value from the pre-splice values and the peer contributions
// pub fn compute_post_splice_value(
// pre_channel_value: u64, our_funding_contribution: i64, their_funding_contribution: i64,
// ) -> u64 {
// Self::add_checked(
// pre_channel_value,
// our_funding_contribution.saturating_add(their_funding_contribution),
// )
// }
}

/// Contains everything about the channel including state, and various flags.
pub(super) struct ChannelContext<SP: Deref>
where
Expand Down Expand Up @@ -2974,6 +2996,8 @@ where
"commitment_signed"
}

/// Checks whether the channel was opened through V2 channel open (negotiation).
/// See also: is_v2()
fn is_v2_established(&self) -> bool {
let channel_parameters = &self.funding().channel_transaction_parameters;
// This will return false if `counterparty_parameters` is `None`, but for a `FundedChannel`, it
Expand Down Expand Up @@ -10829,6 +10853,75 @@ where
})
}

/// Compute the channel balances (local & remote, in msats) by taking into account fees,
// anchor values, and dust limits.
/// Pending HTLCs are not taken into account, this method should be used when there is no such,
/// e.g. in quiescence state
#[cfg(splicing)]
fn compute_balances_less_fees(
&self, channel_value_sats: u64, value_to_self_msat: u64, is_local: bool,
) -> (u64, u64) {
// We should get here only when there are no pending HTLCs, as they are not taken into account
debug_assert!(self.context.pending_inbound_htlcs.is_empty());
debug_assert!(self.context.pending_outbound_htlcs.is_empty());
Comment on lines +10864 to +10866
Copy link
Contributor

@tankyleo tankyleo Apr 2, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Came across this while taking a look: seems under quiescence we can reach this point with these vectors not empty - all the HTLCs in these vectors just have to be in the Committed state.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So I suppose:

  1. the check should assert only that there are no unsettled pending HTLCs
  2. if there are settled pending HTLC, they should be considered when computing the balance.
    @tankyleo , can you assist how to do 1.?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This shouldn't matter anymore since compute_balance_less_fees should be removed in favor of build_commitment_stats


let feerate_per_kw = self.context.feerate_per_kw;

// compute 'raw' counterparty balance
let value_to_remote_msat: i64 =
((channel_value_sats * 1000) as i64).saturating_sub(value_to_self_msat as i64);
debug_assert!(value_to_remote_msat >= 0);

let total_fee_sats = SpecTxBuilder {}.commit_tx_fee_sat(
feerate_per_kw,
0,
&self.funding.channel_transaction_parameters.channel_type_features,
);
let anchors_val_sats = if self
.funding
.channel_transaction_parameters
.channel_type_features
.supports_anchors_zero_fee_htlc_tx()
{
ANCHOR_OUTPUT_VALUE_SATOSHI * 2
} else {
0
} as i64;

// consider fees and anchor values
let (mut new_value_to_self_msat, mut new_value_to_remote_msat) = if self
.funding
.is_outbound()
{
(
value_to_self_msat as i64 - anchors_val_sats * 1000 - total_fee_sats as i64 * 1000,
value_to_remote_msat,
)
} else {
(
value_to_self_msat as i64,
value_to_remote_msat - anchors_val_sats * 1000 - total_fee_sats as i64 * 1000,
)
};

// consider dust limit
let broadcaster_dust_limit_sats = if is_local {
self.context.holder_dust_limit_satoshis
} else {
self.context.counterparty_dust_limit_satoshis
} as i64;
if new_value_to_self_msat < (broadcaster_dust_limit_sats * 1000) {
new_value_to_self_msat = 0;
}
debug_assert!(new_value_to_self_msat >= 0);
if new_value_to_remote_msat < (broadcaster_dust_limit_sats * 1000) {
new_value_to_remote_msat = 0;
}
debug_assert!(new_value_to_remote_msat >= 0);

(new_value_to_self_msat as u64, new_value_to_remote_msat as u64)
}

/// Handle splice_ack
#[cfg(splicing)]
pub(crate) fn splice_ack<ES: Deref, L: Deref>(
Expand All @@ -10839,35 +10932,37 @@ where
ES::Target: EntropySource,
L::Target: Logger,
{
let pending_splice = if let Some(ref mut pending_splice) = &mut self.pending_splice {
pending_splice
} else {
return Err(ChannelError::Ignore(format!("Channel is not in pending splice")));
};
let funding_negotiation_context = {
let pending_splice = if let Some(ref mut pending_splice) = &mut self.pending_splice {
pending_splice
} else {
return Err(ChannelError::Ignore(format!("Channel is not in pending splice")));
};

// TODO(splicing): Add check that we are the splice (quiescence) initiator
// TODO(splicing): Add check that we are the splice (quiescence) initiator

let funding_negotiation_context = match pending_splice.funding_negotiation.take() {
Some(FundingNegotiation::AwaitingAck(context)) => context,
Some(FundingNegotiation::ConstructingTransaction(funding, constructor)) => {
pending_splice.funding_negotiation =
Some(FundingNegotiation::ConstructingTransaction(funding, constructor));
return Err(ChannelError::WarnAndDisconnect(format!(
"Got unexpected splice_ack; splice negotiation already in progress"
)));
},
Some(FundingNegotiation::AwaitingSignatures(funding)) => {
pending_splice.funding_negotiation =
Some(FundingNegotiation::AwaitingSignatures(funding));
return Err(ChannelError::WarnAndDisconnect(format!(
"Got unexpected splice_ack; splice negotiation already in progress"
)));
},
None => {
return Err(ChannelError::Ignore(format!(
"Got unexpected splice_ack; no splice negotiation in progress"
)));
},
match pending_splice.funding_negotiation.take() {
Some(FundingNegotiation::AwaitingAck(context)) => context,
Some(FundingNegotiation::ConstructingTransaction(funding, constructor)) => {
pending_splice.funding_negotiation =
Some(FundingNegotiation::ConstructingTransaction(funding, constructor));
return Err(ChannelError::WarnAndDisconnect(format!(
"Got unexpected splice_ack; splice negotiation already in progress"
)));
},
Some(FundingNegotiation::AwaitingSignatures(funding)) => {
pending_splice.funding_negotiation =
Some(FundingNegotiation::AwaitingSignatures(funding));
return Err(ChannelError::WarnAndDisconnect(format!(
"Got unexpected splice_ack; splice negotiation already in progress"
)));
},
None => {
return Err(ChannelError::Ignore(format!(
"Got unexpected splice_ack; no splice negotiation in progress"
)));
},
}
};

let our_funding_contribution_satoshis =
Expand All @@ -10882,8 +10977,32 @@ where
msg.funding_pubkey,
)?;

// TODO(splicing): Pre-check for reserve requirement
// Pre-check for reserve requirement
// (Note: It should also be checked later at tx_complete)
let pre_channel_value_sats = self.funding.get_value_satoshis();
let post_channel_value_sats = self.funding.compute_post_splice_value(
our_funding_contribution_satoshis,
their_funding_contribution_satoshis,
);
let pre_balance_self_msat = self.funding.value_to_self_msat;
let post_balance_self_msat = PendingSplice::add_checked(
pre_balance_self_msat,
our_funding_contribution_satoshis * 1000,
);
let (pre_balance_self_less_fees_msat, pre_balance_counterparty_less_fees_msat) =
self.compute_balances_less_fees(pre_channel_value_sats, pre_balance_self_msat, true);
let (post_balance_self_less_fees_msat, post_balance_counterparty_less_fees_msat) =
self.compute_balances_less_fees(post_channel_value_sats, post_balance_self_msat, true);
// Pre-check for reserve requirement
// This will also be checked later at tx_complete
let _res = self.check_splice_balances_meet_v2_reserve_requirements(
pre_balance_self_less_fees_msat,
post_balance_self_less_fees_msat,
pre_balance_counterparty_less_fees_msat,
post_balance_counterparty_less_fees_msat,
pre_channel_value_sats,
post_channel_value_sats,
)?;

log_info!(
logger,
Expand All @@ -10910,6 +11029,11 @@ where
let tx_msg_opt = interactive_tx_constructor.take_initiator_first_message();

debug_assert!(self.interactive_tx_signing_session.is_none());
let pending_splice = if let Some(ref mut pending_splice) = &mut self.pending_splice {
pending_splice
} else {
return Err(ChannelError::Ignore(format!("Channel is not in pending splice")));
};
pending_splice.funding_negotiation = Some(FundingNegotiation::ConstructingTransaction(
splice_funding,
interactive_tx_constructor,
Expand Down Expand Up @@ -10970,6 +11094,84 @@ where
))
}

/// Check that balances (self and counterparty) meet the channel reserve requirements or violates them (below reserve).
/// The channel value is an input as opposed to using from the FundingScope, so that this can be used in case of splicing
/// to check with new channel value (before being committed to it).
#[cfg(splicing)]
pub fn check_splice_balances_meet_v2_reserve_requirements(
&self, self_balance_pre_msat: u64, self_balance_post_msat: u64,
counterparty_balance_pre_msat: u64, counterparty_balance_post_msat: u64,
channel_value_pre_sats: u64, channel_value_post_sats: u64,
) -> Result<(), ChannelError> {
let is_ok_self = self.check_splice_balance_meets_v2_reserve_requirement(
self_balance_pre_msat,
self_balance_post_msat,
channel_value_pre_sats,
channel_value_post_sats,
self.context.holder_dust_limit_satoshis,
);
if let Err(channel_reserve_self) = is_ok_self {
return Err(ChannelError::Warn(format!(
"Balance below reserve, mandated by holder, {} vs {}",
self_balance_post_msat, channel_reserve_self,
)));
}
let is_ok_cp = self.check_splice_balance_meets_v2_reserve_requirement(
counterparty_balance_pre_msat,
counterparty_balance_post_msat,
channel_value_pre_sats,
channel_value_post_sats,
self.context.counterparty_dust_limit_satoshis,
);
if let Err(channel_reserve_cp) = is_ok_cp {
return Err(ChannelError::Warn(format!(
"Balance below reserve mandated by counterparty, {} vs {}",
counterparty_balance_post_msat, channel_reserve_cp,
)));
}
Ok(())
}

/// Check that post-splicing balance meets reserve requirements, but only if it met it pre-splice as well.
/// In case of error, it returns the minimum channel reserve that was violated (in sats)
#[cfg(splicing)]
pub fn check_splice_balance_meets_v2_reserve_requirement(
&self, pre_balance_msat: u64, post_balance_msat: u64, pre_channel_value_sats: u64,
post_channel_value_sats: u64, dust_limit_sats: u64,
) -> Result<(), u64> {
let post_channel_reserve_sats =
get_v2_channel_reserve_satoshis(post_channel_value_sats, dust_limit_sats);
if post_balance_msat >= (post_channel_reserve_sats * 1000) {
return Ok(());
}
// We're not allowed to dip below the reserve once we've been above,
// check differently for originally v1 and v2 channels
if self.is_v2() {
let pre_channel_reserve_sats =
get_v2_channel_reserve_satoshis(pre_channel_value_sats, dust_limit_sats);
if pre_balance_msat >= (pre_channel_reserve_sats * 1000) {
return Err(post_channel_reserve_sats);
}
} else {
if pre_balance_msat >= (self.funding.holder_selected_channel_reserve_satoshis * 1000) {
return Err(post_channel_reserve_sats);
}
if let Some(cp_reserve) = self.funding.counterparty_selected_channel_reserve_satoshis {
if pre_balance_msat >= (cp_reserve * 1000) {
return Err(post_channel_reserve_sats);
}
}
}
// Make sure we either remain with the same balance or move towards the reserve.
if post_balance_msat >= pre_balance_msat {
Ok(())
} else {
Err(post_channel_reserve_sats)
}
}

// Send stuff to our remote peers:

/// Queues up an outbound HTLC to send by placing it in the holding cell. You should call
/// [`Self::maybe_free_holding_cell_htlcs`] in order to actually generate and send the
/// commitment update.
Expand Down Expand Up @@ -11792,6 +11994,18 @@ where
// CHANNEL_ANNOUNCEMENT_PROPAGATION_DELAY confirmations.
self.context.historical_scids.drain(0..end)
}

/// Check is channel is currently v2:
/// - established as v2
/// - or past a splice (which implicitly makes the channel v2)
#[cfg(splicing)]
fn is_v2(&self) -> bool {
if self.funding.channel_transaction_parameters.splice_parent_funding_txid.is_some() {
true
} else {
self.is_v2_established()
}
}
}

/// A not-yet-funded outbound (from holder) channel using V1 channel establishment.
Expand Down
Loading