diff --git a/fuzz/src/router.rs b/fuzz/src/router.rs index 2295ae3d7ff..aa3d274dac2 100644 --- a/fuzz/src/router.rs +++ b/fuzz/src/router.rs @@ -257,6 +257,7 @@ pub fn do_test(data: &[u8], out: Out) { pending_inbound_htlcs: Vec::new(), pending_outbound_htlcs: Vec::new(), current_dust_exposure_msat: None, + splice_details: None, }); } Some(&$first_hops_vec[..]) diff --git a/lightning/src/ln/channel.rs b/lightning/src/ln/channel.rs index 3df6f5fc436..2ace2ab1746 100644 --- a/lightning/src/ln/channel.rs +++ b/lightning/src/ln/channel.rs @@ -48,7 +48,8 @@ use crate::ln::chan_utils::{ }; use crate::ln::channel_state::{ ChannelShutdownState, CounterpartyForwardingInfo, InboundHTLCDetails, InboundHTLCStateDetails, - OutboundHTLCDetails, OutboundHTLCStateDetails, + OutboundHTLCDetails, OutboundHTLCStateDetails, SpliceCandidateDetails, SpliceDetails, + SpliceNegotiationDetails, SpliceNegotiationStatus, }; use crate::ln::channelmanager::{ self, BlindedFailure, ChannelReadyOrder, FundingConfirmedMessage, HTLCFailureMsg, @@ -84,7 +85,7 @@ use crate::util::config::{ use crate::util::errors::APIError; use crate::util::logger::{Level as LoggerLevel, Logger, Record, WithContext}; use crate::util::scid_utils::{block_from_scid, scid_from_parts}; -use crate::util::ser::{Readable, ReadableArgs, RequiredWrapper, Writeable, Writer}; +use crate::util::ser::{Iterable, Readable, ReadableArgs, RequiredWrapper, Writeable, Writer}; use crate::util::wallet_utils::{ConfirmedUtxo, Input}; use crate::{impl_readable_for_vec, impl_writeable_for_vec}; @@ -2961,9 +2962,20 @@ impl FundingScope { struct PendingFunding { funding_negotiation: Option, + /// Our contribution to the funding negotiation round currently in progress, if we are + /// contributing to it. Set when the round starts, moved into the [`NegotiatedCandidate`] + /// when negotiation completes, and dropped in + /// [`FundedChannel::reset_pending_splice_state`] if the round is abandoned. + /// + /// When the counterparty initiates an RBF and a prior round included our contribution, this + /// is set to that contribution adjusted to the new feerate (or the RBF is rejected if the + /// adjustment fails, in which case no round starts). This ensures a splice we contributed to + /// never loses our contribution in subsequent rounds. + negotiation_contribution: Option, + /// Funding candidates that have been negotiated but have not reached enough confirmations /// by both counterparties to have exchanged `splice_locked` and be promoted. - negotiated_candidates: Vec, + negotiated_candidates: Vec, /// The funding txid used in the `splice_locked` sent to the counterparty. sent_funding_txid: Option, @@ -2974,19 +2986,19 @@ struct PendingFunding { /// The feerate used in the last successfully negotiated funding transaction. /// Used for validating the minimum feerate increase rule on RBF attempts. last_funding_feerate_sat_per_1000_weight: Option, +} - /// The funding contributions from splice/RBF rounds where we contributed. - /// - /// A new entry is appended when we contribute to a negotiation round (either as initiator - /// or acceptor). Rounds where we don't contribute (e.g., counterparty-only splice) do not - /// add an entry. Once non-empty, every subsequent round appends: when the counterparty - /// initiates an RBF, the last entry is adjusted to the new feerate and appended as a new - /// entry (or the RBF is rejected if the adjustment fails, in which case no round starts). - /// - /// If the round aborts, the last entry is popped in - /// [`FundedChannel::reset_pending_splice_state`], restoring the prior round's contribution - /// as the most recent entry. - contributions: Vec, +/// A funding candidate that has been negotiated, together with our contribution, if any, to the +/// negotiation round that produced it. +#[derive(Debug)] +struct NegotiatedCandidate { + funding: FundingScope, + + /// Our contribution to the negotiation round that produced this candidate, or `None` if only + /// the counterparty contributed. Once a candidate includes our contribution, every later + /// candidate does as well: RBF rounds carry the contribution forward (possibly adjusted to a + /// new feerate) rather than dropping it, preserving the splice intention. + contribution: Option, } #[derive(Debug)] @@ -3047,20 +3059,38 @@ impl Writeable for PendingFundingWriteable<'_> { Some(FundingNegotiation::AwaitingSignatures { .. }) ) ); - let contributions_len = if self.reset_funding_negotiation - && self.pending_funding.funding_negotiation.is_some() - { - self.pending_funding.contributions.len().saturating_sub(1) - } else { - self.pending_funding.contributions.len() - }; + // The in-flight round's contribution is only written if its negotiation survives + // serialization round trips. + let negotiation_contribution = funding_negotiation + .is_some() + .then(|| self.pending_funding.negotiation_contribution.as_ref()) + .flatten(); + // `negotiated_candidates` is decomposed into parallel fields for backwards + // compatibility: candidate fundings are written as in LDK 0.2, with their contributions + // written separately, aligned to the tail of the candidate list. The alignment is + // lossless because contributions form a suffix of the candidates (once a round includes + // our contribution, every subsequent round does as well). + let candidates = &self.pending_funding.negotiated_candidates; + debug_assert!( + candidates + .iter() + .skip_while(|candidate| candidate.contribution.is_none()) + .all(|candidate| candidate.contribution.is_some()), + "contributions must form a suffix of the negotiated candidates", + ); + let fundings = Iterable(candidates.iter().map(|candidate| &candidate.funding)); + let has_contributions = candidates.iter().any(|candidate| candidate.contribution.is_some()); + let contributions = has_contributions.then(|| { + Iterable(candidates.iter().filter_map(|candidate| candidate.contribution.as_ref())) + }); write_tlv_fields!(writer, { (1, funding_negotiation, upgradable_option), - (3, self.pending_funding.negotiated_candidates, required_vec), + (3, fundings, required), (5, self.pending_funding.sent_funding_txid, option), (7, self.pending_funding.received_funding_txid, option), (8, self.pending_funding.last_funding_feerate_sat_per_1000_weight, option), - (10, self.pending_funding.contributions[..contributions_len], optional_vec), + (10, contributions, option), + (12, negotiation_contribution, option), }); Ok(()) } @@ -3068,14 +3098,56 @@ impl Writeable for PendingFundingWriteable<'_> { impl Readable for PendingFunding { fn read(reader: &mut R) -> Result { - Ok(_decode_and_build!(reader, Self, { + let mut funding_negotiation = None; + let mut fundings: Option> = None; + let mut sent_funding_txid = None; + let mut received_funding_txid = None; + let mut last_funding_feerate_sat_per_1000_weight = None; + let mut contributions: Option> = None; + let mut negotiation_contribution: Option = None; + + read_tlv_fields!(reader, { (1, funding_negotiation, upgradable_option), - (3, negotiated_candidates, required_vec), + (3, fundings, optional_vec), (5, sent_funding_txid, option), (7, received_funding_txid, option), (8, last_funding_feerate_sat_per_1000_weight, option), (10, contributions, optional_vec), - })) + (12, negotiation_contribution, option), + }); + + let fundings = fundings.unwrap_or_default(); + let mut contributions = contributions.unwrap_or_default(); + // Data written before the in-flight round's contribution was stored separately kept it + // as the last entry while a negotiation was pending. + if negotiation_contribution.is_none() && contributions.len() > fundings.len() { + negotiation_contribution = contributions.pop(); + } + // An in-flight contribution is only meaningful while its negotiation round is alive; + // rounds not surviving serialization round trips have their events handled separately. + if funding_negotiation.is_none() { + negotiation_contribution = None; + } + + let contrib_offset = fundings.len().saturating_sub(contributions.len()); + let mut contributions = contributions.into_iter(); + let negotiated_candidates = fundings + .into_iter() + .enumerate() + .map(|(i, funding)| NegotiatedCandidate { + funding, + contribution: if i >= contrib_offset { contributions.next() } else { None }, + }) + .collect(); + + Ok(PendingFunding { + funding_negotiation, + negotiation_contribution, + negotiated_candidates, + sent_funding_txid, + received_funding_txid, + last_funding_feerate_sat_per_1000_weight, + }) } } @@ -3225,22 +3297,89 @@ impl PendingFunding { feerate_sat_per_kw >= min_feerate } + /// All stored contributions: those of the negotiated candidates followed by the in-flight + /// negotiation round's, if any. + fn contributions(&self) -> impl Iterator + '_ { + self.negotiated_candidates + .iter() + .filter_map(|candidate| candidate.contribution.as_ref()) + .chain(self.negotiation_contribution.as_ref()) + } + fn contributed_inputs(&self) -> impl Iterator + '_ { - self.contributions.iter().flat_map(|c| c.contributed_inputs()) + self.contributions().flat_map(|c| c.contributed_inputs()) } fn contributed_outputs(&self) -> impl Iterator + '_ { - self.contributions.iter().flat_map(|c| c.contributed_outputs()) + self.contributions().flat_map(|c| c.contributed_outputs()) } fn prior_contributed_inputs(&self) -> impl Iterator + '_ { - let len = self.contributions.len(); - self.contributions[..len.saturating_sub(1)].iter().flat_map(|c| c.contributed_inputs()) + self.negotiated_candidates + .iter() + .filter_map(|candidate| candidate.contribution.as_ref()) + .flat_map(|c| c.contributed_inputs()) } fn prior_contributed_outputs(&self) -> impl Iterator + '_ { - let len = self.contributions.len(); - self.contributions[..len.saturating_sub(1)].iter().flat_map(|c| c.contributed_outputs()) + self.negotiated_candidates + .iter() + .filter_map(|candidate| candidate.contribution.as_ref()) + .flat_map(|c| c.contributed_outputs()) + } + + /// Our most recent contribution across rounds, including any round still under negotiation. + fn latest_contribution(&self) -> Option<&FundingContribution> { + self.negotiation_contribution.as_ref().or_else(|| { + self.negotiated_candidates.last().and_then(|candidate| candidate.contribution.as_ref()) + }) + } + + fn details( + &self, context: &ChannelContext, best_block_height: u32, + ) -> SpliceDetails { + let negotiation = self.funding_negotiation.as_ref().map(|negotiation| { + let status = match negotiation { + FundingNegotiation::AwaitingAck { .. } => SpliceNegotiationStatus::AwaitingAck, + FundingNegotiation::ConstructingTransaction { .. } => { + SpliceNegotiationStatus::ConstructingTransaction + }, + FundingNegotiation::AwaitingSignatures { .. } => { + SpliceNegotiationStatus::AwaitingSignatures + }, + }; + SpliceNegotiationDetails { + status, + is_initiator: negotiation.is_initiator(), + funding_feerate_sat_per_1000_weight: negotiation + .funding_feerate_sat_per_1000_weight(), + new_channel_value_satoshis: negotiation + .as_funding() + .map(|funding| funding.get_value_satoshis()), + txid: negotiation.as_funding().and_then(|funding| funding.get_funding_txid()), + contribution: self.negotiation_contribution.clone(), + } + }); + let candidates = self + .negotiated_candidates + .iter() + .map(|candidate| SpliceCandidateDetails { + txid: candidate + .funding + .get_funding_txid() + .expect("negotiated candidates should have a funding txid"), + new_channel_value_satoshis: candidate.funding.get_value_satoshis(), + contribution: candidate.contribution.clone(), + confirmations: candidate.funding.get_funding_tx_confirmations(best_block_height), + confirmations_required: context.minimum_depth(&candidate.funding), + }) + .collect(); + SpliceDetails { + negotiation, + candidates, + sent_splice_locked_txid: self.sent_funding_txid, + received_splice_locked_txid: self.received_funding_txid, + } } fn check_get_splice_locked( @@ -3248,7 +3387,7 @@ impl PendingFunding { ) -> Option { debug_assert!(confirmed_funding_index < self.negotiated_candidates.len()); - let funding = &self.negotiated_candidates[confirmed_funding_index]; + let funding = &self.negotiated_candidates[confirmed_funding_index].funding; if !context.check_funding_meets_minimum_depth(funding, height) { return None; } @@ -7272,8 +7411,9 @@ where /// Builds a [`SpliceFundingFailed`] from a contribution, filtering out inputs/outputs /// that are still committed to a prior splice round. fn splice_funding_failed_for(&self, contribution: FundingContribution) -> SpliceFundingFailed { - // The contribution was never pushed to `contributions`, so `contributed_inputs()` and - // `contributed_outputs()` return only prior rounds' entries for filtering. + // The contribution was never stored in the pending splice state, so + // `contributed_inputs()` and `contributed_outputs()` return only prior rounds' entries + // for filtering. splice_funding_failed_for!( self, true, @@ -7323,12 +7463,15 @@ where }) } - fn pending_funding(&self) -> &[FundingScope] { - if let Some(pending_splice) = &self.pending_splice { - pending_splice.negotiated_candidates.as_slice() - } else { - &[] - } + fn negotiated_candidates(&self) -> &[NegotiatedCandidate] { + self.pending_splice + .as_ref() + .map(|pending_splice| pending_splice.negotiated_candidates.as_slice()) + .unwrap_or(&[]) + } + + fn pending_funding(&self) -> impl ExactSizeIterator + '_ { + self.negotiated_candidates().iter().map(|candidate| &candidate.funding) } fn funding_and_pending_funding_iter_mut(&mut self) -> impl Iterator { @@ -7337,10 +7480,19 @@ where .as_mut() .map(|pending_splice| pending_splice.negotiated_candidates.as_mut_slice()) .unwrap_or(&mut []) - .iter_mut(), + .iter_mut() + .map(|candidate| &mut candidate.funding), ) } + /// Returns details about any pending splice attempts for inclusion in + /// [`crate::ln::channel_state::ChannelDetails`]. + pub fn pending_splice_details(&self, best_block_height: u32) -> Option { + self.pending_splice + .as_ref() + .map(|pending_splice| pending_splice.details(&self.context, best_block_height)) + } + fn has_pending_splice_awaiting_signatures(&self) -> bool { self.pending_splice .as_ref() @@ -7428,7 +7580,7 @@ where .take() .map(|negotiation| negotiation.is_initiator()) .unwrap_or(false); - let contribution = pending_splice.contributions.pop(); + let contribution = pending_splice.negotiation_contribution.take(); if let Some(ref contribution) = contribution { debug_assert!( pending_splice @@ -7439,8 +7591,8 @@ where ); } - // After pop, `contributed_inputs()` / `contributed_outputs()` return only prior - // rounds for filtering. + // With the in-flight contribution taken, `contributed_inputs()` / + // `contributed_outputs()` return only prior rounds' entries for filtering. let splice_funding_failed = contribution.and_then(|contribution| { splice_funding_failed_for!( self, @@ -7451,7 +7603,7 @@ where ) }); - if self.pending_funding().is_empty() { + if self.negotiated_candidates().is_empty() { self.pending_splice.take(); } @@ -7478,7 +7630,7 @@ where .as_ref() .map(|negotiation| negotiation.is_initiator()) .unwrap_or(false); - let contribution = pending_splice.contributions.last().cloned()?; + let contribution = pending_splice.negotiation_contribution.clone()?; splice_funding_failed_for!( self, is_initiator, @@ -8111,7 +8263,7 @@ where } core::iter::once(&self.funding) - .chain(self.pending_funding().iter()) + .chain(self.pending_funding()) .try_for_each(|funding| self.context.validate_update_add_htlc(funding, msg, fee_estimator))?; // Now update local state: @@ -8543,7 +8695,7 @@ where let funding_contribution = self .pending_splice .as_ref() - .and_then(|pending_splice| pending_splice.contributions.last()) + .and_then(|pending_splice| pending_splice.negotiation_contribution.as_ref()) .cloned(); log_info!( @@ -8606,7 +8758,7 @@ where ) -> Result, ChannelError> { self.commitment_signed_check_state()?; - if !self.pending_funding().is_empty() { + if !self.negotiated_candidates().is_empty() { return Err(ChannelError::close( "Got a single commitment_signed message when expecting a batch".to_owned(), )); @@ -8674,7 +8826,7 @@ where // pending splice transaction has confirmed since receiving the batch. let mut commitment_txs = Vec::with_capacity(self.pending_funding().len() + 1); let mut htlc_data = None; - for funding in core::iter::once(&self.funding).chain(self.pending_funding().iter()) { + for funding in core::iter::once(&self.funding).chain(self.pending_funding()) { let funding_txid = funding.get_funding_txid().expect("Funding txid must be known for pending scope"); let msg = messages.get(&funding_txid).ok_or_else(|| { @@ -9546,7 +9698,18 @@ where let channel_type = funding.get_channel_type().clone(); let funding_redeem_script = funding.get_funding_redeemscript(); - pending_splice.negotiated_candidates.push(funding); + let contribution = pending_splice.negotiation_contribution.take(); + debug_assert!( + contribution.is_some() + || pending_splice + .negotiated_candidates + .iter() + .all(|candidate| candidate.contribution.is_none()), + "a round following one we contributed to must carry our contribution", + ); + pending_splice + .negotiated_candidates + .push(NegotiatedCandidate { funding, contribution }); let splice_negotiated = SpliceFundingNegotiated { funding_txo: funding_txo.into_bitcoin_outpoint(), @@ -9569,29 +9732,21 @@ where ); } - let contrib_offset = pending_splice - .negotiated_candidates - .len() - .saturating_sub(pending_splice.contributions.len()); let candidates = pending_splice .negotiated_candidates .iter() - .enumerate() - .map(|(i, funding)| { - let txid = funding + .map(|candidate| { + let txid = candidate + .funding .get_funding_txid() .expect("negotiated candidates should have a funding txid"); - let contribution = i - .checked_sub(contrib_offset) - .and_then(|j| pending_splice.contributions.get(j)) - .cloned(); FundingCandidate { txid, channels: vec![ChannelFunding { counterparty_node_id: self.context.counterparty_node_id, channel_id: self.context.channel_id, purpose: FundingPurpose::Splice, - contribution, + contribution: candidate.contribution.clone(), }], } }) @@ -9752,7 +9907,7 @@ where debug_assert!(!self.funding.get_channel_type().supports_anchor_zero_fee_commitments()); let can_send_update_fee = core::iter::once(&self.funding) - .chain(self.pending_funding().iter()) + .chain(self.pending_funding()) .all(|funding| self.context.can_send_update_fee(funding, feerate_per_kw, fee_estimator, logger)); if !can_send_update_fee { return None; @@ -10103,7 +10258,7 @@ where } core::iter::once(&self.funding) - .chain(self.pending_funding().iter()) + .chain(self.pending_funding()) .try_for_each(|funding| FundedChannel::::check_remote_fee(funding.get_channel_type(), fee_estimator, msg.feerate_per_kw, Some(self.context.feerate_per_kw), logger))?; self.context.pending_update_fee = Some((msg.feerate_per_kw, FeeUpdateState::RemoteAnnounced)); @@ -10808,7 +10963,6 @@ where // for this `txid`. let inferred_splice_locked = msg.my_current_funding_locked.as_ref().and_then(|funding_locked| { self.pending_funding() - .iter() .find(|funding| funding.get_funding_txid() == Some(funding_locked.txid)) .and_then(|_| { self.pending_splice.as_ref().and_then(|pending_splice| { @@ -11605,7 +11759,7 @@ where ); core::iter::once(&self.funding) - .chain(self.pending_funding().iter()) + .chain(self.pending_funding()) .try_for_each(|funding| self.context.can_accept_incoming_htlc(funding, dust_exposure_limiting_feerate, &logger)) } @@ -11923,6 +12077,7 @@ where let funding = pending_splice .negotiated_candidates .iter_mut() + .map(|candidate| &mut candidate.funding) .find(|funding| funding.get_funding_txid() == Some(splice_txid)) .unwrap(); @@ -11937,9 +12092,12 @@ where .funding_transaction .as_ref() .expect("Promoted splice funding should have a funding transaction"); - let contributions = core::mem::take(&mut pending_splice.contributions); - contributions + let candidates = core::mem::take(&mut pending_splice.negotiated_candidates); + let negotiation_contribution = pending_splice.negotiation_contribution.take(); + candidates .into_iter() + .filter_map(|candidate| candidate.contribution) + .chain(negotiation_contribution) .filter_map(|contribution| { contribution.into_unique_contributions( promoted_tx.input.iter().map(|i| i.previous_output), @@ -12028,7 +12186,9 @@ where let mut confirmed_funding_index = None; let mut funding_already_confirmed = false; - for (index, funding) in pending_splice.negotiated_candidates.iter_mut().enumerate() { + let candidates = + pending_splice.negotiated_candidates.iter_mut().map(|candidate| &mut candidate.funding); + for (index, funding) in candidates.enumerate() { if self.context.check_for_funding_tx_confirmed( funding, block_hash, height, index_in_block, &mut confirmed_tx, logger, )? { @@ -12188,7 +12348,8 @@ where if let Some(pending_splice) = &mut self.pending_splice { let mut confirmed_funding_index = None; - for (index, funding) in pending_splice.negotiated_candidates.iter().enumerate() { + let candidates = pending_splice.negotiated_candidates.iter().map(|candidate| &candidate.funding); + for (index, funding) in candidates.enumerate() { if funding.funding_tx_confirmation_height != 0 { if confirmed_funding_index.is_some() { let err_reason = "splice tx of another pending funding already confirmed"; @@ -12200,7 +12361,8 @@ where } if let Some(confirmed_funding_index) = confirmed_funding_index { - let funding = &mut pending_splice.negotiated_candidates[confirmed_funding_index]; + let funding = + &mut pending_splice.negotiated_candidates[confirmed_funding_index].funding; // Check if the splice funding transaction was unconfirmed if funding.get_funding_tx_confirmations(height) == 0 { @@ -12256,7 +12418,7 @@ where pub fn get_relevant_txids(&self) -> impl Iterator)> + '_ { core::iter::once(&self.funding) - .chain(self.pending_funding().iter()) + .chain(self.pending_funding()) .map(|funding| { ( funding.get_funding_txid(), @@ -12706,15 +12868,7 @@ where ); let min_rbf_feerate = prev_feerate.map(min_rbf_feerate); let prior = if pending_splice.last_funding_feerate_sat_per_1000_weight.is_some() { - if let Some(prior) = self - .pending_splice - .as_ref() - .and_then(|pending_splice| pending_splice.contributions.last()) - { - Some(prior.clone()) - } else { - None - } + pending_splice.latest_contribution().cloned() } else { None }; @@ -13004,11 +13158,11 @@ where FundingNegotiation::AwaitingAck { context, new_holder_funding_key: funding_pubkey }; self.pending_splice = Some(PendingFunding { funding_negotiation: Some(funding_negotiation), + negotiation_contribution: None, negotiated_candidates: vec![], sent_funding_txid: None, received_funding_txid: None, last_funding_feerate_sat_per_1000_weight: None, - contributions: vec![], }); msgs::SpliceInit { @@ -13030,6 +13184,7 @@ where .negotiated_candidates .first() .unwrap() + .funding .get_holder_pubkeys() .funding_pubkey; @@ -13378,11 +13533,11 @@ where ); self.pending_splice = Some(PendingFunding { funding_negotiation: Some(funding_negotiation), + negotiation_contribution: adjusted_contribution, negotiated_candidates: Vec::new(), received_funding_txid: None, sent_funding_txid: None, last_funding_feerate_sat_per_1000_weight: None, - contributions: adjusted_contribution.into_iter().collect(), }); Ok(msgs::SpliceAck { @@ -13465,8 +13620,8 @@ where // Reuse funding pubkeys from the last negotiated candidate since all RBF candidates // for the same splice share the same funding output script. Ok(( - last_candidate.get_holder_pubkeys().clone(), - *last_candidate.counterparty_funding_pubkey(), + last_candidate.funding.get_holder_pubkeys().clone(), + *last_candidate.funding.counterparty_funding_pubkey(), )) } @@ -13490,7 +13645,7 @@ where } else if let Some(prior) = self .pending_splice .as_ref() - .and_then(|pending_splice| pending_splice.contributions.last()) + .and_then(|pending_splice| pending_splice.latest_contribution()) { let net_value = holder_balance .ok_or_else(|| ChannelError::Abort(AbortReason::InsufficientRbfFeerate)) @@ -13533,16 +13688,14 @@ where self.pending_splice .as_mut() .expect("pending_splice is Some") - .contributions - .push(adjusted_contribution.clone()); + .negotiation_contribution = Some(adjusted_contribution.clone()); adjusted_contribution.into_tx_parts() } else if prior_net_value.is_some() { let prior_contribution = self .pending_splice .as_ref() .expect("pending_splice is Some") - .contributions - .last() + .latest_contribution() .expect("prior_net_value was Some") .clone(); let adjusted_contribution = prior_contribution @@ -13551,8 +13704,7 @@ where self.pending_splice .as_mut() .expect("pending_splice is Some") - .contributions - .push(adjusted_contribution.clone()); + .negotiation_contribution = Some(adjusted_contribution.clone()); adjusted_contribution.into_tx_parts() } else { Default::default() @@ -13610,8 +13762,8 @@ where let last_candidate = pending_splice.negotiated_candidates.last().ok_or_else(|| { ChannelError::WarnAndDisconnect("No negotiated splice candidates for RBF".to_owned()) })?; - let holder_pubkeys = last_candidate.get_holder_pubkeys().clone(); - let counterparty_funding_pubkey = *last_candidate.counterparty_funding_pubkey(); + let holder_pubkeys = last_candidate.funding.get_holder_pubkeys().clone(); + let counterparty_funding_pubkey = *last_candidate.funding.counterparty_funding_pubkey(); let new_funding = self .validate_splice_contributions( @@ -13876,7 +14028,7 @@ where if !pending_splice .negotiated_candidates .iter() - .any(|funding| funding.get_funding_txid() == Some(msg.splice_txid)) + .any(|candidate| candidate.funding.get_funding_txid() == Some(msg.splice_txid)) { let err = "unknown splice funding txid"; return Err(ChannelError::close(err.to_string())); @@ -14074,7 +14226,7 @@ where &self, fee_estimator: &LowerBoundedFeeEstimator, ) -> Result { let init = self.context.get_available_balances_for_scope(&self.funding, fee_estimator)?; - self.pending_funding().iter().try_fold(init, |acc, funding| { + self.pending_funding().try_fold(init, |acc, funding| { let e = self.context.get_available_balances_for_scope(funding, fee_estimator)?; Ok(AvailableBalances { inbound_capacity_msat: acc.inbound_capacity_msat.min(e.inbound_capacity_msat), @@ -14136,7 +14288,7 @@ where } self.context.resend_order = RAACommitmentOrder::RevokeAndACKFirst; - let update = if self.pending_funding().is_empty() { + let update = if self.negotiated_candidates().is_empty() { let (htlcs_ref, counterparty_commitment_tx) = self.build_commitment_no_state_update(&self.funding, logger); let htlc_outputs = htlcs_ref @@ -14167,7 +14319,7 @@ where } else { let mut htlc_data = None; let commitment_txs = core::iter::once(&self.funding) - .chain(self.pending_funding().iter()) + .chain(self.pending_funding()) .map(|funding| { let (htlcs_ref, counterparty_commitment_tx) = self.build_commitment_no_state_update(funding, logger); @@ -14229,7 +14381,7 @@ where &self, logger: &L, ) -> Result, ChannelError> { core::iter::once(&self.funding) - .chain(self.pending_funding().iter()) + .chain(self.pending_funding()) .map(|funding| self.send_commitment_no_state_update_for_funding(funding, logger)) .collect::, ChannelError>>() } @@ -14700,14 +14852,14 @@ where } let tx_init_rbf = self.send_tx_init_rbf(context); self.pending_splice.as_mut().unwrap() - .contributions.push(prior_contribution); + .negotiation_contribution = Some(prior_contribution); return Ok(Some(StfuResponse::TxInitRbf(tx_init_rbf))); } let splice_init = self.send_splice_init(context); debug_assert!(self.pending_splice.is_some()); self.pending_splice.as_mut().unwrap() - .contributions.push(prior_contribution); + .negotiation_contribution = Some(prior_contribution); return Ok(Some(StfuResponse::SpliceInit(splice_init))); }, #[cfg(any(test, fuzzing, feature = "_test_utils"))] @@ -16370,7 +16522,7 @@ impl Writeable for FundedChannel { // resumed on reestablishment, but keep any already-negotiated candidates. let reset_funding_negotiation = self.should_reset_pending_splice_state(true); let should_persist_pending_splice = - !reset_funding_negotiation || !self.pending_funding().is_empty(); + !reset_funding_negotiation || !self.negotiated_candidates().is_empty(); let pending_splice = should_persist_pending_splice .then(|| ()) .and_then(|_| self.pending_splice.as_ref()) diff --git a/lightning/src/ln/channel_state.rs b/lightning/src/ln/channel_state.rs index 6e5d633e920..6b7f415bec1 100644 --- a/lightning/src/ln/channel_state.rs +++ b/lightning/src/ln/channel_state.rs @@ -12,10 +12,12 @@ use alloc::vec::Vec; use bitcoin::secp256k1::PublicKey; +use bitcoin::Txid; use crate::chain::chaininterface::{FeeEstimator, LowerBoundedFeeEstimator}; use crate::chain::transaction::OutPoint; use crate::ln::channel::Channel; +use crate::ln::funding::FundingContribution; use crate::ln::types::ChannelId; use crate::sign::SignerProvider; use crate::types::features::{ChannelTypeFeatures, InitFeatures}; @@ -494,6 +496,14 @@ pub struct ChannelDetails { /// /// [`ChannelConfig::max_dust_htlc_exposure`]: crate::util::config::ChannelConfig::max_dust_htlc_exposure pub current_dust_exposure_msat: Option, + /// Details of any pending splice attempts on this channel. + /// + /// This includes any splice currently under negotiation with our counterparty as well as any + /// negotiated splice or RBF attempts waiting on sufficient on-chain confirmations. This will + /// be `None` if no splice is pending. + /// + /// This field will be `None` for objects serialized with LDK versions prior to 0.3. + pub splice_details: Option, } impl ChannelDetails { @@ -619,6 +629,9 @@ impl ChannelDetails { pending_inbound_htlcs: context.get_pending_inbound_htlc_details(funding), pending_outbound_htlcs: context.get_pending_outbound_htlc_details(funding), current_dust_exposure_msat: Some(balance.dust_exposure_msat), + splice_details: channel + .as_funded() + .and_then(|chan| chan.pending_splice_details(best_block_height)), } } } @@ -661,11 +674,135 @@ impl_ser_tlv_based!(ChannelDetails, { (45, pending_outbound_htlcs, optional_vec), (47, funding_redeem_script, option), (49, current_dust_exposure_msat, option), + (51, splice_details, option), (_unused, user_channel_id, (static_value, _user_channel_id_low.unwrap_or(0) as u128 | ((_user_channel_id_high.unwrap_or(0) as u128) << 64) )), }); +/// Details of pending splice attempts on a channel, as returned in +/// [`ChannelDetails::splice_details`]. +/// +/// This includes any splice currently under negotiation with the counterparty as well as any +/// negotiated splice or RBF attempts waiting on sufficient on-chain confirmations. +#[derive(Clone, Debug, PartialEq)] +pub struct SpliceDetails { + /// A splice or RBF attempt currently being negotiated with the counterparty, if any. + /// + /// Note that a negotiation which has not yet reached + /// [`SpliceNegotiationStatus::AwaitingSignatures`] does not survive a restart, so this only + /// reflects in-memory negotiation state. + pub negotiation: Option, + /// Negotiated splice transactions that have not yet reached sufficient confirmations by both + /// counterparties to have exchanged `splice_locked`, in order: the original negotiation + /// followed by any RBF replacements. + /// + /// More than one entry indicates the use of RBF; at most one of these candidates will + /// ultimately confirm. + pub candidates: Vec, + /// The txid announced in the `splice_locked` we sent, i.e., the candidate that we consider to + /// have sufficient confirmations. + pub sent_splice_locked_txid: Option, + /// The txid announced in the `splice_locked` received from the counterparty, i.e., the + /// candidate that they consider to have sufficient confirmations. + pub received_splice_locked_txid: Option, +} + +impl_ser_tlv_based!(SpliceDetails, { + (1, negotiation, option), + (3, candidates, required_vec), + (5, sent_splice_locked_txid, option), + (7, received_splice_locked_txid, option), +}); + +/// Details of a splice or RBF attempt currently being negotiated with the counterparty. +#[derive(Clone, Debug, PartialEq)] +pub struct SpliceNegotiationDetails { + /// How far the negotiation has progressed. + pub status: SpliceNegotiationStatus, + /// Whether we initiated the negotiation. + pub is_initiator: bool, + /// The feerate of the splice transaction under negotiation, denominated in satoshi per 1000 + /// weight units. + pub funding_feerate_sat_per_1000_weight: u32, + /// The value, in satoshis, of the channel once the splice transaction under negotiation + /// confirms and is promoted. + /// + /// This will be `None` while [`SpliceNegotiationStatus::AwaitingAck`], as the value is not + /// known until both counterparties' contributions have been exchanged. + pub new_channel_value_satoshis: Option, + /// The txid of the splice transaction under negotiation. + /// + /// This will be `None` until [`SpliceNegotiationStatus::AwaitingSignatures`], as the txid is + /// not known until the transaction has been fully constructed. + pub txid: Option, + /// Our contribution to the splice under negotiation, or `None` if we are not contributing. + /// + /// Note that for a counterparty-initiated RBF attempt, this is the prior round's contribution + /// adjusted to the new feerate. + pub contribution: Option, +} + +impl_ser_tlv_based!(SpliceNegotiationDetails, { + (1, status, required), + (3, is_initiator, required), + (5, funding_feerate_sat_per_1000_weight, required), + (7, new_channel_value_satoshis, option), + (9, txid, option), + (11, contribution, option), +}); + +/// The status of a splice or RBF negotiation in progress with the counterparty. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum SpliceNegotiationStatus { + /// We sent `splice_init` or `tx_init_rbf` and are awaiting the counterparty's acknowledgement. + AwaitingAck, + /// The splice transaction is being interactively constructed. + ConstructingTransaction, + /// The splice transaction has been negotiated and is awaiting signatures from both + /// counterparties. + AwaitingSignatures, +} + +impl_ser_tlv_based_enum!(SpliceNegotiationStatus, + (0, AwaitingAck) => {}, + (2, ConstructingTransaction) => {}, + (4, AwaitingSignatures) => {}, +); + +/// Details of a negotiated splice transaction that has not yet reached sufficient confirmations +/// by both counterparties to have exchanged `splice_locked`. +#[derive(Clone, Debug, PartialEq)] +pub struct SpliceCandidateDetails { + /// The txid of the splice transaction. + pub txid: Txid, + /// The value, in satoshis, of the channel once this candidate confirms and is promoted. + pub new_channel_value_satoshis: u64, + /// Our contribution to this candidate, or `None` if we did not contribute. + /// + /// Once a candidate includes our contribution, every later candidate does as well: RBF + /// attempts carry the contribution forward (possibly adjusted to a new feerate) rather than + /// dropping it, preserving the splice intention. + /// + /// Note that [`FundingContribution::feerate`] is the feerate used when selecting the + /// contribution's inputs, which is not necessarily the exact feerate of the negotiated + /// transaction. + pub contribution: Option, + /// The current number of confirmations of this candidate's transaction. + pub confirmations: u32, + /// The number of confirmations required before `splice_locked` can be sent for this + /// candidate. + pub confirmations_required: Option, +} + +impl_ser_tlv_based!(SpliceCandidateDetails, { + (1, txid, required), + (3, new_channel_value_satoshis, required), + (5, contribution, option), + (7, confirmations, required), + (9, confirmations_required, option), +}); + #[derive(Clone, Copy, Debug, PartialEq, Eq)] /// Further information on the details of the channel shutdown. /// Upon channels being forced closed (i.e. commitment transaction confirmation detected @@ -718,7 +855,10 @@ mod tests { }, }; - use super::{ChannelCounterparty, ChannelDetails, ChannelShutdownState}; + use super::{ + ChannelCounterparty, ChannelDetails, ChannelShutdownState, SpliceCandidateDetails, + SpliceDetails, SpliceNegotiationDetails, SpliceNegotiationStatus, + }; #[test] fn test_channel_details_serialization() { @@ -783,6 +923,25 @@ mod tests { is_dust: false, }], current_dust_exposure_msat: Some(150_000), + splice_details: Some(SpliceDetails { + negotiation: Some(SpliceNegotiationDetails { + status: SpliceNegotiationStatus::ConstructingTransaction, + is_initiator: true, + funding_feerate_sat_per_1000_weight: 1000, + new_channel_value_satoshis: Some(70_000), + txid: None, + contribution: None, + }), + candidates: vec![SpliceCandidateDetails { + txid: bitcoin::Txid::from_slice(&[7; 32]).unwrap(), + new_channel_value_satoshis: 60_000, + contribution: None, + confirmations: 3, + confirmations_required: Some(6), + }], + sent_splice_locked_txid: None, + received_splice_locked_txid: Some(bitcoin::Txid::from_slice(&[7; 32]).unwrap()), + }), }; let mut buffer = Vec::new(); channel_details.write(&mut buffer).unwrap(); diff --git a/lightning/src/ln/splicing_tests.rs b/lightning/src/ln/splicing_tests.rs index f480c4e9bc0..57429864ecd 100644 --- a/lightning/src/ln/splicing_tests.rs +++ b/lightning/src/ln/splicing_tests.rs @@ -22,6 +22,7 @@ use crate::ln::channel::{ DISCONNECT_PEER_AWAITING_RESPONSE_TICKS, FEE_SPIKE_BUFFER_FEE_INCREASE_MULTIPLE, MIN_CHANNEL_VALUE_SATOSHIS, }; +use crate::ln::channel_state::SpliceNegotiationStatus; use crate::ln::channelmanager::{provided_init_features, PaymentId, BREAKDOWN_TIMEOUT}; use crate::ln::functional_test_utils::*; use crate::ln::funding::{FundingContribution, FundingContributionError, FundingTemplate}; @@ -10248,3 +10249,335 @@ fn test_splice_out_maximum_includes_pending_claimed_inbound_htlc() { assert!(nodes[1].node.splice_channel(&channel_id, &node_id_0).is_ok()); } + +#[test] +fn test_channel_details_pending_splice() { + // Test that `ChannelDetails::splice_details` reflects pending splice state throughout + // negotiation, signing, RBF, restarts, and locking. + let chanmon_cfgs = create_chanmon_cfgs(2); + let node_cfgs = create_node_cfgs(2, &chanmon_cfgs); + let (persister_0, persister_1); + let (chain_monitor_0, chain_monitor_1); + let node_chanmgrs = create_node_chanmgrs(2, &node_cfgs, &[None, None]); + let (node_0, node_1); + let mut 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 initial_channel_value_sat = 100_000; + let (_, _, channel_id, _) = + create_announced_chan_between_nodes_with_value(&nodes, 0, 1, initial_channel_value_sat, 0); + + let splice_details = |node: &Node<'_, '_, '_>| { + node.node + .list_channels() + .iter() + .find(|channel| channel.channel_id == channel_id) + .unwrap() + .splice_details + .clone() + }; + + // No splice is pending yet. + assert_eq!(splice_details(&nodes[0]), None); + assert_eq!(splice_details(&nodes[1]), None); + + let added_value = Amount::from_sat(50_000); + provide_utxo_reserves(&nodes, 2, added_value * 2); + + // Contributing funds alone does not start the negotiation; that happens once the channel + // becomes quiescent and splice_init is sent. + let contribution = do_initiate_splice_in(&nodes[0], &nodes[1], channel_id, added_value); + assert_eq!(splice_details(&nodes[0]), None); + assert_eq!(splice_details(&nodes[1]), None); + + let new_channel_value_sat = + (initial_channel_value_sat as i64 + contribution.net_value().to_sat()) as u64; + + let stfu_init = get_event_msg!(nodes[0], MessageSendEvent::SendStfu, node_id_1); + nodes[1].node.handle_stfu(node_id_0, &stfu_init); + let stfu_ack = get_event_msg!(nodes[1], MessageSendEvent::SendStfu, node_id_0); + nodes[0].node.handle_stfu(node_id_1, &stfu_ack); + + // Once quiescent, the initiator sends splice_init and awaits the counterparty's splice_ack. + // The new channel value is not yet known as it depends on the counterparty's contribution. + let details = splice_details(&nodes[0]).unwrap(); + let negotiation = details.negotiation.unwrap(); + assert_eq!(negotiation.status, SpliceNegotiationStatus::AwaitingAck); + assert!(negotiation.is_initiator); + assert_eq!(negotiation.funding_feerate_sat_per_1000_weight, FEERATE_FLOOR_SATS_PER_KW); + assert_eq!(negotiation.new_channel_value_satoshis, None); + assert_eq!(negotiation.txid, None); + assert_eq!(negotiation.contribution, Some(contribution.clone())); + assert!(details.candidates.is_empty()); + assert_eq!(splice_details(&nodes[1]), None); + + let splice_init = get_event_msg!(nodes[0], MessageSendEvent::SendSpliceInit, node_id_1); + nodes[1].node.handle_splice_init(node_id_0, &splice_init); + + // The acceptor starts constructing the transaction upon receiving splice_init, at which + // point both contributions are known. + let details = splice_details(&nodes[1]).unwrap(); + let negotiation = details.negotiation.unwrap(); + assert_eq!(negotiation.status, SpliceNegotiationStatus::ConstructingTransaction); + assert!(!negotiation.is_initiator); + assert_eq!(negotiation.funding_feerate_sat_per_1000_weight, FEERATE_FLOOR_SATS_PER_KW); + assert_eq!(negotiation.new_channel_value_satoshis, Some(new_channel_value_sat)); + assert_eq!(negotiation.txid, None); + assert_eq!(negotiation.contribution, None); + assert!(details.candidates.is_empty()); + + let splice_ack = get_event_msg!(nodes[1], MessageSendEvent::SendSpliceAck, node_id_0); + nodes[0].node.handle_splice_ack(node_id_1, &splice_ack); + + let negotiation = splice_details(&nodes[0]).unwrap().negotiation.unwrap(); + assert_eq!(negotiation.status, SpliceNegotiationStatus::ConstructingTransaction); + assert!(negotiation.is_initiator); + assert_eq!(negotiation.new_channel_value_satoshis, Some(new_channel_value_sat)); + assert_eq!(negotiation.txid, None); + + let new_funding_script = chan_utils::make_funding_redeemscript( + &splice_init.funding_pubkey, + &splice_ack.funding_pubkey, + ) + .to_p2wsh(); + + complete_interactive_funding_negotiation( + &nodes[0], + &nodes[1], + channel_id, + contribution.clone(), + new_funding_script.clone(), + ); + + // Once construction completes, the negotiation awaits signatures and the txid is known. + let negotiation_0 = splice_details(&nodes[0]).unwrap().negotiation.unwrap(); + let negotiation_1 = splice_details(&nodes[1]).unwrap().negotiation.unwrap(); + assert_eq!(negotiation_0.status, SpliceNegotiationStatus::AwaitingSignatures); + assert_eq!(negotiation_1.status, SpliceNegotiationStatus::AwaitingSignatures); + assert!(negotiation_0.txid.is_some()); + assert_eq!(negotiation_0.txid, negotiation_1.txid); + assert_eq!(negotiation_0.new_channel_value_satoshis, Some(new_channel_value_sat)); + assert_eq!(negotiation_0.contribution, Some(contribution.clone())); + assert_eq!(negotiation_1.contribution, None); + + let (splice_tx, splice_locked) = sign_interactive_funding_tx(&nodes[0], &nodes[1], false, None); + assert!(splice_locked.is_none()); + assert_eq!(negotiation_0.txid, Some(splice_tx.compute_txid())); + + expect_splice_pending_event(&nodes[0], &node_id_1); + expect_splice_pending_event(&nodes[1], &node_id_0); + + // With signatures exchanged, the negotiated splice is a candidate awaiting confirmations. + let details = splice_details(&nodes[0]).unwrap(); + assert_eq!(details.negotiation, None); + assert_eq!(details.candidates.len(), 1); + assert_eq!(details.candidates[0].txid, splice_tx.compute_txid()); + assert_eq!(details.candidates[0].new_channel_value_satoshis, new_channel_value_sat); + assert_eq!(details.candidates[0].contribution, Some(contribution.clone())); + assert_eq!(details.candidates[0].confirmations, 0); + assert_eq!(details.candidates[0].confirmations_required, Some(6)); + assert_eq!(details.sent_splice_locked_txid, None); + assert_eq!(details.received_splice_locked_txid, None); + + // The acceptor did not contribute to the splice. + let details = splice_details(&nodes[1]).unwrap(); + assert_eq!(details.negotiation, None); + assert_eq!(details.candidates.len(), 1); + assert_eq!(details.candidates[0].txid, splice_tx.compute_txid()); + assert_eq!(details.candidates[0].contribution, None); + + // Initiate an RBF attempt at a higher feerate. + provide_utxo_reserves(&nodes, 2, added_value * 2); + let rbf_feerate_sat_per_kwu = FEERATE_FLOOR_SATS_PER_KW as u64 + 25; + let rbf_feerate = FeeRate::from_sat_per_kwu(rbf_feerate_sat_per_kwu); + let rbf_contribution = do_initiate_rbf_splice_in(&nodes[0], &nodes[1], channel_id, rbf_feerate); + complete_rbf_handshake(&nodes[0], &nodes[1]); + + // The RBF negotiation is reported alongside the still-pending original candidate. + let details = splice_details(&nodes[0]).unwrap(); + let negotiation = details.negotiation.unwrap(); + assert_eq!(negotiation.status, SpliceNegotiationStatus::ConstructingTransaction); + assert_eq!(negotiation.funding_feerate_sat_per_1000_weight, rbf_feerate_sat_per_kwu as u32); + assert_eq!(negotiation.contribution, Some(rbf_contribution.clone())); + assert_eq!(details.candidates.len(), 1); + assert_eq!(details.candidates[0].txid, splice_tx.compute_txid()); + assert_eq!(details.candidates[0].contribution, Some(contribution.clone())); + + let rbf_channel_value_sat = + (initial_channel_value_sat as i64 + rbf_contribution.net_value().to_sat()) as u64; + + complete_interactive_funding_negotiation( + &nodes[0], + &nodes[1], + channel_id, + rbf_contribution.clone(), + new_funding_script, + ); + let (rbf_tx, splice_locked) = + sign_interactive_funding_tx(&nodes[0], &nodes[1], false, Some(splice_tx.compute_txid())); + assert!(splice_locked.is_none()); + + expect_splice_pending_event(&nodes[0], &node_id_1); + expect_splice_pending_event(&nodes[1], &node_id_0); + + // Both the original splice and its RBF replacement are candidates, in negotiation order. + let details = splice_details(&nodes[0]).unwrap(); + assert_eq!(details.negotiation, None); + assert_eq!(details.candidates.len(), 2); + assert_eq!(details.candidates[0].txid, splice_tx.compute_txid()); + assert_eq!(details.candidates[0].contribution, Some(contribution.clone())); + assert_eq!(details.candidates[1].txid, rbf_tx.compute_txid()); + assert_eq!(details.candidates[1].new_channel_value_satoshis, rbf_channel_value_sat); + assert_eq!(details.candidates[1].contribution, Some(rbf_contribution.clone())); + + let details = splice_details(&nodes[1]).unwrap(); + assert_eq!(details.candidates.len(), 2); + assert_eq!(details.candidates[1].contribution, None); + + // Pending splice state, including per-candidate contributions, survives a restart. + let encoded_monitor_0 = get_monitor!(nodes[0], channel_id).encode(); + reload_node!( + nodes[0], + &nodes[0].node.encode(), + &[&encoded_monitor_0], + persister_0, + chain_monitor_0, + node_0 + ); + let encoded_monitor_1 = get_monitor!(nodes[1], channel_id).encode(); + reload_node!( + nodes[1], + &nodes[1].node.encode(), + &[&encoded_monitor_1], + persister_1, + chain_monitor_1, + node_1 + ); + + let details = splice_details(&nodes[0]).unwrap(); + assert_eq!(details.negotiation, None); + assert_eq!(details.candidates.len(), 2); + assert_eq!(details.candidates[0].txid, splice_tx.compute_txid()); + assert_eq!(details.candidates[0].contribution, Some(contribution)); + assert_eq!(details.candidates[1].txid, rbf_tx.compute_txid()); + assert_eq!(details.candidates[1].contribution, Some(rbf_contribution)); + + let details = splice_details(&nodes[1]).unwrap(); + assert_eq!(details.candidates.len(), 2); + assert!(details.candidates.iter().all(|candidate| candidate.contribution.is_none())); + + let mut reconnect_args = ReconnectArgs::new(&nodes[0], &nodes[1]); + reconnect_args.send_announcement_sigs = (true, true); + reconnect_nodes(reconnect_args); + + // Mine the RBF transaction; only its candidate accrues confirmations. + mine_transaction(&nodes[0], &rbf_tx); + mine_transaction(&nodes[1], &rbf_tx); + + let details = splice_details(&nodes[0]).unwrap(); + assert_eq!(details.candidates[0].confirmations, 0); + assert_eq!(details.candidates[1].confirmations, 1); + + connect_blocks(&nodes[0], ANTI_REORG_DELAY - 1); + connect_blocks(&nodes[1], ANTI_REORG_DELAY - 1); + + // Once sufficiently confirmed, the splice_locked we sent is reflected in the details until + // the counterparty's splice_locked is received and the splice is promoted. + let splice_locked = get_event_msg!(nodes[0], MessageSendEvent::SendSpliceLocked, node_id_1); + let details = splice_details(&nodes[0]).unwrap(); + assert_eq!(details.sent_splice_locked_txid, Some(rbf_tx.compute_txid())); + assert_eq!(details.received_splice_locked_txid, None); + assert_eq!(details.candidates[1].confirmations, ANTI_REORG_DELAY); + + lock_splice(&nodes[0], &nodes[1], &splice_locked, false, &[splice_tx.compute_txid()]); + + // The splice is no longer pending once promoted. + assert_eq!(splice_details(&nodes[0]), None); + assert_eq!(splice_details(&nodes[1]), None); +} + +#[test] +fn test_channel_details_first_contribution_on_rbf() { + // When the counterparty's splice did not include a contribution from us and our first + // contribution comes in an RBF round we initiate, the in-flight contribution must not be + // attributed to the negotiated counterparty-only candidate. + 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, &[None, None]); + 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 initial_channel_value_sat = 100_000; + let (_, _, channel_id, _) = + create_announced_chan_between_nodes_with_value(&nodes, 0, 1, initial_channel_value_sat, 0); + + let added_value = Amount::from_sat(50_000); + provide_utxo_reserves(&nodes, 2, added_value * 2); + + // Splice initiated by node 1; node 0 does not contribute. + let contribution = do_initiate_splice_in(&nodes[1], &nodes[0], channel_id, added_value); + let (splice_tx, _) = splice_channel(&nodes[1], &nodes[0], channel_id, contribution); + + // Node 0 initiates an RBF, contributing for the first time. + let rbf_feerate = FeeRate::from_sat_per_kwu(FEERATE_FLOOR_SATS_PER_KW as u64 + 25); + let funding_template = nodes[0].node.splice_channel(&channel_id, &node_id_1).unwrap(); + let wallet = WalletSync::new(Arc::clone(&nodes[0].wallet_source), nodes[0].logger); + let rbf_contribution = funding_template + .without_prior_contribution(rbf_feerate, FeeRate::MAX) + .with_coin_selection_source_sync(&wallet) + .add_value(added_value) + .unwrap() + .build() + .unwrap(); + nodes[0] + .node + .funding_contributed(&channel_id, &node_id_1, rbf_contribution.clone(), None) + .unwrap(); + complete_rbf_handshake(&nodes[0], &nodes[1]); + let _ = get_event_msg!(nodes[0], MessageSendEvent::SendTxAddInput, node_id_1); + + // While the RBF is being negotiated, node 0's contribution belongs to the negotiation, not + // to the negotiated counterparty-only candidate. + let channels = nodes[0].node.list_channels(); + let details = channels[0].splice_details.as_ref().unwrap(); + assert_eq!(details.negotiation.as_ref().unwrap().contribution, Some(rbf_contribution.clone())); + assert_eq!(details.candidates.len(), 1); + assert_eq!(details.candidates[0].txid, splice_tx.compute_txid()); + assert_eq!(details.candidates[0].contribution, None); + + // Node 1 adjusted its prior contribution for the RBF round; the negotiated candidate keeps + // its original contribution. + let channels = nodes[1].node.list_channels(); + let details = channels[0].splice_details.as_ref().unwrap(); + assert!(details.negotiation.as_ref().unwrap().contribution.is_some()); + assert!(details.candidates[0].contribution.is_some()); + + // Abort the negotiation via disconnect. + nodes[0].node.peer_disconnected(node_id_1); + nodes[1].node.peer_disconnected(node_id_0); + + expect_splice_failed_events( + &nodes[0], + &channel_id, + rbf_contribution, + NegotiationFailureReason::PeerDisconnected, + ); + // Node 1 did not initiate the RBF round and its contribution to it (the prior round's + // contribution adjusted to the new feerate) has no inputs or outputs unique from the prior + // round, so no events are emitted. + assert!(nodes[1].node.get_and_clear_pending_events().is_empty()); + + // After the reset, the contribution alignment is restored on both nodes. + let channels = nodes[0].node.list_channels(); + let details = channels[0].splice_details.as_ref().unwrap(); + assert_eq!(details.negotiation, None); + assert_eq!(details.candidates[0].contribution, None); + let channels = nodes[1].node.list_channels(); + let details = channels[0].splice_details.as_ref().unwrap(); + assert_eq!(details.negotiation, None); + assert!(details.candidates[0].contribution.is_some()); +} diff --git a/lightning/src/routing/router.rs b/lightning/src/routing/router.rs index 364bd86704e..1de97207d1e 100644 --- a/lightning/src/routing/router.rs +++ b/lightning/src/routing/router.rs @@ -4173,6 +4173,7 @@ mod tests { pending_inbound_htlcs: Vec::new(), pending_outbound_htlcs: Vec::new(), current_dust_exposure_msat: None, + splice_details: None, } } @@ -9676,6 +9677,7 @@ pub(crate) mod bench_utils { pending_inbound_htlcs: Vec::new(), pending_outbound_htlcs: Vec::new(), current_dust_exposure_msat: None, + splice_details: None, } }