diff --git a/lightning/src/chain/channelmonitor.rs b/lightning/src/chain/channelmonitor.rs index a8d055a9c5b..d07d86c26e3 100644 --- a/lightning/src/chain/channelmonitor.rs +++ b/lightning/src/chain/channelmonitor.rs @@ -4039,9 +4039,14 @@ impl ChannelMonitorImpl { } if let Some(parent_funding_txid) = channel_parameters.splice_parent_funding_txid.as_ref() { - // Only one splice can be negotiated at a time after we've exchanged `channel_ready` - // (implying our funding is confirmed) that spends our currently locked funding. - if !self.pending_funding.is_empty() { + // Multiple RBF candidates for the same splice are allowed (they share the same + // parent funding txid). A new splice with a different parent while one is pending + // is not allowed. + let has_different_parent = self.pending_funding.iter().any(|funding| { + funding.channel_parameters.splice_parent_funding_txid.as_ref() + != Some(parent_funding_txid) + }); + if has_different_parent { log_error!( logger, "Negotiated splice while channel is pending channel_ready/splice_locked" diff --git a/lightning/src/ln/channel.rs b/lightning/src/ln/channel.rs index 9361cd3c749..a54c032100a 100644 --- a/lightning/src/ln/channel.rs +++ b/lightning/src/ln/channel.rs @@ -55,7 +55,9 @@ use crate::ln::channelmanager::{ PendingHTLCStatus, RAACommitmentOrder, SentHTLCId, BREAKDOWN_TIMEOUT, MAX_LOCAL_BREAKDOWN_TIMEOUT, MIN_CLTV_EXPIRY_DELTA, }; -use crate::ln::funding::{FundingContribution, FundingTemplate, FundingTxInput}; +use crate::ln::funding::{ + FeeRateAdjustmentError, FundingContribution, FundingTemplate, FundingTxInput, +}; use crate::ln::interactivetxs::{ AbortReason, HandleTxCompleteValue, InteractiveTxConstructor, InteractiveTxConstructorArgs, InteractiveTxMessageSend, InteractiveTxSigningSession, SharedOwnedInput, SharedOwnedOutput, @@ -2896,6 +2898,17 @@ struct PendingFunding { /// The funding txid used in the `splice_locked` received from the counterparty. received_funding_txid: Option, + + /// The feerate used in the last successfully negotiated funding transaction. + /// Used for validating the 25/24 feerate increase rule on RBF attempts. + last_funding_feerate_sat_per_1000_weight: Option, + + /// The funding contributions from all explicit splice/RBF attempts on this channel. + /// Each entry reflects the feerate-adjusted contribution that was actually used in that + /// negotiation. The last entry is re-used when the counterparty initiates an RBF and we + /// have no pending `QuiescentAction`. When re-used as acceptor, the last entry is replaced + /// with the version adjusted for the new feerate. + contributions: Vec, } impl_writeable_tlv_based!(PendingFunding, { @@ -2903,6 +2916,8 @@ impl_writeable_tlv_based!(PendingFunding, { (3, negotiated_candidates, required_vec), (5, sent_funding_txid, option), (7, received_funding_txid, option), + (9, last_funding_feerate_sat_per_1000_weight, option), + (11, contributions, optional_vec), }); #[derive(Debug)] @@ -2959,9 +2974,111 @@ impl FundingNegotiation { FundingNegotiation::AwaitingSignatures { is_initiator, .. } => *is_initiator, } } + fn for_initiator( + funding: FundingScope, context: &ChannelContext, + funding_negotiation_context: FundingNegotiationContext, entropy_source: &ES, + holder_node_id: &PublicKey, + ) -> (FundingNegotiation, Option) { + let (interactive_tx_constructor, tx_msg_opt) = funding_negotiation_context + .into_interactive_tx_constructor( + context, + &funding, + entropy_source, + holder_node_id.clone(), + ); + debug_assert!(tx_msg_opt.is_some()); + + ( + FundingNegotiation::ConstructingTransaction { funding, interactive_tx_constructor }, + tx_msg_opt, + ) + } + + fn for_acceptor( + funding: FundingScope, context: &ChannelContext, entropy_source: &ES, + holder_node_id: &PublicKey, our_funding_contribution: SignedAmount, + prev_funding_input: SharedOwnedInput, locktime: u32, feerate_sat_per_1000_weight: u32, + our_funding_inputs: Vec, our_funding_outputs: Vec, + ) -> FundingNegotiation { + let funding_negotiation_context = FundingNegotiationContext { + is_initiator: false, + our_funding_contribution, + funding_tx_locktime: LockTime::from_consensus(locktime), + funding_feerate_sat_per_1000_weight: feerate_sat_per_1000_weight, + shared_funding_input: Some(prev_funding_input), + our_funding_inputs, + our_funding_outputs, + }; + + let (interactive_tx_constructor, first_message) = funding_negotiation_context + .into_interactive_tx_constructor( + context, + &funding, + entropy_source, + holder_node_id.clone(), + ); + debug_assert!(first_message.is_none()); + + FundingNegotiation::ConstructingTransaction { funding, interactive_tx_constructor } + } } impl PendingFunding { + fn awaiting_ack_context( + &self, msg_name: &str, + ) -> Result<(&FundingNegotiationContext, &PublicKey), ChannelError> { + match &self.funding_negotiation { + Some(FundingNegotiation::AwaitingAck { context, new_holder_funding_key }) => { + Ok((context, new_holder_funding_key)) + }, + Some(FundingNegotiation::ConstructingTransaction { .. }) + | Some(FundingNegotiation::AwaitingSignatures { .. }) => Err(ChannelError::WarnAndDisconnect( + format!("Got unexpected {}; funding negotiation already in progress", msg_name,), + )), + None => Err(ChannelError::Ignore(format!( + "Got unexpected {}; no funding negotiation in progress", + msg_name, + ))), + } + } + + fn take_awaiting_ack_context( + &mut self, msg_name: &str, + ) -> Result { + match self.funding_negotiation.take() { + Some(FundingNegotiation::AwaitingAck { context, .. }) => Ok(context), + Some(other) => { + self.funding_negotiation = Some(other); + Err(ChannelError::WarnAndDisconnect(format!( + "Got unexpected {}; funding negotiation already in progress", + msg_name, + ))) + }, + None => Err(ChannelError::Ignore(format!( + "Got unexpected {}; no funding negotiation in progress", + msg_name, + ))), + } + } + + fn contributed_inputs(&self) -> impl Iterator + '_ { + self.contributions.iter().flat_map(|c| c.contributed_inputs()) + } + + fn contributed_outputs(&self) -> impl Iterator + '_ { + self.contributions.iter().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()) + } + + 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()) + } + fn check_get_splice_locked( &mut self, context: &ChannelContext, confirmed_funding_index: usize, height: u32, ) -> Option { @@ -3010,28 +3127,10 @@ pub(super) enum QuiescentError { FailSplice(SpliceFundingFailed), } -impl From for QuiescentError { - fn from(action: QuiescentAction) -> Self { - match action { - QuiescentAction::Splice { contribution, .. } => { - let (contributed_inputs, contributed_outputs) = - contribution.into_contributed_inputs_and_outputs(); - return QuiescentError::FailSplice(SpliceFundingFailed { - funding_txo: None, - channel_type: None, - contributed_inputs, - contributed_outputs, - }); - }, - #[cfg(any(test, fuzzing, feature = "_test_utils"))] - QuiescentAction::DoNothing => QuiescentError::DoNothing, - } - } -} - pub(crate) enum StfuResponse { Stfu(msgs::Stfu), SpliceInit(msgs::SpliceInit), + TxInitRbf(msgs::TxInitRbf), } /// Wrapper around a [`Transaction`] useful for caching the result of [`Transaction::compute_txid`]. @@ -6565,7 +6664,7 @@ pub struct SpliceFundingFailed { } macro_rules! maybe_create_splice_funding_failed { - ($funded_channel: expr, $pending_splice: expr, $get: ident, $contributed_inputs_and_outputs: ident) => {{ + ($funded_channel: expr, $pending_splice: expr, $pending_splice_ref: expr, $get: ident, $contributed_inputs_and_outputs: ident) => {{ $pending_splice .and_then(|pending_splice| pending_splice.funding_negotiation.$get()) .filter(|funding_negotiation| funding_negotiation.is_initiator()) @@ -6579,7 +6678,7 @@ macro_rules! maybe_create_splice_funding_failed { .as_funding() .map(|funding| funding.get_channel_type().clone()); - let (contributed_inputs, contributed_outputs) = match funding_negotiation { + let (mut contributed_inputs, mut contributed_outputs) = match funding_negotiation { FundingNegotiation::AwaitingAck { context, .. } => { context.$contributed_inputs_and_outputs() }, @@ -6595,6 +6694,15 @@ macro_rules! maybe_create_splice_funding_failed { .$contributed_inputs_and_outputs(), }; + if let Some(pending_splice) = $pending_splice_ref { + for input in pending_splice.prior_contributed_inputs() { + contributed_inputs.retain(|i| *i != input); + } + for output in pending_splice.prior_contributed_outputs() { + contributed_outputs.retain(|o| *o != *output); + } + } + SpliceFundingFailed { funding_txo, channel_type, @@ -6628,11 +6736,19 @@ where shutdown_result } - fn abandon_quiescent_action(&mut self) -> Option { - match self.quiescent_action.take() { - Some(QuiescentAction::Splice { contribution, .. }) => { - let (inputs, outputs) = contribution.into_contributed_inputs_and_outputs(); - Some(SpliceFundingFailed { + fn quiescent_action_into_error(&self, action: QuiescentAction) -> QuiescentError { + match action { + QuiescentAction::Splice { contribution, .. } => { + let (mut inputs, mut outputs) = contribution.into_contributed_inputs_and_outputs(); + if let Some(ref pending_splice) = self.pending_splice { + for input in pending_splice.contributed_inputs() { + inputs.retain(|i| *i != input); + } + for output in pending_splice.contributed_outputs() { + outputs.retain(|o| *o != *output); + } + } + QuiescentError::FailSplice(SpliceFundingFailed { funding_txo: None, channel_type: None, contributed_inputs: inputs, @@ -6640,11 +6756,20 @@ where }) }, #[cfg(any(test, fuzzing, feature = "_test_utils"))] - Some(quiescent_action) => { - self.quiescent_action = Some(quiescent_action); + QuiescentAction::DoNothing => QuiescentError::DoNothing, + } + } + + fn abandon_quiescent_action(&mut self) -> Option { + let action = self.quiescent_action.take()?; + match self.quiescent_action_into_error(action) { + QuiescentError::FailSplice(failed) => Some(failed), + #[cfg(any(test, fuzzing, feature = "_test_utils"))] + QuiescentError::DoNothing => None, + _ => { + debug_assert!(false); None }, - None => None, } } @@ -6743,19 +6868,32 @@ where fn reset_pending_splice_state(&mut self) -> Option { debug_assert!(self.should_reset_pending_splice_state(true)); - debug_assert!( - self.context.interactive_tx_signing_session.is_none() - || !self - .context - .interactive_tx_signing_session - .as_ref() - .expect("We have a pending splice awaiting signatures") - .has_received_commitment_signed() - ); + + // Only clear the signing session if the current round is mid-signing. When an earlier + // round completed signing and a later RBF round is in AwaitingAck or + // ConstructingTransaction, the session belongs to the prior round and must be preserved. + let current_is_awaiting_signatures = self + .pending_splice + .as_ref() + .and_then(|ps| ps.funding_negotiation.as_ref()) + .map(|fn_| matches!(fn_, FundingNegotiation::AwaitingSignatures { .. })) + .unwrap_or(false); + if current_is_awaiting_signatures { + debug_assert!( + self.context.interactive_tx_signing_session.is_none() + || !self + .context + .interactive_tx_signing_session + .as_ref() + .expect("We have a pending splice awaiting signatures") + .has_received_commitment_signed() + ); + } let splice_funding_failed = maybe_create_splice_funding_failed!( self, self.pending_splice.as_mut(), + self.pending_splice.as_ref(), take, into_contributed_inputs_and_outputs ); @@ -6765,7 +6903,9 @@ where } self.context.channel_state.clear_quiescent(); - self.context.interactive_tx_signing_session.take(); + if current_is_awaiting_signatures { + self.context.interactive_tx_signing_session.take(); + } splice_funding_failed } @@ -6778,6 +6918,7 @@ where maybe_create_splice_funding_failed!( self, self.pending_splice.as_ref(), + self.pending_splice.as_ref(), as_ref, to_contributed_inputs_and_outputs ) @@ -11775,6 +11916,126 @@ where Ok(FundingTemplate::new(Some(shared_input), min_feerate, max_feerate)) } + /// Initiate an RBF of a pending splice transaction. + pub fn rbf_channel( + &self, min_feerate: FeeRate, max_feerate: FeeRate, + ) -> Result { + if self.holder_commitment_point.current_point().is_none() { + return Err(APIError::APIMisuseError { + err: format!( + "Channel {} cannot RBF until a payment is routed", + self.context.channel_id(), + ), + }); + } + + if self.quiescent_action.is_some() { + return Err(APIError::APIMisuseError { + err: format!( + "Channel {} cannot RBF as one is waiting to be negotiated", + self.context.channel_id(), + ), + }); + } + + if !self.context.is_usable() { + return Err(APIError::APIMisuseError { + err: format!( + "Channel {} cannot RBF as it is either pending open/close", + self.context.channel_id() + ), + }); + } + + if self.context.minimum_depth(&self.funding) == Some(0) { + return Err(APIError::APIMisuseError { + err: format!( + "Channel {} has option_zeroconf, cannot RBF splice", + self.context.channel_id(), + ), + }); + } + + if min_feerate > max_feerate { + return Err(APIError::APIMisuseError { + err: format!( + "Channel {} min_feerate {} exceeds max_feerate {}", + self.context.channel_id(), + min_feerate, + max_feerate, + ), + }); + } + + self.can_initiate_rbf(min_feerate).map_err(|err| APIError::APIMisuseError { err })?; + + let funding_txo = self.funding.get_funding_txo().expect("funding_txo should be set"); + let previous_utxo = + self.funding.get_funding_output().expect("funding_output should be set"); + let shared_input = Input { + outpoint: funding_txo.into_bitcoin_outpoint(), + previous_utxo, + satisfaction_weight: EMPTY_SCRIPT_SIG_WEIGHT + FUNDING_TRANSACTION_WITNESS_WEIGHT, + }; + + Ok(FundingTemplate::new(Some(shared_input), min_feerate, max_feerate)) + } + + fn can_initiate_rbf(&self, feerate: FeeRate) -> Result<(), String> { + let pending_splice = match &self.pending_splice { + Some(pending_splice) => pending_splice, + None => { + return Err(format!( + "Channel {} has no pending splice to RBF", + self.context.channel_id(), + )); + }, + }; + + if pending_splice.funding_negotiation.is_some() { + return Err(format!( + "Channel {} cannot RBF as a funding negotiation is already in progress", + self.context.channel_id(), + )); + } + + if pending_splice.sent_funding_txid.is_some() { + return Err(format!( + "Channel {} already sent splice_locked, cannot RBF", + self.context.channel_id(), + )); + } + + if pending_splice.received_funding_txid.is_some() { + return Err(format!( + "Channel {} counterparty already sent splice_locked, cannot RBF", + self.context.channel_id(), + )); + } + + if pending_splice.negotiated_candidates.is_empty() { + return Err(format!( + "Channel {} has no negotiated splice candidates to RBF", + self.context.channel_id(), + )); + } + + // Check the 25/24 feerate increase rule + let new_feerate = feerate.to_sat_per_kwu() as u32; + if let Some(prev_feerate) = pending_splice.last_funding_feerate_sat_per_1000_weight { + if (new_feerate as u64) * 24 < (prev_feerate as u64) * 25 { + return Err(format!( + "Channel {} RBF feerate {} is less than 25/24 of the previous feerate {}", + self.context.channel_id(), + new_feerate, + prev_feerate, + )); + } + } + + Ok(()) + } + pub fn funding_contributed( &mut self, contribution: FundingContribution, locktime: LockTime, logger: &L, ) -> Result, QuiescentError> { @@ -11782,9 +12043,16 @@ where if let Some(QuiescentAction::Splice { contribution: existing, .. }) = &self.quiescent_action { + let pending_splice = self.pending_splice.as_ref(); + let prior_inputs = pending_splice + .into_iter() + .flat_map(|pending_splice| pending_splice.contributed_inputs()); + let prior_outputs = pending_splice + .into_iter() + .flat_map(|pending_splice| pending_splice.contributed_outputs()); return match contribution.into_unique_contributions( - existing.contributed_inputs(), - existing.contributed_outputs(), + existing.contributed_inputs().chain(prior_inputs), + existing.contributed_outputs().chain(prior_outputs), ) { None => Err(QuiescentError::DoNothing), Some((inputs, outputs)) => Err(QuiescentError::DiscardFunding { inputs, outputs }), @@ -11798,17 +12066,21 @@ where .filter(|funding_negotiation| funding_negotiation.is_initiator()); if let Some(funding_negotiation) = initiated_funding_negotiation { + let pending_splice = + self.pending_splice.as_ref().expect("funding negotiation implies pending splice"); + let prior_inputs = pending_splice.contributed_inputs(); + let prior_outputs = pending_splice.contributed_outputs(); let unique_contributions = match funding_negotiation { FundingNegotiation::AwaitingAck { context, .. } => contribution .into_unique_contributions( - context.contributed_inputs(), - context.contributed_outputs(), + context.contributed_inputs().chain(prior_inputs), + context.contributed_outputs().chain(prior_outputs), ), FundingNegotiation::ConstructingTransaction { interactive_tx_constructor, .. } => contribution.into_unique_contributions( - interactive_tx_constructor.contributed_inputs(), - interactive_tx_constructor.contributed_outputs(), + interactive_tx_constructor.contributed_inputs().chain(prior_inputs), + interactive_tx_constructor.contributed_outputs().chain(prior_outputs), ), FundingNegotiation::AwaitingSignatures { .. } => { let session = self @@ -11817,8 +12089,8 @@ where .as_ref() .expect("pending splice awaiting signatures"); contribution.into_unique_contributions( - session.contributed_inputs(), - session.contributed_outputs(), + session.contributed_inputs().chain(prior_inputs), + session.contributed_outputs().chain(prior_outputs), ) }, }; @@ -11898,6 +12170,8 @@ where negotiated_candidates: vec![], sent_funding_txid: None, received_funding_txid: None, + last_funding_feerate_sat_per_1000_weight: Some(funding_feerate_per_kw), + contributions: vec![], }); msgs::SpliceInit { @@ -11910,6 +12184,34 @@ where } } + fn send_tx_init_rbf(&mut self, context: FundingNegotiationContext) -> msgs::TxInitRbf { + let pending_splice = + self.pending_splice.as_mut().expect("pending_splice should exist for RBF"); + debug_assert!(!pending_splice.negotiated_candidates.is_empty()); + + let new_holder_funding_key = pending_splice + .negotiated_candidates + .first() + .unwrap() + .get_holder_pubkeys() + .funding_pubkey; + + let funding_feerate_per_kw = context.funding_feerate_sat_per_1000_weight; + let funding_contribution_satoshis = context.our_funding_contribution.to_sat(); + let locktime = context.funding_tx_locktime.to_consensus_u32(); + + pending_splice.funding_negotiation = + Some(FundingNegotiation::AwaitingAck { context, new_holder_funding_key }); + pending_splice.last_funding_feerate_sat_per_1000_weight = Some(funding_feerate_per_kw); + + msgs::TxInitRbf { + channel_id: self.context.channel_id, + locktime, + feerate_sat_per_1000_weight: funding_feerate_per_kw, + funding_output_contribution: Some(funding_contribution_satoshis), + } + } + #[cfg(test)] pub fn abandon_splice( &mut self, @@ -12090,11 +12392,9 @@ where Ok(()) } - pub(crate) fn splice_init( - &mut self, msg: &msgs::SpliceInit, entropy_source: &ES, holder_node_id: &PublicKey, - logger: &L, - ) -> Result { - let feerate = FeeRate::from_sat_per_kwu(msg.funding_feerate_per_kw as u64); + fn resolve_queued_contribution( + &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) @@ -12108,34 +12408,61 @@ where ); }) .ok(); - let our_funding_contribution = - holder_balance.and_then(|_| self.queued_funding_contribution()).and_then(|c| { - c.net_value_for_acceptor_at_feerate(feerate, holder_balance.unwrap()) - .map_err(|e| { + + let net_value = match holder_balance.and_then(|_| self.queued_funding_contribution()) { + Some(c) => { + match c.net_value_for_acceptor_at_feerate(feerate, holder_balance.unwrap()) { + Ok(net_value) => Some(net_value), + Err(e @ FeeRateAdjustmentError::FeeRateTooHigh { .. }) => { + return Err(ChannelError::WarnAndDisconnect(format!( + "Cannot accommodate initiator's feerate ({}) for channel {}: {}", + feerate, + self.context.channel_id(), + e, + ))); + }, + Err(e) => { log_info!( logger, - "Cannot accommodate initiator's feerate ({}) for channel {}: {}; \ - proceeding without contribution", + "Cannot accommodate initiator's feerate ({}) for channel {}: {}", feerate, self.context.channel_id(), e, ); - }) - .ok() - }); + None + }, + } + }, + None => None, + }; + + Ok((net_value, holder_balance)) + } + + pub(crate) fn splice_init( + &mut self, msg: &msgs::SpliceInit, entropy_source: &ES, holder_node_id: &PublicKey, + logger: &L, + ) -> Result { + let feerate = FeeRate::from_sat_per_kwu(msg.funding_feerate_per_kw as u64); + let (our_funding_contribution, holder_balance) = + self.resolve_queued_contribution(feerate, logger)?; let splice_funding = self.validate_splice_init(msg, our_funding_contribution.unwrap_or(SignedAmount::ZERO))?; - let (our_funding_inputs, our_funding_outputs) = if our_funding_contribution.is_some() { - self.take_queued_funding_contribution() - .expect("queued_funding_contribution was Some") - .for_acceptor_at_feerate(feerate, holder_balance.unwrap()) - .expect("feerate compatibility already checked") - .into_tx_parts() - } else { - Default::default() - }; + // Adjust for the feerate and clone so we can store it for future RBF re-use. + let (adjusted_contribution, our_funding_inputs, our_funding_outputs) = + if our_funding_contribution.is_some() { + let adjusted_contribution = self + .take_queued_funding_contribution() + .expect("queued_funding_contribution was Some") + .for_acceptor_at_feerate(feerate, holder_balance.unwrap()) + .expect("feerate compatibility already checked"); + let (inputs, outputs) = adjusted_contribution.clone().into_tx_parts(); + (Some(adjusted_contribution), inputs, outputs) + } else { + (None, Default::default(), Default::default()) + }; let our_funding_contribution = our_funding_contribution.unwrap_or(SignedAmount::ZERO); log_info!( @@ -12146,35 +12473,27 @@ where self.funding.get_value_satoshis(), ); + let new_funding_pubkey = splice_funding.get_holder_pubkeys().funding_pubkey; let prev_funding_input = self.funding.to_splice_funding_input(); - let funding_negotiation_context = FundingNegotiationContext { - is_initiator: false, + let funding_negotiation = FundingNegotiation::for_acceptor( + splice_funding, + &self.context, + entropy_source, + holder_node_id, our_funding_contribution, - funding_tx_locktime: LockTime::from_consensus(msg.locktime), - funding_feerate_sat_per_1000_weight: msg.funding_feerate_per_kw, - shared_funding_input: Some(prev_funding_input), + prev_funding_input, + msg.locktime, + msg.funding_feerate_per_kw, our_funding_inputs, our_funding_outputs, - }; - - let (interactive_tx_constructor, first_message) = funding_negotiation_context - .into_interactive_tx_constructor( - &self.context, - &splice_funding, - entropy_source, - holder_node_id.clone(), - ); - debug_assert!(first_message.is_none()); - - let new_funding_pubkey = splice_funding.get_holder_pubkeys().funding_pubkey; + ); self.pending_splice = Some(PendingFunding { - funding_negotiation: Some(FundingNegotiation::ConstructingTransaction { - funding: splice_funding, - interactive_tx_constructor, - }), + funding_negotiation: Some(funding_negotiation), negotiated_candidates: Vec::new(), received_funding_txid: None, sent_funding_txid: None, + last_funding_feerate_sat_per_1000_weight: Some(msg.funding_feerate_per_kw), + contributions: adjusted_contribution.into_iter().collect(), }); Ok(msgs::SpliceAck { @@ -12185,6 +12504,291 @@ where }) } + /// Checks during handling tx_init_rbf for an existing splice + fn validate_tx_init_rbf( + &self, msg: &msgs::TxInitRbf, our_funding_contribution: SignedAmount, + fee_estimator: &LowerBoundedFeeEstimator, + ) -> Result { + if self.holder_commitment_point.current_point().is_none() { + return Err(ChannelError::WarnAndDisconnect(format!( + "Channel {} commitment point needs to be advanced once before RBF", + self.context.channel_id(), + ))); + } + + if !self.context.channel_state.is_quiescent() { + return Err(ChannelError::WarnAndDisconnect("Quiescence needed for RBF".to_owned())); + } + + if self.context.minimum_depth(&self.funding) == Some(0) { + return Err(ChannelError::WarnAndDisconnect(format!( + "Channel {} has option_zeroconf, cannot RBF splice", + self.context.channel_id(), + ))); + } + + let pending_splice = match &self.pending_splice { + Some(pending_splice) => pending_splice, + None => { + return Err(ChannelError::WarnAndDisconnect(format!( + "Channel {} has no pending splice to RBF", + self.context.channel_id(), + ))); + }, + }; + + if pending_splice.funding_negotiation.is_some() { + return Err(ChannelError::WarnAndDisconnect(format!( + "Channel {} already has a funding negotiation in progress", + self.context.channel_id(), + ))); + } + + if pending_splice.received_funding_txid.is_some() { + return Err(ChannelError::WarnAndDisconnect(format!( + "Channel {} counterparty already sent splice_locked, cannot RBF", + self.context.channel_id(), + ))); + } + + if pending_splice.sent_funding_txid.is_some() { + return Err(ChannelError::WarnAndDisconnect(format!( + "Channel {} already sent splice_locked, cannot RBF", + self.context.channel_id(), + ))); + } + + let first_candidate = match pending_splice.negotiated_candidates.first() { + Some(candidate) => candidate, + None => { + return Err(ChannelError::WarnAndDisconnect(format!( + "Channel {} has no negotiated splice candidates to RBF", + self.context.channel_id(), + ))); + }, + }; + + // Check the 25/24 feerate increase rule + let prev_feerate = + pending_splice.last_funding_feerate_sat_per_1000_weight.unwrap_or_else(|| { + fee_estimator.bounded_sat_per_1000_weight(ConfirmationTarget::UrgentOnChainSweep) + }); + let new_feerate = msg.feerate_sat_per_1000_weight; + if (new_feerate as u64) * 24 < (prev_feerate as u64) * 25 { + return Err(ChannelError::WarnAndDisconnect(format!( + "Channel {} RBF feerate {} is less than 25/24 of the previous feerate {}", + self.context.channel_id(), + new_feerate, + prev_feerate, + ))); + } + + let their_funding_contribution = match msg.funding_output_contribution { + Some(value) => SignedAmount::from_sat(value), + None => SignedAmount::ZERO, + }; + + self.validate_splice_contributions(our_funding_contribution, their_funding_contribution) + .map_err(|e| ChannelError::WarnAndDisconnect(e))?; + + // Reuse funding pubkeys from the first negotiated candidate since all RBF candidates + // for the same splice share the same funding output script. + let holder_pubkeys = first_candidate.get_holder_pubkeys().clone(); + let counterparty_funding_pubkey = *first_candidate.counterparty_funding_pubkey(); + + Ok(FundingScope::for_splice( + &self.funding, + &self.context, + our_funding_contribution, + their_funding_contribution, + counterparty_funding_pubkey, + holder_pubkeys, + )) + } + + pub(crate) fn tx_init_rbf( + &mut self, msg: &msgs::TxInitRbf, entropy_source: &ES, holder_node_id: &PublicKey, + fee_estimator: &LowerBoundedFeeEstimator, logger: &L, + ) -> Result { + let feerate = FeeRate::from_sat_per_kwu(msg.feerate_sat_per_1000_weight as u64); + let (queued_net_value, holder_balance) = + self.resolve_queued_contribution(feerate, logger)?; + + // If no queued contribution, try prior contribution from previous negotiation. + // Failing here means the RBF would erase our splice — reject it. + let prior_net_value = if queued_net_value.is_some() { + None + } else if let Some(prior) = self + .pending_splice + .as_ref() + .and_then(|pending_splice| pending_splice.contributions.last()) + { + let net_value = holder_balance + .ok_or_else(|| { + ChannelError::WarnAndDisconnect(format!( + "Channel {} cannot accommodate RBF feerate for our prior contribution", + self.context.channel_id(), + )) + }) + .and_then(|holder_balance| { + prior.net_value_for_acceptor_at_feerate(feerate, holder_balance).map_err(|e| { + ChannelError::WarnAndDisconnect(format!( + "Channel {} cannot accommodate RBF feerate for our prior \ + contribution: {}", + self.context.channel_id(), + e + )) + }) + })?; + Some(net_value) + } else { + None + }; + + let our_funding_contribution = queued_net_value.or(prior_net_value); + + let rbf_funding = self.validate_tx_init_rbf( + msg, + our_funding_contribution.unwrap_or(SignedAmount::ZERO), + fee_estimator, + )?; + + // Consume the appropriate contribution source. + let (our_funding_inputs, our_funding_outputs) = if queued_net_value.is_some() { + let adjusted_contribution = self + .take_queued_funding_contribution() + .expect("queued_funding_contribution was Some") + .for_acceptor_at_feerate(feerate, holder_balance.unwrap()) + .expect("feerate compatibility already checked"); + self.pending_splice + .as_mut() + .expect("pending_splice is Some") + .contributions + .push(adjusted_contribution.clone()); + adjusted_contribution.into_tx_parts() + } else if prior_net_value.is_some() { + let prior_contribution = self + .pending_splice + .as_mut() + .expect("pending_splice is Some") + .contributions + .pop() + .expect("prior_net_value was Some"); + let adjusted_contribution = prior_contribution + .for_acceptor_at_feerate(feerate, holder_balance.unwrap()) + .expect("feerate compatibility already checked"); + self.pending_splice + .as_mut() + .expect("pending_splice is Some") + .contributions + .push(adjusted_contribution.clone()); + adjusted_contribution.into_tx_parts() + } else { + Default::default() + }; + + let our_funding_contribution = our_funding_contribution.unwrap_or(SignedAmount::ZERO); + + log_info!( + logger, + "Starting RBF funding negotiation for channel {} after receiving tx_init_rbf; channel value: {} sats", + self.context.channel_id, + rbf_funding.get_value_satoshis(), + ); + + let prev_funding_input = self.funding.to_splice_funding_input(); + let funding_negotiation = FundingNegotiation::for_acceptor( + rbf_funding, + &self.context, + entropy_source, + holder_node_id, + our_funding_contribution, + prev_funding_input, + msg.locktime, + msg.feerate_sat_per_1000_weight, + our_funding_inputs, + our_funding_outputs, + ); + let pending_splice = self.pending_splice.as_mut().expect("pending_splice should exist"); + pending_splice.funding_negotiation = Some(funding_negotiation); + pending_splice.last_funding_feerate_sat_per_1000_weight = + Some(msg.feerate_sat_per_1000_weight); + + Ok(msgs::TxAckRbf { + channel_id: self.context.channel_id, + funding_output_contribution: if our_funding_contribution != SignedAmount::ZERO { + Some(our_funding_contribution.to_sat()) + } else { + None + }, + }) + } + + fn validate_tx_ack_rbf(&self, msg: &msgs::TxAckRbf) -> Result { + let pending_splice = self + .pending_splice + .as_ref() + .ok_or_else(|| ChannelError::Ignore("Channel is not in pending splice".to_owned()))?; + + let (funding_negotiation_context, _) = pending_splice.awaiting_ack_context("tx_ack_rbf")?; + + let our_funding_contribution = funding_negotiation_context.our_funding_contribution; + let their_funding_contribution = match msg.funding_output_contribution { + Some(value) => SignedAmount::from_sat(value), + None => SignedAmount::ZERO, + }; + self.validate_splice_contributions(our_funding_contribution, their_funding_contribution) + .map_err(|e| ChannelError::WarnAndDisconnect(e))?; + + let first_candidate = pending_splice.negotiated_candidates.first().ok_or_else(|| { + ChannelError::WarnAndDisconnect("No negotiated splice candidates for RBF".to_owned()) + })?; + let holder_pubkeys = first_candidate.get_holder_pubkeys().clone(); + let counterparty_funding_pubkey = *first_candidate.counterparty_funding_pubkey(); + + Ok(FundingScope::for_splice( + &self.funding, + &self.context, + our_funding_contribution, + their_funding_contribution, + counterparty_funding_pubkey, + holder_pubkeys, + )) + } + + pub(crate) fn tx_ack_rbf( + &mut self, msg: &msgs::TxAckRbf, entropy_source: &ES, holder_node_id: &PublicKey, + logger: &L, + ) -> Result, ChannelError> { + let rbf_funding = self.validate_tx_ack_rbf(msg)?; + + log_info!( + logger, + "Starting RBF funding negotiation for channel {} after receiving tx_ack_rbf; channel value: {} sats", + self.context.channel_id, + rbf_funding.get_value_satoshis(), + ); + + let pending_splice = self + .pending_splice + .as_mut() + .expect("pending_splice existence validated in validate_tx_ack_rbf"); + let funding_negotiation_context = pending_splice + .take_awaiting_ack_context("tx_ack_rbf") + .expect("awaiting ack state validated in validate_tx_ack_rbf"); + + let (funding_negotiation, tx_msg_opt) = FundingNegotiation::for_initiator( + rbf_funding, + &self.context, + funding_negotiation_context, + entropy_source, + holder_node_id, + ); + pending_splice.funding_negotiation = Some(funding_negotiation); + + Ok(tx_msg_opt) + } + pub(crate) fn splice_ack( &mut self, msg: &msgs::SpliceAck, entropy_source: &ES, holder_node_id: &PublicKey, logger: &L, @@ -12199,33 +12803,24 @@ where self.funding.get_value_satoshis(), ); - let pending_splice = - self.pending_splice.as_mut().expect("We should have returned an error earlier!"); - // TODO: Good candidate for a let else statement once MSRV >= 1.65 - let funding_negotiation_context = - if let Some(FundingNegotiation::AwaitingAck { context, .. }) = - pending_splice.funding_negotiation.take() - { - context - } else { - panic!("We should have returned an error earlier!"); - }; - - let (interactive_tx_constructor, tx_msg_opt) = funding_negotiation_context - .into_interactive_tx_constructor( - &self.context, - &splice_funding, - entropy_source, - holder_node_id.clone(), - ); - debug_assert!(tx_msg_opt.is_some()); - debug_assert!(self.context.interactive_tx_signing_session.is_none()); - pending_splice.funding_negotiation = Some(FundingNegotiation::ConstructingTransaction { - funding: splice_funding, - interactive_tx_constructor, - }); + let pending_splice = self + .pending_splice + .as_mut() + .expect("pending_splice existence validated in validate_splice_ack"); + let funding_negotiation_context = pending_splice + .take_awaiting_ack_context("splice_ack") + .expect("awaiting ack state validated in validate_splice_ack"); + + let (funding_negotiation, tx_msg_opt) = FundingNegotiation::for_initiator( + splice_funding, + &self.context, + funding_negotiation_context, + entropy_source, + holder_node_id, + ); + pending_splice.funding_negotiation = Some(funding_negotiation); Ok(tx_msg_opt) } @@ -12238,24 +12833,8 @@ where .as_ref() .ok_or_else(|| ChannelError::Ignore("Channel is not in pending splice".to_owned()))?; - let (funding_negotiation_context, new_holder_funding_key) = match &pending_splice - .funding_negotiation - { - Some(FundingNegotiation::AwaitingAck { context, new_holder_funding_key, .. }) => { - (context, new_holder_funding_key) - }, - Some(FundingNegotiation::ConstructingTransaction { .. }) - | Some(FundingNegotiation::AwaitingSignatures { .. }) => { - return Err(ChannelError::WarnAndDisconnect( - "Got unexpected splice_ack; splice negotiation already in progress".to_owned(), - )); - }, - None => { - return Err(ChannelError::Ignore( - "Got unexpected splice_ack; no splice negotiation in progress".to_owned(), - )); - }, - }; + let (funding_negotiation_context, new_holder_funding_key) = + pending_splice.awaiting_ack_context("splice_ack")?; let our_funding_contribution = funding_negotiation_context.our_funding_contribution; let their_funding_contribution = SignedAmount::from_sat(msg.funding_contribution_satoshis); @@ -12998,14 +13577,14 @@ where if !self.context.is_usable() { log_debug!(logger, "Channel is not in a usable state to propose quiescence"); - return Err(action.into()); + return Err(self.quiescent_action_into_error(action)); } if self.quiescent_action.is_some() { log_debug!( logger, "Channel already has a pending quiescent action and cannot start another", ); - return Err(action.into()); + return Err(self.quiescent_action_into_error(action)); } // Since we don't have a pending quiescent action, we should never be in a state where we // sent `stfu` without already having become quiescent. @@ -13096,21 +13675,7 @@ where )); }, Some(QuiescentAction::Splice { contribution, locktime }) => { - // TODO(splicing): If the splice has been negotiated but has not been locked, we - // can RBF here to add the contribution. - if self.pending_splice.is_some() { - debug_assert!(false); - self.quiescent_action = - Some(QuiescentAction::Splice { contribution, locktime }); - - return Err(ChannelError::WarnAndDisconnect( - format!( - "Channel {} cannot be spliced as it already has a splice pending", - self.context.channel_id(), - ), - )); - } - + let prior_contribution = contribution.clone(); let prev_funding_input = self.funding.to_splice_funding_input(); let our_funding_contribution = contribution.net_value(); let funding_feerate_per_kw = contribution.feerate().to_sat_per_kwu() as u32; @@ -13126,7 +13691,18 @@ where our_funding_outputs, }; + if self.pending_splice.is_some() { + let tx_init_rbf = self.send_tx_init_rbf(context); + debug_assert!(self.pending_splice.is_some()); + self.pending_splice.as_mut().unwrap() + .contributions.push(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); return Ok(Some(StfuResponse::SpliceInit(splice_init))); }, #[cfg(any(test, fuzzing, feature = "_test_utils"))] @@ -13165,17 +13741,18 @@ where } if let Some(action) = self.quiescent_action.as_ref() { - // We can't initiate another splice while ours is pending, so don't bother becoming - // quiescent yet. - // TODO(splicing): Allow the splice as an RBF once supported. - let has_splice_action = matches!(action, QuiescentAction::Splice { .. }); - if has_splice_action && self.pending_splice.is_some() { - log_given_level!( - logger, - logger_level, - "Waiting for pending splice to lock before sending stfu for new splice" - ); - return None; + #[allow(irrefutable_let_patterns)] + if let QuiescentAction::Splice { contribution, .. } = action { + if self.pending_splice.is_some() { + if let Err(msg) = self.can_initiate_rbf(contribution.feerate()) { + log_given_level!( + logger, + logger_level, + "Waiting on sending stfu for splice RBF: {msg}" + ); + return None; + } + } } } diff --git a/lightning/src/ln/channelmanager.rs b/lightning/src/ln/channelmanager.rs index ada27af749f..330ed386628 100644 --- a/lightning/src/ln/channelmanager.rs +++ b/lightning/src/ln/channelmanager.rs @@ -4728,6 +4728,94 @@ impl< } } + /// Initiate an RBF of a pending splice transaction for an existing channel. + /// + /// This is used after a splice has been negotiated but before it has been locked, in order + /// to bump the feerate of the funding transaction via replace-by-fee. + /// + /// # Required Feature Flags + /// + /// Initiating an RBF requires that the channel counterparty supports splicing. The + /// counterparty must be currently connected. + /// + /// # Arguments + /// + /// The RBF initiator is responsible for paying fees for common fields, shared inputs, and + /// shared outputs along with any contributed inputs and outputs. When building a + /// [`FundingContribution`], fees are estimated using `min_feerate` and must be covered by the + /// supplied inputs for splice-in or the channel balance for splice-out. If the counterparty + /// also initiates an RBF and wins the tie-break, they become the initiator and choose the + /// feerate. In that case, `max_feerate` is used to reject a feerate that is too high for our + /// contribution. + /// + /// Returns a [`FundingTemplate`] which should be used to build a [`FundingContribution`] via + /// one of its splice methods (e.g., [`FundingTemplate::splice_in_sync`]). The resulting + /// contribution must then be passed to [`ChannelManager::funding_contributed`]. + /// + /// # Events + /// + /// Once the funding transaction has been constructed, an [`Event::SplicePending`] will be + /// emitted. At this point, any inputs contributed to the splice can only be re-spent if an + /// [`Event::DiscardFunding`] is seen. + /// + /// After initial signatures have been exchanged, [`Event::FundingTransactionReadyForSigning`] + /// will be generated and [`ChannelManager::funding_transaction_signed`] should be called. + /// + /// If any failures occur while negotiating the funding transaction, an [`Event::SpliceFailed`] + /// will be emitted. Any contributed inputs no longer used will be included here and thus can + /// be re-spent. + /// + /// Once the splice has been locked by both counterparties, an [`Event::ChannelReady`] will be + /// emitted with the new funding output. At this point, a new splice can be negotiated by + /// calling `splice_channel` again on this channel. + /// + /// [`FundingContribution`]: crate::ln::funding::FundingContribution + pub fn rbf_channel( + &self, channel_id: &ChannelId, counterparty_node_id: &PublicKey, min_feerate: FeeRate, + max_feerate: FeeRate, + ) -> Result { + let per_peer_state = self.per_peer_state.read().unwrap(); + + let peer_state_mutex = match per_peer_state + .get(counterparty_node_id) + .ok_or_else(|| APIError::no_such_peer(counterparty_node_id)) + { + Ok(p) => p, + Err(e) => return Err(e), + }; + + let mut peer_state = peer_state_mutex.lock().unwrap(); + if !peer_state.latest_features.supports_splicing() { + return Err(APIError::ChannelUnavailable { + err: "Peer does not support splicing".to_owned(), + }); + } + if !peer_state.latest_features.supports_quiescence() { + return Err(APIError::ChannelUnavailable { + err: "Peer does not support quiescence, a splicing prerequisite".to_owned(), + }); + } + + // Look for the channel + match peer_state.channel_by_id.entry(*channel_id) { + hash_map::Entry::Occupied(chan_phase_entry) => { + if let Some(chan) = chan_phase_entry.get().as_funded() { + chan.rbf_channel(min_feerate, max_feerate) + } else { + Err(APIError::ChannelUnavailable { + err: format!( + "Channel with id {} is not funded, cannot RBF splice", + channel_id + ), + }) + } + }, + hash_map::Entry::Vacant(_) => { + Err(APIError::no_such_channel_for_peer(channel_id, counterparty_node_id)) + }, + } + } + #[cfg(test)] pub(crate) fn abandon_splice( &self, channel_id: &ChannelId, counterparty_node_id: &PublicKey, @@ -12603,6 +12691,13 @@ This indicates a bug inside LDK. Please report this error at https://github.com/ }); Ok(true) }, + Some(StfuResponse::TxInitRbf(msg)) => { + peer_state.pending_msg_events.push(MessageSendEvent::SendTxInitRbf { + node_id: *counterparty_node_id, + msg, + }); + Ok(true) + }, } } else { let msg = "Peer sent `stfu` for an unfunded channel"; @@ -12877,6 +12972,53 @@ This indicates a bug inside LDK. Please report this error at https://github.com/ } } + /// Handle incoming tx_init_rbf, start a new round of interactive transaction construction. + fn internal_tx_init_rbf( + &self, counterparty_node_id: &PublicKey, msg: &msgs::TxInitRbf, + ) -> Result<(), MsgHandleErrInternal> { + let per_peer_state = self.per_peer_state.read().unwrap(); + let peer_state_mutex = per_peer_state.get(counterparty_node_id).ok_or_else(|| { + MsgHandleErrInternal::unreachable_no_such_peer(counterparty_node_id, msg.channel_id) + })?; + let mut peer_state_lock = peer_state_mutex.lock().unwrap(); + let peer_state = &mut *peer_state_lock; + + match peer_state.channel_by_id.entry(msg.channel_id) { + hash_map::Entry::Vacant(_) => { + return Err(MsgHandleErrInternal::no_such_channel_for_peer( + counterparty_node_id, + msg.channel_id, + )) + }, + hash_map::Entry::Occupied(mut chan_entry) => { + if let Some(ref mut funded_channel) = chan_entry.get_mut().as_funded_mut() { + let init_res = funded_channel.tx_init_rbf( + msg, + &self.entropy_source, + &self.get_our_node_id(), + &self.fee_estimator, + &self.logger, + ); + let tx_ack_rbf_msg = try_channel_entry!(self, peer_state, init_res, chan_entry); + peer_state.pending_msg_events.push(MessageSendEvent::SendTxAckRbf { + node_id: *counterparty_node_id, + msg: tx_ack_rbf_msg, + }); + Ok(()) + } else { + try_channel_entry!( + self, + peer_state, + Err( + ChannelError::close("Channel is not funded, cannot RBF splice".into(),) + ), + chan_entry + ) + } + }, + } + } + /// Handle incoming splice request ack, transition channel to splice-pending (unless some check fails). fn internal_splice_ack( &self, counterparty_node_id: &PublicKey, msg: &msgs::SpliceAck, @@ -12922,6 +13064,50 @@ This indicates a bug inside LDK. Please report this error at https://github.com/ } } + fn internal_tx_ack_rbf( + &self, counterparty_node_id: &PublicKey, msg: &msgs::TxAckRbf, + ) -> Result<(), MsgHandleErrInternal> { + let per_peer_state = self.per_peer_state.read().unwrap(); + let peer_state_mutex = per_peer_state.get(counterparty_node_id).ok_or_else(|| { + MsgHandleErrInternal::unreachable_no_such_peer(counterparty_node_id, msg.channel_id) + })?; + let mut peer_state_lock = peer_state_mutex.lock().unwrap(); + let peer_state = &mut *peer_state_lock; + + // Look for the channel + match peer_state.channel_by_id.entry(msg.channel_id) { + hash_map::Entry::Vacant(_) => Err(MsgHandleErrInternal::no_such_channel_for_peer( + counterparty_node_id, + msg.channel_id, + )), + hash_map::Entry::Occupied(mut chan_entry) => { + if let Some(ref mut funded_channel) = chan_entry.get_mut().as_funded_mut() { + let tx_ack_rbf_res = funded_channel.tx_ack_rbf( + msg, + &self.entropy_source, + &self.get_our_node_id(), + &self.logger, + ); + let tx_msg_opt = + try_channel_entry!(self, peer_state, tx_ack_rbf_res, chan_entry); + if let Some(tx_msg) = tx_msg_opt { + peer_state + .pending_msg_events + .push(tx_msg.into_msg_send_event(counterparty_node_id.clone())); + } + Ok(()) + } else { + try_channel_entry!( + self, + peer_state, + Err(ChannelError::close("Channel is not funded, cannot RBF splice".into())), + chan_entry + ) + } + }, + } + } + fn internal_splice_locked( &self, counterparty_node_id: &PublicKey, msg: &msgs::SpliceLocked, ) -> Result<(), MsgHandleErrInternal> { @@ -16330,19 +16516,29 @@ impl< } fn handle_tx_init_rbf(&self, counterparty_node_id: PublicKey, msg: &msgs::TxInitRbf) { - let err = Err(MsgHandleErrInternal::send_err_msg_no_close( - "Dual-funded channels not supported".to_owned(), - msg.channel_id.clone(), - )); - let _: Result<(), _> = self.handle_error(err, counterparty_node_id); + let _persistence_guard = PersistenceNotifierGuard::optionally_notify(self, || { + let res = self.internal_tx_init_rbf(&counterparty_node_id, msg); + let persist = match &res { + Err(e) if e.closes_channel() => NotifyOption::DoPersist, + Err(_) => NotifyOption::SkipPersistHandleEvents, + Ok(()) => NotifyOption::SkipPersistHandleEvents, + }; + let _ = self.handle_error(res, counterparty_node_id); + persist + }); } fn handle_tx_ack_rbf(&self, counterparty_node_id: PublicKey, msg: &msgs::TxAckRbf) { - let err = Err(MsgHandleErrInternal::send_err_msg_no_close( - "Dual-funded channels not supported".to_owned(), - msg.channel_id.clone(), - )); - let _: Result<(), _> = self.handle_error(err, counterparty_node_id); + let _persistence_guard = PersistenceNotifierGuard::optionally_notify(self, || { + let res = self.internal_tx_ack_rbf(&counterparty_node_id, msg); + let persist = match &res { + Err(e) if e.closes_channel() => NotifyOption::DoPersist, + Err(_) => NotifyOption::SkipPersistHandleEvents, + Ok(()) => NotifyOption::SkipPersistHandleEvents, + }; + let _ = self.handle_error(res, counterparty_node_id); + persist + }); } fn handle_tx_abort(&self, counterparty_node_id: PublicKey, msg: &msgs::TxAbort) { diff --git a/lightning/src/ln/funding.rs b/lightning/src/ln/funding.rs index 84c9d4dd343..c81024ca080 100644 --- a/lightning/src/ln/funding.rs +++ b/lightning/src/ln/funding.rs @@ -42,7 +42,7 @@ use crate::util::wallet_utils::{ #[derive(Debug)] pub(super) enum FeeRateAdjustmentError { /// The counterparty's proposed feerate is below `min_feerate`, which was used as the feerate - /// during coin selection. + /// during coin selection. We'll retry via RBF at our preferred feerate. FeeRateTooLow { target_feerate: FeeRate, min_feerate: FeeRate }, /// The counterparty's proposed feerate is above `max_feerate` and the re-estimated fee for /// our contributed inputs and outputs exceeds the original fee estimate (computed at @@ -68,7 +68,12 @@ impl core::fmt::Display for FeeRateAdjustmentError { fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { match self { FeeRateAdjustmentError::FeeRateTooLow { target_feerate, min_feerate } => { - write!(f, "Target feerate {} is below our minimum {}", target_feerate, min_feerate) + write!( + f, + "Target feerate {} is below our minimum {}; \ + proceeding without contribution, will RBF later", + target_feerate, min_feerate, + ) }, FeeRateAdjustmentError::FeeRateTooHigh { target_feerate, @@ -83,12 +88,17 @@ impl core::fmt::Display for FeeRateAdjustmentError { ) }, FeeRateAdjustmentError::FeeBufferOverflow => { - write!(f, "Arithmetic overflow when computing available fee buffer") + write!( + f, + "Arithmetic overflow when computing available fee buffer; \ + proceeding without contribution", + ) }, FeeRateAdjustmentError::FeeBufferInsufficient { source, available, required } => { write!( f, - "Fee buffer {} ({}) is insufficient for required fee {}", + "Fee buffer {} ({}) is insufficient for required fee {}; \ + proceeding without contribution", available, source, required, ) }, @@ -388,6 +398,17 @@ pub struct FundingContribution { is_splice: bool, } +impl_writeable_tlv_based!(FundingContribution, { + (1, value_added, required), + (3, estimated_fee, required), + (5, inputs, optional_vec), + (7, outputs, optional_vec), + (9, change_output, option), + (11, feerate, required), + (13, max_feerate, required), + (15, is_splice, required), +}); + impl FundingContribution { pub(super) fn feerate(&self) -> FeeRate { self.feerate diff --git a/lightning/src/ln/splicing_tests.rs b/lightning/src/ln/splicing_tests.rs index 486e386be87..9dbd9c2f02d 100644 --- a/lightning/src/ln/splicing_tests.rs +++ b/lightning/src/ln/splicing_tests.rs @@ -40,7 +40,8 @@ use bitcoin::secp256k1::ecdsa::Signature; use bitcoin::secp256k1::{PublicKey, Secp256k1, SecretKey}; use bitcoin::transaction::Version; use bitcoin::{ - Amount, FeeRate, OutPoint as BitcoinOutPoint, Psbt, ScriptBuf, Transaction, TxOut, WPubkeyHash, + Amount, FeeRate, OutPoint as BitcoinOutPoint, Psbt, ScriptBuf, Transaction, TxOut, Txid, + WPubkeyHash, }; #[test] @@ -234,6 +235,21 @@ pub fn do_initiate_splice_in<'a, 'b, 'c, 'd>( funding_contribution } +fn do_initiate_rbf_splice_in<'a, 'b, 'c, 'd>( + node: &'a Node<'b, 'c, 'd>, counterparty: &'a Node<'b, 'c, 'd>, channel_id: ChannelId, + value_added: Amount, feerate: FeeRate, +) -> FundingContribution { + let node_id_counterparty = counterparty.node.get_our_node_id(); + let funding_template = + node.node.rbf_channel(&channel_id, &node_id_counterparty, feerate, FeeRate::MAX).unwrap(); + let wallet = WalletSync::new(Arc::clone(&node.wallet_source), node.logger); + let funding_contribution = funding_template.splice_in_sync(value_added, &wallet).unwrap(); + node.node + .funding_contributed(&channel_id, &node_id_counterparty, funding_contribution.clone(), None) + .unwrap(); + funding_contribution +} + pub fn initiate_splice_out<'a, 'b, 'c, 'd>( initiator: &'a Node<'b, 'c, 'd>, acceptor: &'a Node<'b, 'c, 'd>, channel_id: ChannelId, outputs: Vec, @@ -312,6 +328,25 @@ pub fn complete_splice_handshake<'a, 'b, 'c, 'd>( new_funding_script } +fn complete_rbf_handshake<'a, 'b, 'c, 'd>( + initiator: &'a Node<'b, 'c, 'd>, acceptor: &'a Node<'b, 'c, 'd>, +) -> msgs::TxAckRbf { + 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 tx_init_rbf = get_event_msg!(initiator, MessageSendEvent::SendTxInitRbf, node_id_acceptor); + acceptor.node.handle_tx_init_rbf(node_id_initiator, &tx_init_rbf); + let tx_ack_rbf = get_event_msg!(acceptor, MessageSendEvent::SendTxAckRbf, node_id_initiator); + initiator.node.handle_tx_ack_rbf(node_id_acceptor, &tx_ack_rbf); + + tx_ack_rbf +} + pub fn complete_interactive_funding_negotiation<'a, 'b, 'c, 'd>( initiator: &'a Node<'b, 'c, 'd>, acceptor: &'a Node<'b, 'c, 'd>, channel_id: ChannelId, initiator_contribution: FundingContribution, new_funding_script: ScriptBuf, @@ -709,6 +744,85 @@ pub fn lock_splice<'a, 'b, 'c, 'd>( node_b_stfu } +fn lock_rbf_splice_after_blocks<'a, 'b, 'c, 'd>( + node_a: &'a Node<'b, 'c, 'd>, node_b: &'a Node<'b, 'c, 'd>, tx: &Transaction, num_blocks: u32, + expected_discard_txids: &[Txid], +) -> Option { + let node_id_a = node_a.node.get_our_node_id(); + let node_id_b = node_b.node.get_our_node_id(); + + mine_transaction(node_a, tx); + mine_transaction(node_b, tx); + + connect_blocks(node_a, num_blocks); + connect_blocks(node_b, num_blocks); + + let splice_locked_b = get_event_msg!(node_a, MessageSendEvent::SendSpliceLocked, node_id_b); + node_b.node.handle_splice_locked(node_id_a, &splice_locked_b); + + let mut msg_events = node_b.node.get_and_clear_pending_msg_events(); + + // If the acceptor had a pending QuiescentAction, return the stfu message so that it can be + // used for the next splice attempt. + let node_b_stfu = msg_events + .last() + .filter(|event| matches!(event, MessageSendEvent::SendStfu { .. })) + .is_some() + .then(|| msg_events.pop().unwrap()); + + assert_eq!(msg_events.len(), 2, "{msg_events:?}"); + if let MessageSendEvent::SendSpliceLocked { msg, .. } = msg_events.remove(0) { + node_a.node.handle_splice_locked(node_id_b, &msg); + } else { + panic!("Expected SendSpliceLocked, got {:?}", msg_events[0]); + } + if let MessageSendEvent::SendAnnouncementSignatures { msg, .. } = msg_events.remove(0) { + node_a.node.handle_announcement_signatures(node_id_b, &msg); + } else { + panic!("Expected SendAnnouncementSignatures"); + } + + // Expect ChannelReady + DiscardFunding events on both nodes. + let expected_num_events = 1 + expected_discard_txids.len(); + for node in [node_a, node_b] { + let events = node.node.get_and_clear_pending_events(); + assert_eq!(events.len(), expected_num_events, "{events:?}"); + assert!(matches!(events[0], Event::ChannelReady { .. })); + let discard_txids: Vec<_> = events[1..] + .iter() + .map(|e| match e { + Event::DiscardFunding { funding_info: FundingInfo::Tx { transaction }, .. } => { + transaction.compute_txid() + }, + Event::DiscardFunding { + funding_info: FundingInfo::OutPoint { outpoint }, .. + } => outpoint.txid, + other => panic!("Expected DiscardFunding, got {:?}", other), + }) + .collect(); + for txid in expected_discard_txids { + assert!(discard_txids.contains(txid), "Missing DiscardFunding for txid {}", txid,); + } + check_added_monitors(node, 1); + } + + // Complete the announcement exchange. + let mut msg_events = node_a.node.get_and_clear_pending_msg_events(); + assert_eq!(msg_events.len(), 2, "{msg_events:?}"); + if let MessageSendEvent::SendAnnouncementSignatures { msg, .. } = msg_events.remove(0) { + node_b.node.handle_announcement_signatures(node_id_a, &msg); + } else { + panic!("Expected SendAnnouncementSignatures"); + } + assert!(matches!(msg_events.remove(0), MessageSendEvent::BroadcastChannelAnnouncement { .. })); + + let mut msg_events = node_b.node.get_and_clear_pending_msg_events(); + assert_eq!(msg_events.len(), 1, "{msg_events:?}"); + assert!(matches!(msg_events.remove(0), MessageSendEvent::BroadcastChannelAnnouncement { .. })); + + node_b_stfu +} + #[test] fn test_splice_state_reset_on_disconnect() { do_test_splice_state_reset_on_disconnect(false); @@ -1691,6 +1805,91 @@ fn do_test_splice_tiebreak( } } +#[test] +fn test_splice_tiebreak_feerate_too_high_rejected() { + // Node 0 (winner) proposes a feerate far above node 1's (loser) max_feerate, and node 1's + // fair fee at that feerate exceeds its budget. This triggers FeeRateAdjustmentError::TooHigh, + // causing node 1 to reject with WarnAndDisconnect. + 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); + + provide_utxo_reserves(&nodes, 2, Amount::from_sat(100_000)); + + // Node 0 uses an extremely high feerate (100,000 sat/kwu). Node 1 uses the floor feerate + // with a moderate splice-in (50,000 sats from a 100,000 sat UTXO) and a low max_feerate + // (3,000 sat/kwu). The target (100k) far exceeds node 1's max (3k), and the fair fee at + // 100k exceeds node 1's budget, triggering TooHigh. + let high_feerate = FeeRate::from_sat_per_kwu(100_000); + let floor_feerate = FeeRate::from_sat_per_kwu(FEERATE_FLOOR_SATS_PER_KW as u64); + let node_0_added_value = Amount::from_sat(50_000); + let node_1_added_value = Amount::from_sat(50_000); + let node_1_max_feerate = FeeRate::from_sat_per_kwu(3_000); + + // Node 0: very high feerate, moderate splice-in. + let funding_template_0 = + nodes[0].node.splice_channel(&channel_id, &node_id_1, high_feerate, FeeRate::MAX).unwrap(); + let wallet_0 = WalletSync::new(Arc::clone(&nodes[0].wallet_source), nodes[0].logger); + let node_0_funding_contribution = + funding_template_0.splice_in_sync(node_0_added_value, &wallet_0).unwrap(); + nodes[0] + .node + .funding_contributed(&channel_id, &node_id_1, node_0_funding_contribution.clone(), None) + .unwrap(); + + // Node 1: floor feerate, moderate splice-in, low max_feerate. + let funding_template_1 = nodes[1] + .node + .splice_channel(&channel_id, &node_id_0, floor_feerate, node_1_max_feerate) + .unwrap(); + let wallet_1 = WalletSync::new(Arc::clone(&nodes[1].wallet_source), nodes[1].logger); + let node_1_funding_contribution = + funding_template_1.splice_in_sync(node_1_added_value, &wallet_1).unwrap(); + nodes[1] + .node + .funding_contributed(&channel_id, &node_id_0, node_1_funding_contribution.clone(), None) + .unwrap(); + + // Both emit STFU. + let stfu_0 = get_event_msg!(nodes[0], MessageSendEvent::SendStfu, node_id_1); + let stfu_1 = get_event_msg!(nodes[1], MessageSendEvent::SendStfu, node_id_0); + + // Tie-break: node 0 wins. + nodes[1].node.handle_stfu(node_id_0, &stfu_0); + assert!(nodes[1].node.get_and_clear_pending_msg_events().is_empty()); + nodes[0].node.handle_stfu(node_id_1, &stfu_1); + + // Node 0 sends SpliceInit at 100,000 sat/kwu. + let splice_init = get_event_msg!(nodes[0], MessageSendEvent::SendSpliceInit, node_id_1); + + // Node 1 handles SpliceInit — TooHigh: target (100k) >> max (3k) and fair fee > budget. + nodes[1].node.handle_splice_init(node_id_0, &splice_init); + + let msg_events = nodes[1].node.get_and_clear_pending_msg_events(); + assert_eq!(msg_events.len(), 1, "{msg_events:?}"); + match &msg_events[0] { + MessageSendEvent::HandleError { + action: msgs::ErrorAction::DisconnectPeerWithWarning { msg }, + .. + } => { + assert!( + msg.data.contains("Cannot accommodate initiator's feerate"), + "Unexpected warning: {}", + msg.data + ); + }, + other => panic!("Expected HandleError/DisconnectPeerWithWarning, got {:?}", other), + } +} + #[cfg(test)] #[derive(PartialEq)] enum SpliceStatus { @@ -2790,12 +2989,14 @@ fn fail_quiescent_action_on_channel_close() { #[test] fn abandon_splice_quiescent_action_on_shutdown() { - do_abandon_splice_quiescent_action_on_shutdown(true); - do_abandon_splice_quiescent_action_on_shutdown(false); + do_abandon_splice_quiescent_action_on_shutdown(true, false); + do_abandon_splice_quiescent_action_on_shutdown(false, false); + do_abandon_splice_quiescent_action_on_shutdown(true, true); + do_abandon_splice_quiescent_action_on_shutdown(false, true); } #[cfg(test)] -fn do_abandon_splice_quiescent_action_on_shutdown(local_shutdown: bool) { +fn do_abandon_splice_quiescent_action_on_shutdown(local_shutdown: bool, pending_splice: bool) { 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]); @@ -2809,6 +3010,19 @@ fn do_abandon_splice_quiescent_action_on_shutdown(local_shutdown: bool) { let (_, _, channel_id, _) = create_announced_chan_between_nodes_with_value(&nodes, 0, 1, initial_channel_capacity, 0); + // When testing with a prior pending splice, complete splice A first so that + // `quiescent_action_into_error` filters against `pending_splice.contributed_inputs/outputs`. + if pending_splice { + let funding_contribution = do_initiate_splice_in( + &nodes[0], + &nodes[1], + channel_id, + Amount::from_sat(initial_channel_capacity / 2), + ); + let (_splice_tx, _new_funding_script) = + splice_channel(&nodes[0], &nodes[1], channel_id, funding_contribution); + } + // Since we cannot close after having sent `stfu`, send an HTLC so that when we attempt to // splice, the `stfu` message is held back. let payment_amount = 1_000_000; @@ -2821,7 +3035,8 @@ fn do_abandon_splice_quiescent_action_on_shutdown(local_shutdown: bool) { check_added_monitors(&nodes[0], 1); nodes[1].node.handle_update_add_htlc(node_id_0, &update.update_add_htlcs[0]); - nodes[1].node.handle_commitment_signed(node_id_0, &update.commitment_signed[0]); + // After a splice, commitment_signed messages are batched across funding scopes. + nodes[1].node.handle_commitment_signed_batch_test(node_id_0, &update.commitment_signed); check_added_monitors(&nodes[1], 1); let (revoke_and_ack, _) = get_revoke_commit_msgs(&nodes[1], &node_id_0); @@ -2829,9 +3044,13 @@ fn do_abandon_splice_quiescent_action_on_shutdown(local_shutdown: bool) { check_added_monitors(&nodes[0], 1); // Attempt the splice. `stfu` should not go out yet as the state machine is pending. - let splice_in_amount = initial_channel_capacity / 2; + // Use a different amount when there's a prior splice so the change output differs. + let splice_in_amount = + if pending_splice { initial_channel_capacity / 4 } else { initial_channel_capacity / 2 }; let funding_contribution = initiate_splice_in(&nodes[0], &nodes[1], channel_id, Amount::from_sat(splice_in_amount)); + let splice_b_change_output = + if pending_splice { funding_contribution.change_output().cloned() } else { None }; assert!(nodes[0].node.get_and_clear_pending_msg_events().is_empty()); // Close the channel. We should see a `SpliceFailed` event for the pending splice @@ -2845,7 +3064,33 @@ fn do_abandon_splice_quiescent_action_on_shutdown(local_shutdown: bool) { let shutdown = get_event_msg!(closer_node, MessageSendEvent::SendShutdown, closee_node_id); closee_node.node.handle_shutdown(closer_node_id, &shutdown); - expect_splice_failed_events(&nodes[0], &channel_id, funding_contribution); + if pending_splice { + // With a prior pending splice, contributions are filtered against committed inputs/outputs. + let events = nodes[0].node.get_and_clear_pending_events(); + assert_eq!(events.len(), 2, "{events:?}"); + match &events[0] { + Event::SpliceFailed { channel_id: cid, .. } => { + assert_eq!(*cid, channel_id); + }, + other => panic!("Expected SpliceFailed, got {:?}", other), + } + match &events[1] { + Event::DiscardFunding { + funding_info: FundingInfo::Contribution { inputs, outputs }, + .. + } => { + // The UTXO was filtered: it's still committed to the prior splice. + assert!(inputs.is_empty(), "Expected empty inputs (filtered), got {:?}", inputs); + // The change output was NOT filtered: different splice-in amount produces a + // different change. + let expected_outputs: Vec<_> = splice_b_change_output.into_iter().collect(); + assert_eq!(*outputs, expected_outputs); + }, + other => panic!("Expected DiscardFunding with Contribution, got {:?}", other), + } + } else { + expect_splice_failed_events(&nodes[0], &channel_id, funding_contribution); + } let _ = get_event_msg!(closee_node, MessageSendEvent::SendShutdown, closer_node_id); } @@ -4006,3 +4251,1375 @@ fn do_test_splice_pending_htlcs(config: UserConfig) { let _ = send_payment(&nodes[0], &[&nodes[1]], 2_000 * 1000); let _ = send_payment(&nodes[1], &[&nodes[0]], 2_000 * 1000); } + +// Helper to re-enter quiescence between two nodes where node_a is the initiator. +// Returns after both sides are quiescent (no splice_init is generated since we use DoNothing). +fn reenter_quiescence<'a, 'b, 'c>( + node_a: &Node<'a, 'b, 'c>, node_b: &Node<'a, 'b, 'c>, channel_id: &ChannelId, +) { + let node_id_a = node_a.node.get_our_node_id(); + let node_id_b = node_b.node.get_our_node_id(); + + node_a.node.maybe_propose_quiescence(&node_id_b, channel_id).unwrap(); + let stfu_a = get_event_msg!(node_a, MessageSendEvent::SendStfu, node_id_b); + node_b.node.handle_stfu(node_id_a, &stfu_a); + let stfu_b = get_event_msg!(node_b, MessageSendEvent::SendStfu, node_id_a); + node_a.node.handle_stfu(node_id_b, &stfu_b); +} + +#[test] +fn test_splice_rbf_acceptor_basic() { + // Test the full end-to-end flow for RBF of a pending splice transaction. + // Complete a splice-in, then use rbf_channel API to initiate an RBF attempt + // with a higher feerate, going through the full tx_init_rbf → tx_ack_rbf → + // interactive TX → signing → mining → splice_locked flow. + 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); + + // Step 1: Complete a splice-in from node 0. + let funding_contribution = do_initiate_splice_in(&nodes[0], &nodes[1], channel_id, added_value); + // Save the pre-splice funding outpoint before splice_channel modifies the monitor. + let original_funding_outpoint = nodes[0] + .chain_monitor + .chain_monitor + .get_monitor(channel_id) + .map(|monitor| (monitor.get_funding_txo(), monitor.get_funding_script())) + .unwrap(); + + let (first_splice_tx, new_funding_script) = + splice_channel(&nodes[0], &nodes[1], channel_id, funding_contribution); + + // Step 2: Provide more UTXO reserves for the RBF attempt. + provide_utxo_reserves(&nodes, 2, added_value * 2); + + // Step 3: Use rbf_channel API to initiate the RBF. + // Original feerate was FEERATE_FLOOR_SATS_PER_KW (253). 253 * 25 / 24 = 263.54, so 264 works. + let rbf_feerate_sat_per_kwu = (FEERATE_FLOOR_SATS_PER_KW as u64 * 25 + 23) / 24; // ceil(253*25/24) = 264 + let rbf_feerate = FeeRate::from_sat_per_kwu(rbf_feerate_sat_per_kwu); + let funding_contribution = + do_initiate_rbf_splice_in(&nodes[0], &nodes[1], channel_id, added_value, rbf_feerate); + + // Steps 4-8: STFU exchange → tx_init_rbf → tx_ack_rbf. + complete_rbf_handshake(&nodes[0], &nodes[1]); + + // Step 9: Complete interactive funding negotiation. + complete_interactive_funding_negotiation( + &nodes[0], + &nodes[1], + channel_id, + funding_contribution, + new_funding_script.clone(), + ); + + // Step 10: Sign and broadcast. + let (rbf_tx, splice_locked) = sign_interactive_funding_tx(&nodes[0], &nodes[1], false); + assert!(splice_locked.is_none()); + + expect_splice_pending_event(&nodes[0], &node_id_1); + expect_splice_pending_event(&nodes[1], &node_id_0); + + // Step 11: Mine, lock, and verify DiscardFunding for the replaced splice candidate. + lock_rbf_splice_after_blocks( + &nodes[0], + &nodes[1], + &rbf_tx, + ANTI_REORG_DELAY - 1, + &[first_splice_tx.compute_txid()], + ); + + // Clean up old watched outpoints from the chain source. + // The original channel's funding outpoint and the first (replaced) splice's funding outpoint + // are still being watched but are no longer tracked by the deserialized monitor. + let (orig_outpoint, orig_script) = original_funding_outpoint; + let first_splice_funding_idx = + first_splice_tx.output.iter().position(|o| o.script_pubkey == new_funding_script).unwrap(); + let first_splice_outpoint = + OutPoint { txid: first_splice_tx.compute_txid(), index: first_splice_funding_idx as u16 }; + for node in &nodes { + node.chain_source.remove_watched_txn_and_outputs(orig_outpoint, orig_script.clone()); + node.chain_source + .remove_watched_txn_and_outputs(first_splice_outpoint, new_funding_script.clone()); + } +} + +#[test] +fn test_splice_rbf_insufficient_feerate() { + // Test that rbf_channel rejects a feerate that doesn't satisfy the 25/24 rule, and that the + // acceptor also rejects tx_init_rbf with an insufficient feerate from a misbehaving peer. + 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); + + // Complete a splice-in. + let funding_contribution = do_initiate_splice_in(&nodes[0], &nodes[1], channel_id, added_value); + let (_splice_tx, _new_funding_script) = + splice_channel(&nodes[0], &nodes[1], channel_id, funding_contribution); + + // Initiator-side: rbf_channel rejects an insufficient feerate. + // Original feerate was 253. Using exactly 253 should fail since 253 * 24 < 253 * 25. + let same_feerate = FeeRate::from_sat_per_kwu(FEERATE_FLOOR_SATS_PER_KW as u64); + let err = + nodes[0].node.rbf_channel(&channel_id, &node_id_1, same_feerate, FeeRate::MAX).unwrap_err(); + assert_eq!( + err, + APIError::APIMisuseError { + err: format!( + "Channel {} RBF feerate {} is less than 25/24 of the previous feerate {}", + channel_id, FEERATE_FLOOR_SATS_PER_KW, FEERATE_FLOOR_SATS_PER_KW, + ), + } + ); + + // Acceptor-side: tx_init_rbf with an insufficient feerate is also rejected. + reenter_quiescence(&nodes[0], &nodes[1], &channel_id); + + let tx_init_rbf = msgs::TxInitRbf { + channel_id, + locktime: 0, + feerate_sat_per_1000_weight: FEERATE_FLOOR_SATS_PER_KW, + funding_output_contribution: Some(added_value.to_sat() as i64), + }; + + nodes[1].node.handle_tx_init_rbf(node_id_0, &tx_init_rbf); + + let msg_events = nodes[1].node.get_and_clear_pending_msg_events(); + assert_eq!(msg_events.len(), 1); + match &msg_events[0] { + MessageSendEvent::HandleError { action, .. } => { + assert_eq!( + *action, + msgs::ErrorAction::DisconnectPeerWithWarning { + msg: msgs::WarningMessage { + channel_id, + data: format!( + "Channel {} RBF feerate {} is less than 25/24 of the previous feerate {}", + channel_id, FEERATE_FLOOR_SATS_PER_KW, FEERATE_FLOOR_SATS_PER_KW, + ), + }, + } + ); + }, + _ => panic!("Expected HandleError, got {:?}", msg_events[0]), + } +} + +#[test] +fn test_splice_rbf_no_pending_splice() { + // Test that tx_init_rbf is rejected when there is no pending splice to RBF. + 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 initial_channel_value_sat = 100_000; + let (_, _, channel_id, _) = + create_announced_chan_between_nodes_with_value(&nodes, 0, 1, initial_channel_value_sat, 0); + + // Re-enter quiescence without having done a splice. + reenter_quiescence(&nodes[0], &nodes[1], &channel_id); + + let tx_init_rbf = msgs::TxInitRbf { + channel_id, + locktime: 0, + feerate_sat_per_1000_weight: 500, + funding_output_contribution: Some(50_000), + }; + + nodes[1].node.handle_tx_init_rbf(node_id_0, &tx_init_rbf); + + let msg_events = nodes[1].node.get_and_clear_pending_msg_events(); + assert_eq!(msg_events.len(), 1); + match &msg_events[0] { + MessageSendEvent::HandleError { action, .. } => { + assert_eq!( + *action, + msgs::ErrorAction::DisconnectPeerWithWarning { + msg: msgs::WarningMessage { + channel_id, + data: format!("Channel {} has no pending splice to RBF", channel_id), + }, + } + ); + }, + _ => panic!("Expected HandleError, got {:?}", msg_events[0]), + } +} + +#[test] +fn test_splice_rbf_active_negotiation() { + // Test that tx_init_rbf is rejected when a funding negotiation is already in progress. + // Start a splice but don't complete interactive TX construction, then send tx_init_rbf. + 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 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); + + // Initiate a splice but only complete the handshake (STFU + splice_init/ack), + // leaving interactive TX construction in progress. + let _funding_contribution = + do_initiate_splice_in(&nodes[0], &nodes[1], channel_id, added_value); + let _new_funding_script = complete_splice_handshake(&nodes[0], &nodes[1]); + + // Now the acceptor (node 1) has a funding_negotiation in progress (ConstructingTransaction). + // Sending tx_init_rbf should be rejected. + let tx_init_rbf = msgs::TxInitRbf { + channel_id, + locktime: 0, + feerate_sat_per_1000_weight: 500, + funding_output_contribution: Some(added_value.to_sat() as i64), + }; + + nodes[1].node.handle_tx_init_rbf(node_id_0, &tx_init_rbf); + + let msg_events = nodes[1].node.get_and_clear_pending_msg_events(); + assert_eq!(msg_events.len(), 1); + match &msg_events[0] { + MessageSendEvent::HandleError { action, .. } => { + assert_eq!( + *action, + msgs::ErrorAction::DisconnectPeerWithWarning { + msg: msgs::WarningMessage { + channel_id, + data: format!( + "Channel {} already has a funding negotiation in progress", + channel_id, + ), + }, + } + ); + }, + _ => panic!("Expected HandleError, got {:?}", msg_events[0]), + } + + // Clear the initiator's pending interactive TX messages from the incomplete splice handshake. + nodes[0].node.get_and_clear_pending_msg_events(); +} + +#[test] +fn test_splice_rbf_after_splice_locked() { + // Test that tx_init_rbf is rejected when the counterparty has already sent splice_locked. + 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); + + // Complete a splice-in from node 0. + let funding_contribution = do_initiate_splice_in(&nodes[0], &nodes[1], channel_id, added_value); + let (splice_tx, _new_funding_script) = + splice_channel(&nodes[0], &nodes[1], channel_id, funding_contribution); + + // Mine the splice tx on both nodes. + mine_transaction(&nodes[0], &splice_tx); + mine_transaction(&nodes[1], &splice_tx); + + // Connect enough blocks on node 0 only so it sends splice_locked. + connect_blocks(&nodes[0], ANTI_REORG_DELAY - 1); + + let splice_locked = get_event_msg!(nodes[0], MessageSendEvent::SendSpliceLocked, node_id_1); + + // Deliver splice_locked to node 1. Since node 1 hasn't confirmed enough blocks, + // it won't send its own splice_locked back, but it will set received_funding_txid. + nodes[1].node.handle_splice_locked(node_id_0, &splice_locked); + + // Node 1 shouldn't have any messages to send (no splice_locked since it hasn't confirmed). + let msg_events = nodes[1].node.get_and_clear_pending_msg_events(); + assert!(msg_events.is_empty(), "Expected no messages, got {:?}", msg_events); + + // Re-enter quiescence (node 0 initiates). + reenter_quiescence(&nodes[0], &nodes[1], &channel_id); + + // Node 0 sends tx_init_rbf, but node 0 already sent splice_locked, so it should be rejected. + let tx_init_rbf = msgs::TxInitRbf { + channel_id, + locktime: 0, + feerate_sat_per_1000_weight: 500, + funding_output_contribution: Some(added_value.to_sat() as i64), + }; + + nodes[1].node.handle_tx_init_rbf(node_id_0, &tx_init_rbf); + + let msg_events = nodes[1].node.get_and_clear_pending_msg_events(); + assert_eq!(msg_events.len(), 1); + match &msg_events[0] { + MessageSendEvent::HandleError { action, .. } => { + assert_eq!( + *action, + msgs::ErrorAction::DisconnectPeerWithWarning { + msg: msgs::WarningMessage { + channel_id, + data: format!( + "Channel {} counterparty already sent splice_locked, cannot RBF", + channel_id, + ), + }, + } + ); + }, + _ => panic!("Expected HandleError, got {:?}", msg_events[0]), + } +} + +#[test] +fn test_splice_rbf_zeroconf_rejected() { + // Test that tx_init_rbf is rejected when option_zeroconf is negotiated. + // The zero-conf check happens before the pending_splice check, so we don't need to complete + // a splice — just enter quiescence and send tx_init_rbf. + let chanmon_cfgs = create_chanmon_cfgs(2); + let node_cfgs = create_node_cfgs(2, &chanmon_cfgs); + let mut config = test_default_channel_config(); + config.channel_handshake_limits.trust_own_funding_0conf = true; + let node_chanmgrs = create_node_chanmgrs(2, &node_cfgs, &[Some(config.clone()), Some(config)]); + let nodes = create_network(2, &node_cfgs, &node_chanmgrs); + + let node_id_0 = nodes[0].node.get_our_node_id(); + + let initial_channel_value_sat = 100_000; + let (funding_tx, channel_id) = + open_zero_conf_channel_with_value(&nodes[0], &nodes[1], None, initial_channel_value_sat, 0); + mine_transaction(&nodes[0], &funding_tx); + mine_transaction(&nodes[1], &funding_tx); + + // Enter quiescence (node 0 initiates). + reenter_quiescence(&nodes[0], &nodes[1], &channel_id); + + // Node 0 sends tx_init_rbf, but the channel has option_zeroconf, so it should be rejected. + let tx_init_rbf = msgs::TxInitRbf { + channel_id, + locktime: 0, + feerate_sat_per_1000_weight: 500, + funding_output_contribution: Some(50_000), + }; + + nodes[1].node.handle_tx_init_rbf(node_id_0, &tx_init_rbf); + + let msg_events = nodes[1].node.get_and_clear_pending_msg_events(); + assert_eq!(msg_events.len(), 1); + match &msg_events[0] { + MessageSendEvent::HandleError { action, .. } => { + assert_eq!( + *action, + msgs::ErrorAction::DisconnectPeerWithWarning { + msg: msgs::WarningMessage { + channel_id, + data: format!( + "Channel {} has option_zeroconf, cannot RBF splice", + channel_id, + ), + }, + } + ); + }, + _ => panic!("Expected HandleError, got {:?}", msg_events[0]), + } +} + +#[test] +fn test_splice_rbf_not_quiescence_initiator() { + // Test that tx_init_rbf from the non-quiescence-initiator is rejected because the + // quiescence initiator's RBF flow has already set funding_negotiation to AwaitingAck. + 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); + + // Complete a splice-in from node 0. + let funding_contribution = do_initiate_splice_in(&nodes[0], &nodes[1], channel_id, added_value); + let (_splice_tx, _new_funding_script) = + splice_channel(&nodes[0], &nodes[1], channel_id, funding_contribution); + + // Provide more UTXO reserves for the RBF attempt. + provide_utxo_reserves(&nodes, 2, added_value * 2); + + // Initiate RBF from node 0 (quiescence initiator). + let rbf_feerate_sat_per_kwu = (FEERATE_FLOOR_SATS_PER_KW as u64 * 25 + 23) / 24; + let rbf_feerate = FeeRate::from_sat_per_kwu(rbf_feerate_sat_per_kwu); + let _funding_contribution = + do_initiate_rbf_splice_in(&nodes[0], &nodes[1], channel_id, added_value, rbf_feerate); + + // STFU exchange: node 0 initiates quiescence. + 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); + + // Node 0 sends tx_init_rbf as the quiescence initiator — grab and discard. + let _tx_init_rbf = get_event_msg!(nodes[0], MessageSendEvent::SendTxInitRbf, node_id_1); + + // Now craft a competing tx_init_rbf from node 1 (the non-initiator). + let tx_init_rbf = msgs::TxInitRbf { + channel_id, + locktime: 0, + feerate_sat_per_1000_weight: 500, + funding_output_contribution: Some(added_value.to_sat() as i64), + }; + + nodes[0].node.handle_tx_init_rbf(node_id_1, &tx_init_rbf); + + let msg_events = nodes[0].node.get_and_clear_pending_msg_events(); + assert_eq!(msg_events.len(), 1); + match &msg_events[0] { + MessageSendEvent::HandleError { action, .. } => { + assert_eq!( + *action, + msgs::ErrorAction::DisconnectPeerWithWarning { + msg: msgs::WarningMessage { + channel_id, + data: format!( + "Channel {} already has a funding negotiation in progress", + channel_id, + ), + }, + } + ); + }, + _ => panic!("Expected HandleError, got {:?}", msg_events[0]), + } +} + +#[test] +fn test_splice_rbf_both_contribute_tiebreak() { + let min_rbf_feerate = (FEERATE_FLOOR_SATS_PER_KW as u64 * 25 + 23) / 24; + let feerate = FeeRate::from_sat_per_kwu(min_rbf_feerate); + let added_value = Amount::from_sat(50_000); + do_test_splice_rbf_tiebreak(feerate, feerate, added_value, true); +} + +#[test] +fn test_splice_rbf_tiebreak_higher_feerate() { + // Node 0 (winner) uses a higher feerate than node 1 (loser). Node 1's change output is + // adjusted (reduced) to accommodate the higher feerate. Negotiation succeeds. + let min_rbf_feerate = (FEERATE_FLOOR_SATS_PER_KW as u64 * 25 + 23) / 24; + do_test_splice_rbf_tiebreak( + FeeRate::from_sat_per_kwu(min_rbf_feerate * 3), + FeeRate::from_sat_per_kwu(min_rbf_feerate), + Amount::from_sat(50_000), + true, + ); +} + +#[test] +fn test_splice_rbf_tiebreak_lower_feerate() { + // Node 0 (winner) uses a lower feerate than node 1 (loser). Since the initiator's feerate + // is below node 1's minimum, node 1 proceeds without contribution and will retry via a new + // splice at its preferred feerate after the RBF locks. + let min_rbf_feerate = (FEERATE_FLOOR_SATS_PER_KW as u64 * 25 + 23) / 24; + do_test_splice_rbf_tiebreak( + FeeRate::from_sat_per_kwu(min_rbf_feerate), + FeeRate::from_sat_per_kwu(min_rbf_feerate * 3), + Amount::from_sat(50_000), + false, + ); +} + +#[test] +fn test_splice_rbf_tiebreak_feerate_too_high() { + // Node 0 (winner) uses a feerate high enough that node 1's (loser) contribution cannot + // cover the fees. Node 1 proceeds without its contribution (QuiescentAction is preserved + // for a future splice). The RBF completes with only node 0's inputs/outputs. + let min_rbf_feerate = (FEERATE_FLOOR_SATS_PER_KW as u64 * 25 + 23) / 24; + do_test_splice_rbf_tiebreak( + FeeRate::from_sat_per_kwu(20_000), + FeeRate::from_sat_per_kwu(min_rbf_feerate), + Amount::from_sat(95_000), + false, + ); +} + +/// Runs the tie-breaker test with the given per-node feerates and node 1's splice value. +/// +/// Both nodes call `rbf_channel` + `funding_contributed`, both send STFU, and node 0 (the outbound +/// channel funder) wins the quiescence tie-break. The loser (node 1) becomes the acceptor. Whether +/// node 1 contributes to the RBF transaction depends on the feerate and budget constraints. +/// +/// `expect_acceptor_contributes` asserts the expected outcome: whether node 1's `tx_ack_rbf` +/// includes a funding output contribution. +fn do_test_splice_rbf_tiebreak( + rbf_feerate_0: FeeRate, rbf_feerate_1: FeeRate, node_1_splice_value: Amount, + expect_acceptor_contributes: bool, +) { + 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); + // Complete an initial splice-in from node 0. + let funding_contribution = do_initiate_splice_in(&nodes[0], &nodes[1], channel_id, added_value); + let original_funding_outpoint = nodes[0] + .chain_monitor + .chain_monitor + .get_monitor(channel_id) + .map(|monitor| (monitor.get_funding_txo(), monitor.get_funding_script())) + .unwrap(); + let (first_splice_tx, new_funding_script) = + splice_channel(&nodes[0], &nodes[1], channel_id, funding_contribution); + + // Provide more UTXOs for both nodes' RBF attempts. + provide_utxo_reserves(&nodes, 2, added_value * 2); + + // Node 0 calls rbf_channel + funding_contributed. + let node_0_funding_contribution = + do_initiate_rbf_splice_in(&nodes[0], &nodes[1], channel_id, added_value, rbf_feerate_0); + + // Node 1 calls rbf_channel + funding_contributed. + let node_1_funding_contribution = do_initiate_rbf_splice_in( + &nodes[1], + &nodes[0], + channel_id, + node_1_splice_value, + rbf_feerate_1, + ); + + // Both nodes sent STFU (both have awaiting_quiescence set). + let stfu_0 = get_event_msg!(nodes[0], MessageSendEvent::SendStfu, node_id_1); + assert!(stfu_0.initiator); + let stfu_1 = get_event_msg!(nodes[1], MessageSendEvent::SendStfu, node_id_0); + assert!(stfu_1.initiator); + + // Exchange STFUs. Node 0 is the outbound channel funder and wins the tie-break. + // Node 1 handles node 0's STFU first — it already sent its own STFU (local_stfu_sent is set), + // so this goes through the tie-break path. Node 1 loses (is_outbound = false) and becomes the + // acceptor. Its quiescent_action is preserved for the tx_init_rbf handler. + nodes[1].node.handle_stfu(node_id_0, &stfu_0); + assert!(nodes[1].node.get_and_clear_pending_msg_events().is_empty()); + + // Node 0 handles node 1's STFU — it already sent its own STFU, so tie-break again. + // Node 0 wins (is_outbound = true), consumes its quiescent_action, and sends tx_init_rbf. + nodes[0].node.handle_stfu(node_id_1, &stfu_1); + + // Node 0 sends tx_init_rbf. + let tx_init_rbf = get_event_msg!(nodes[0], MessageSendEvent::SendTxInitRbf, node_id_1); + assert_eq!(tx_init_rbf.channel_id, channel_id); + assert_eq!(tx_init_rbf.feerate_sat_per_1000_weight, rbf_feerate_0.to_sat_per_kwu() as u32); + + // Node 1 handles tx_init_rbf — its quiescent_action is consumed, adjusting its contribution + // for node 0's feerate. Whether it contributes depends on the feerate and budget constraints. + nodes[1].node.handle_tx_init_rbf(node_id_0, &tx_init_rbf); + let tx_ack_rbf = get_event_msg!(nodes[1], MessageSendEvent::SendTxAckRbf, node_id_0); + assert_eq!(tx_ack_rbf.channel_id, channel_id); + + // Node 0 handles tx_ack_rbf. + let acceptor_contributes = tx_ack_rbf.funding_output_contribution.is_some(); + assert_eq!( + acceptor_contributes, expect_acceptor_contributes, + "Expected acceptor contribution: {}, got: {}", + expect_acceptor_contributes, acceptor_contributes, + ); + nodes[0].node.handle_tx_ack_rbf(node_id_1, &tx_ack_rbf); + + if acceptor_contributes { + // Capture change output values for assertions. + let node_0_change = node_0_funding_contribution + .change_output() + .expect("splice-in should have a change output") + .clone(); + let node_1_change = node_1_funding_contribution + .change_output() + .expect("splice-in should have a change output") + .clone(); + + // Complete interactive funding negotiation with both parties' inputs/outputs. + complete_interactive_funding_negotiation_for_both( + &nodes[0], + &nodes[1], + channel_id, + node_0_funding_contribution, + Some(node_1_funding_contribution), + tx_ack_rbf.funding_output_contribution.unwrap(), + new_funding_script.clone(), + ); + + // Sign (acceptor has contribution) and broadcast. + let (rbf_tx, splice_locked) = sign_interactive_funding_tx_with_acceptor_contribution( + &nodes[0], &nodes[1], false, true, + ); + assert!(splice_locked.is_none()); + + // The initiator's change output should remain unchanged (no feerate adjustment). + let initiator_change_in_tx = rbf_tx + .output + .iter() + .find(|o| o.script_pubkey == node_0_change.script_pubkey) + .expect("Initiator's change output should be in the RBF transaction"); + assert_eq!( + initiator_change_in_tx.value, node_0_change.value, + "Initiator's change output should remain unchanged", + ); + + // The acceptor's change output should be adjusted based on the feerate difference. + let acceptor_change_in_tx = rbf_tx + .output + .iter() + .find(|o| o.script_pubkey == node_1_change.script_pubkey) + .expect("Acceptor's change output should be in the RBF transaction"); + if rbf_feerate_0 <= rbf_feerate_1 { + // Initiator's feerate <= acceptor's original: the acceptor's change increases because + // is_initiator=false has lower weight, and the feerate is the same or lower. + assert!( + acceptor_change_in_tx.value > node_1_change.value, + "Acceptor's change should increase when initiator feerate ({}) <= acceptor \ + feerate ({}): adjusted {} vs original {}", + rbf_feerate_0.to_sat_per_kwu(), + rbf_feerate_1.to_sat_per_kwu(), + acceptor_change_in_tx.value, + node_1_change.value, + ); + } else { + // Initiator's feerate > acceptor's original: the higher feerate more than compensates + // for the lower weight, so the acceptor's change decreases. + assert!( + acceptor_change_in_tx.value < node_1_change.value, + "Acceptor's change should decrease when initiator feerate ({}) > acceptor \ + feerate ({}): adjusted {} vs original {}", + rbf_feerate_0.to_sat_per_kwu(), + rbf_feerate_1.to_sat_per_kwu(), + acceptor_change_in_tx.value, + node_1_change.value, + ); + } + + expect_splice_pending_event(&nodes[0], &node_id_1); + expect_splice_pending_event(&nodes[1], &node_id_0); + + // Mine, lock, and verify DiscardFunding for the replaced splice candidate. + lock_rbf_splice_after_blocks( + &nodes[0], + &nodes[1], + &rbf_tx, + ANTI_REORG_DELAY - 1, + &[first_splice_tx.compute_txid()], + ); + + // Clean up old watched outpoints from the chain source. + let (orig_outpoint, orig_script) = original_funding_outpoint; + let first_splice_funding_idx = first_splice_tx + .output + .iter() + .position(|o| o.script_pubkey == new_funding_script) + .unwrap(); + let first_splice_outpoint = OutPoint { + txid: first_splice_tx.compute_txid(), + index: first_splice_funding_idx as u16, + }; + for node in &nodes { + node.chain_source.remove_watched_txn_and_outputs(orig_outpoint, orig_script.clone()); + node.chain_source + .remove_watched_txn_and_outputs(first_splice_outpoint, new_funding_script.clone()); + } + } else { + // Acceptor does not contribute — complete with only node 0's inputs/outputs. + complete_interactive_funding_negotiation_for_both( + &nodes[0], + &nodes[1], + channel_id, + node_0_funding_contribution, + None, + 0, + new_funding_script.clone(), + ); + + // Sign (acceptor has no contribution) and broadcast. + let (rbf_tx, splice_locked) = sign_interactive_funding_tx_with_acceptor_contribution( + &nodes[0], &nodes[1], false, false, + ); + assert!(splice_locked.is_none()); + + expect_splice_pending_event(&nodes[0], &node_id_1); + expect_splice_pending_event(&nodes[1], &node_id_0); + + // Mine, lock, and verify DiscardFunding for the replaced splice candidate. + // Node 1's QuiescentAction was preserved, so after splice_locked it re-initiates + // quiescence to retry its contribution in a future splice. + let node_b_stfu = lock_rbf_splice_after_blocks( + &nodes[0], + &nodes[1], + &rbf_tx, + ANTI_REORG_DELAY - 1, + &[first_splice_tx.compute_txid()], + ); + let stfu_1 = if let Some(MessageSendEvent::SendStfu { msg, .. }) = node_b_stfu { + msg + } else { + panic!("Expected SendStfu from node 1"); + }; + assert!(stfu_1.initiator); + + // Clean up old watched outpoints. + let (orig_outpoint, orig_script) = original_funding_outpoint; + let first_splice_funding_idx = first_splice_tx + .output + .iter() + .position(|o| o.script_pubkey == new_funding_script) + .unwrap(); + let first_splice_outpoint = OutPoint { + txid: first_splice_tx.compute_txid(), + index: first_splice_funding_idx as u16, + }; + for node in &nodes { + node.chain_source.remove_watched_txn_and_outputs(orig_outpoint, orig_script.clone()); + node.chain_source + .remove_watched_txn_and_outputs(first_splice_outpoint, new_funding_script.clone()); + } + + // === Part 2: Node 1's preserved QuiescentAction leads to a new splice === + // + // After splice_locked, pending_splice is None. So when stfu() consumes the + // QuiescentAction, it sends SpliceInit (not TxInitRbf), starting a brand new splice. + + // Node 0 receives node 1's STFU and responds with its own STFU. + nodes[0].node.handle_stfu(node_id_1, &stfu_1); + let stfu_0 = get_event_msg!(nodes[0], MessageSendEvent::SendStfu, node_id_1); + + // Node 1 receives STFU → quiescence established → node 1 is the initiator → + // sends SpliceInit. + nodes[1].node.handle_stfu(node_id_0, &stfu_0); + let splice_init = get_event_msg!(nodes[1], MessageSendEvent::SendSpliceInit, node_id_0); + + // Node 0 handles SpliceInit → sends SpliceAck. + nodes[0].node.handle_splice_init(node_id_1, &splice_init); + let splice_ack = get_event_msg!(nodes[0], MessageSendEvent::SendSpliceAck, node_id_1); + + // Node 1 handles SpliceAck → starts interactive tx construction. + nodes[1].node.handle_splice_ack(node_id_0, &splice_ack); + + // Compute the new funding script from the splice pubkeys. + let new_funding_script_2 = chan_utils::make_funding_redeemscript( + &splice_init.funding_pubkey, + &splice_ack.funding_pubkey, + ) + .to_p2wsh(); + + // Complete interactive funding negotiation with node 1 as initiator (only node 1 + // contributes). + complete_interactive_funding_negotiation( + &nodes[1], + &nodes[0], + channel_id, + node_1_funding_contribution, + new_funding_script_2, + ); + + // Sign (no acceptor contribution) and broadcast. + let (new_splice_tx, splice_locked) = + sign_interactive_funding_tx(&nodes[1], &nodes[0], false); + assert!(splice_locked.is_none()); + + expect_splice_pending_event(&nodes[1], &node_id_0); + expect_splice_pending_event(&nodes[0], &node_id_1); + + // Mine and lock. + mine_transaction(&nodes[1], &new_splice_tx); + mine_transaction(&nodes[0], &new_splice_tx); + + lock_splice_after_blocks(&nodes[1], &nodes[0], ANTI_REORG_DELAY - 1); + } +} + +#[test] +fn test_splice_rbf_tiebreak_feerate_too_high_rejected() { + // Node 0 (winner) proposes an RBF feerate far above node 1's (loser) max_feerate, and + // node 1's fair fee at that feerate exceeds its budget. This triggers + // FeeRateAdjustmentError::TooHigh in the queued contribution path, causing node 1 to + // reject with WarnAndDisconnect. + 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); + + // Complete an initial splice-in from node 0. + let funding_contribution = do_initiate_splice_in(&nodes[0], &nodes[1], channel_id, added_value); + let (_first_splice_tx, _new_funding_script) = + splice_channel(&nodes[0], &nodes[1], channel_id, funding_contribution); + + // Provide more UTXOs for both nodes' RBF attempts. + provide_utxo_reserves(&nodes, 2, added_value * 2); + + // Node 0 uses an extremely high feerate (100,000 sat/kwu). Node 1 uses the minimum RBF + // feerate with a moderate splice-in (50,000 sats) and a low max_feerate (3,000 sat/kwu). + // The target (100k) far exceeds node 1's max (3k), and the fair fee at 100k exceeds + // node 1's budget, triggering TooHigh. + let high_feerate = FeeRate::from_sat_per_kwu(100_000); + let min_rbf_feerate_sat_per_kwu = (FEERATE_FLOOR_SATS_PER_KW as u64 * 25 + 23) / 24; + let min_rbf_feerate = FeeRate::from_sat_per_kwu(min_rbf_feerate_sat_per_kwu); + let node_1_max_feerate = FeeRate::from_sat_per_kwu(3_000); + + let funding_template_0 = + nodes[0].node.rbf_channel(&channel_id, &node_id_1, high_feerate, FeeRate::MAX).unwrap(); + let wallet_0 = WalletSync::new(Arc::clone(&nodes[0].wallet_source), nodes[0].logger); + let node_0_funding_contribution = + funding_template_0.splice_in_sync(added_value, &wallet_0).unwrap(); + nodes[0] + .node + .funding_contributed(&channel_id, &node_id_1, node_0_funding_contribution.clone(), None) + .unwrap(); + + let funding_template_1 = nodes[1] + .node + .rbf_channel(&channel_id, &node_id_0, min_rbf_feerate, node_1_max_feerate) + .unwrap(); + let wallet_1 = WalletSync::new(Arc::clone(&nodes[1].wallet_source), nodes[1].logger); + let node_1_funding_contribution = + funding_template_1.splice_in_sync(added_value, &wallet_1).unwrap(); + nodes[1] + .node + .funding_contributed(&channel_id, &node_id_0, node_1_funding_contribution.clone(), None) + .unwrap(); + + // Both sent STFU. + let stfu_0 = get_event_msg!(nodes[0], MessageSendEvent::SendStfu, node_id_1); + let stfu_1 = get_event_msg!(nodes[1], MessageSendEvent::SendStfu, node_id_0); + + // Tie-break: node 0 wins. + nodes[1].node.handle_stfu(node_id_0, &stfu_0); + assert!(nodes[1].node.get_and_clear_pending_msg_events().is_empty()); + nodes[0].node.handle_stfu(node_id_1, &stfu_1); + + // Node 0 sends tx_init_rbf at 100,000 sat/kwu. + let tx_init_rbf = get_event_msg!(nodes[0], MessageSendEvent::SendTxInitRbf, node_id_1); + assert_eq!(tx_init_rbf.feerate_sat_per_1000_weight, high_feerate.to_sat_per_kwu() as u32); + + // Node 1 handles tx_init_rbf — TooHigh: target (100k) >> max (3k) and fair fee > budget. + nodes[1].node.handle_tx_init_rbf(node_id_0, &tx_init_rbf); + + let msg_events = nodes[1].node.get_and_clear_pending_msg_events(); + assert_eq!(msg_events.len(), 1, "{msg_events:?}"); + match &msg_events[0] { + MessageSendEvent::HandleError { + action: msgs::ErrorAction::DisconnectPeerWithWarning { msg }, + .. + } => { + assert!( + msg.data.contains("Cannot accommodate initiator's feerate"), + "Unexpected warning: {}", + msg.data + ); + }, + other => panic!("Expected HandleError/DisconnectPeerWithWarning, got {:?}", other), + } +} + +#[test] +fn test_splice_rbf_acceptor_recontributes() { + // When the counterparty RBFs a splice and we have no pending QuiescentAction, + // our prior contribution should be automatically re-used. This tests the scenario: + // 1. Both nodes contribute to a splice (tiebreak: node 0 wins). + // 2. Only node 0 initiates an RBF — node 1 has no QuiescentAction. + // 3. Node 1 should re-contribute its prior inputs/outputs via our_prior_contribution. + 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, Amount::from_sat(100_000)); + + // Step 1: Both nodes initiate a splice at floor feerate. + let feerate = FeeRate::from_sat_per_kwu(FEERATE_FLOOR_SATS_PER_KW as u64); + + let funding_template_0 = + nodes[0].node.splice_channel(&channel_id, &node_id_1, feerate, FeeRate::MAX).unwrap(); + let wallet_0 = WalletSync::new(Arc::clone(&nodes[0].wallet_source), nodes[0].logger); + let node_0_funding_contribution = + funding_template_0.splice_in_sync(added_value, &wallet_0).unwrap(); + nodes[0] + .node + .funding_contributed(&channel_id, &node_id_1, node_0_funding_contribution.clone(), None) + .unwrap(); + + let funding_template_1 = + nodes[1].node.splice_channel(&channel_id, &node_id_0, feerate, FeeRate::MAX).unwrap(); + let wallet_1 = WalletSync::new(Arc::clone(&nodes[1].wallet_source), nodes[1].logger); + let node_1_funding_contribution = + funding_template_1.splice_in_sync(added_value, &wallet_1).unwrap(); + nodes[1] + .node + .funding_contributed(&channel_id, &node_id_0, node_1_funding_contribution.clone(), None) + .unwrap(); + + // Step 2: Both send STFU; tiebreak: node 0 wins. + let stfu_0 = get_event_msg!(nodes[0], MessageSendEvent::SendStfu, node_id_1); + let stfu_1 = get_event_msg!(nodes[1], MessageSendEvent::SendStfu, node_id_0); + + nodes[1].node.handle_stfu(node_id_0, &stfu_0); + assert!(nodes[1].node.get_and_clear_pending_msg_events().is_empty()); + nodes[0].node.handle_stfu(node_id_1, &stfu_1); + + // Step 3: Node 0 sends SpliceInit, node 1 handles as acceptor (QuiescentAction consumed). + let splice_init = get_event_msg!(nodes[0], MessageSendEvent::SendSpliceInit, node_id_1); + nodes[1].node.handle_splice_init(node_id_0, &splice_init); + let splice_ack = get_event_msg!(nodes[1], MessageSendEvent::SendSpliceAck, node_id_0); + assert_ne!(splice_ack.funding_contribution_satoshis, 0); + nodes[0].node.handle_splice_ack(node_id_1, &splice_ack); + + let new_funding_script = chan_utils::make_funding_redeemscript( + &splice_init.funding_pubkey, + &splice_ack.funding_pubkey, + ) + .to_p2wsh(); + + // Complete interactive funding with both contributions. + complete_interactive_funding_negotiation_for_both( + &nodes[0], + &nodes[1], + channel_id, + node_0_funding_contribution, + Some(node_1_funding_contribution.clone()), + splice_ack.funding_contribution_satoshis, + new_funding_script.clone(), + ); + + let (first_splice_tx, splice_locked) = + sign_interactive_funding_tx_with_acceptor_contribution(&nodes[0], &nodes[1], false, true); + assert!(splice_locked.is_none()); + + let original_funding_outpoint = nodes[0] + .chain_monitor + .chain_monitor + .get_monitor(channel_id) + .map(|monitor| (monitor.get_funding_txo(), monitor.get_funding_script())) + .unwrap(); + + expect_splice_pending_event(&nodes[0], &node_id_1); + expect_splice_pending_event(&nodes[1], &node_id_0); + + // Step 4: Provide new UTXOs for node 0's RBF (node 1 does NOT initiate RBF). + provide_utxo_reserves(&nodes, 2, added_value * 2); + + // Step 5: Only node 0 calls rbf_channel + funding_contributed. + let rbf_feerate_sat_per_kwu = (FEERATE_FLOOR_SATS_PER_KW as u64 * 25 + 23) / 24; + let rbf_feerate = FeeRate::from_sat_per_kwu(rbf_feerate_sat_per_kwu); + let rbf_funding_contribution = + do_initiate_rbf_splice_in(&nodes[0], &nodes[1], channel_id, added_value, rbf_feerate); + + // Steps 6-9: STFU exchange → tx_init_rbf → tx_ack_rbf. + // Node 1 should re-contribute via our_prior_contribution. + let tx_ack_rbf = complete_rbf_handshake(&nodes[0], &nodes[1]); + assert!( + tx_ack_rbf.funding_output_contribution.is_some(), + "Acceptor should re-contribute via our_prior_contribution" + ); + + // Step 10: Complete interactive funding with both contributions. + // Node 1's prior contribution is re-used — pass a clone for matching. + complete_interactive_funding_negotiation_for_both( + &nodes[0], + &nodes[1], + channel_id, + rbf_funding_contribution, + Some(node_1_funding_contribution), + tx_ack_rbf.funding_output_contribution.unwrap(), + new_funding_script.clone(), + ); + + // Step 11: Sign (acceptor has contribution) and broadcast. + let (rbf_tx, splice_locked) = + sign_interactive_funding_tx_with_acceptor_contribution(&nodes[0], &nodes[1], false, true); + assert!(splice_locked.is_none()); + + expect_splice_pending_event(&nodes[0], &node_id_1); + expect_splice_pending_event(&nodes[1], &node_id_0); + + // Step 12: Mine, lock, and verify DiscardFunding for the replaced splice candidate. + lock_rbf_splice_after_blocks( + &nodes[0], + &nodes[1], + &rbf_tx, + ANTI_REORG_DELAY - 1, + &[first_splice_tx.compute_txid()], + ); + + // Clean up old watched outpoints. + let (orig_outpoint, orig_script) = original_funding_outpoint; + let first_splice_funding_idx = + first_splice_tx.output.iter().position(|o| o.script_pubkey == new_funding_script).unwrap(); + let first_splice_outpoint = + OutPoint { txid: first_splice_tx.compute_txid(), index: first_splice_funding_idx as u16 }; + for node in &nodes { + node.chain_source.remove_watched_txn_and_outputs(orig_outpoint, orig_script.clone()); + node.chain_source + .remove_watched_txn_and_outputs(first_splice_outpoint, new_funding_script.clone()); + } +} + +#[test] +fn test_splice_rbf_recontributes_feerate_too_high() { + // When the counterparty RBFs at a feerate too high for our prior contribution, + // we should reject the RBF rather than proceeding without our contribution. + 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); + + provide_utxo_reserves(&nodes, 2, Amount::from_sat(100_000)); + + // Step 1: Both nodes initiate a splice. Node 0 at floor feerate, node 1 splices in 95k + // from a 100k UTXO (tight budget: ~5k for change/fees). + let floor_feerate = FeeRate::from_sat_per_kwu(FEERATE_FLOOR_SATS_PER_KW as u64); + + let funding_template_0 = + nodes[0].node.splice_channel(&channel_id, &node_id_1, floor_feerate, FeeRate::MAX).unwrap(); + let wallet_0 = WalletSync::new(Arc::clone(&nodes[0].wallet_source), nodes[0].logger); + let node_0_funding_contribution = + funding_template_0.splice_in_sync(Amount::from_sat(50_000), &wallet_0).unwrap(); + nodes[0] + .node + .funding_contributed(&channel_id, &node_id_1, node_0_funding_contribution.clone(), None) + .unwrap(); + + let node_1_added_value = Amount::from_sat(95_000); + let funding_template_1 = + nodes[1].node.splice_channel(&channel_id, &node_id_0, floor_feerate, FeeRate::MAX).unwrap(); + let wallet_1 = WalletSync::new(Arc::clone(&nodes[1].wallet_source), nodes[1].logger); + let node_1_funding_contribution = + funding_template_1.splice_in_sync(node_1_added_value, &wallet_1).unwrap(); + nodes[1] + .node + .funding_contributed(&channel_id, &node_id_0, node_1_funding_contribution.clone(), None) + .unwrap(); + + // Step 2: Both send STFU; tiebreak: node 0 wins. + let stfu_0 = get_event_msg!(nodes[0], MessageSendEvent::SendStfu, node_id_1); + let stfu_1 = get_event_msg!(nodes[1], MessageSendEvent::SendStfu, node_id_0); + + nodes[1].node.handle_stfu(node_id_0, &stfu_0); + assert!(nodes[1].node.get_and_clear_pending_msg_events().is_empty()); + nodes[0].node.handle_stfu(node_id_1, &stfu_1); + + // Step 3: Complete the initial splice with both contributing. + let splice_init = get_event_msg!(nodes[0], MessageSendEvent::SendSpliceInit, node_id_1); + nodes[1].node.handle_splice_init(node_id_0, &splice_init); + let splice_ack = get_event_msg!(nodes[1], MessageSendEvent::SendSpliceAck, node_id_0); + assert_ne!(splice_ack.funding_contribution_satoshis, 0); + nodes[0].node.handle_splice_ack(node_id_1, &splice_ack); + + let new_funding_script = chan_utils::make_funding_redeemscript( + &splice_init.funding_pubkey, + &splice_ack.funding_pubkey, + ) + .to_p2wsh(); + + complete_interactive_funding_negotiation_for_both( + &nodes[0], + &nodes[1], + channel_id, + node_0_funding_contribution, + Some(node_1_funding_contribution), + splice_ack.funding_contribution_satoshis, + new_funding_script.clone(), + ); + + let (_first_splice_tx, splice_locked) = + sign_interactive_funding_tx_with_acceptor_contribution(&nodes[0], &nodes[1], false, true); + assert!(splice_locked.is_none()); + + expect_splice_pending_event(&nodes[0], &node_id_1); + expect_splice_pending_event(&nodes[1], &node_id_0); + + // Step 4: Provide new UTXOs. Node 0 initiates RBF at 20,000 sat/kwu. + provide_utxo_reserves(&nodes, 2, Amount::from_sat(100_000)); + + let high_feerate = FeeRate::from_sat_per_kwu(20_000); + let funding_template = + nodes[0].node.rbf_channel(&channel_id, &node_id_1, high_feerate, FeeRate::MAX).unwrap(); + let wallet = WalletSync::new(Arc::clone(&nodes[0].wallet_source), nodes[0].logger); + let rbf_funding_contribution = + funding_template.splice_in_sync(Amount::from_sat(50_000), &wallet).unwrap(); + nodes[0] + .node + .funding_contributed(&channel_id, &node_id_1, rbf_funding_contribution.clone(), None) + .unwrap(); + + // Step 5: STFU exchange. + let stfu_a = get_event_msg!(nodes[0], MessageSendEvent::SendStfu, node_id_1); + nodes[1].node.handle_stfu(node_id_0, &stfu_a); + let stfu_b = get_event_msg!(nodes[1], MessageSendEvent::SendStfu, node_id_0); + nodes[0].node.handle_stfu(node_id_1, &stfu_b); + + // Step 6: Node 0 sends tx_init_rbf at 20,000 sat/kwu. + let tx_init_rbf = get_event_msg!(nodes[0], MessageSendEvent::SendTxInitRbf, node_id_1); + assert_eq!(tx_init_rbf.feerate_sat_per_1000_weight, high_feerate.to_sat_per_kwu() as u32); + + // Step 7: Node 1's prior contribution (95k from 100k UTXO) can't cover fees at 20k sat/kwu. + // Should reject with WarnAndDisconnect rather than proceeding without contribution. + nodes[1].node.handle_tx_init_rbf(node_id_0, &tx_init_rbf); + + let msg_events = nodes[1].node.get_and_clear_pending_msg_events(); + assert_eq!(msg_events.len(), 1, "{msg_events:?}"); + match &msg_events[0] { + MessageSendEvent::HandleError { + action: msgs::ErrorAction::DisconnectPeerWithWarning { msg }, + .. + } => { + assert!( + msg.data.contains("cannot accommodate RBF feerate"), + "Unexpected warning: {}", + msg.data + ); + }, + other => panic!("Expected HandleError/DisconnectPeerWithWarning, got {:?}", other), + } +} + +#[test] +fn test_splice_rbf_sequential() { + // Three consecutive RBF rounds on the same splice (initial → RBF #1 → RBF #2). + // Node 0 is the quiescence initiator; node 1 is the acceptor with no contribution. + // Verifies: + // - Each round satisfies the 25/24 feerate rule + // - DiscardFunding events reference the correct txids from previous rounds + // - The final RBF can be mined and splice_locked successfully + 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); + + // Save the pre-splice funding outpoint. + let original_funding_outpoint = nodes[0] + .chain_monitor + .chain_monitor + .get_monitor(channel_id) + .map(|monitor| (monitor.get_funding_txo(), monitor.get_funding_script())) + .unwrap(); + + // --- Round 0: Initial splice-in from node 0 at floor feerate (253). --- + let funding_contribution = do_initiate_splice_in(&nodes[0], &nodes[1], channel_id, added_value); + let (splice_tx_0, new_funding_script) = + splice_channel(&nodes[0], &nodes[1], channel_id, funding_contribution); + + // Feerate progression: 253 → ceil(253*25/24) = 264 → ceil(264*25/24) = 275 + let feerate_1_sat_per_kwu = (FEERATE_FLOOR_SATS_PER_KW as u64 * 25 + 23) / 24; // 264 + let feerate_2_sat_per_kwu = (feerate_1_sat_per_kwu * 25 + 23) / 24; // 275 + + // --- Round 1: RBF #1 at feerate 264. --- + provide_utxo_reserves(&nodes, 2, added_value * 2); + + let rbf_feerate_1 = FeeRate::from_sat_per_kwu(feerate_1_sat_per_kwu); + let funding_contribution_1 = + do_initiate_rbf_splice_in(&nodes[0], &nodes[1], channel_id, added_value, rbf_feerate_1); + complete_rbf_handshake(&nodes[0], &nodes[1]); + + complete_interactive_funding_negotiation( + &nodes[0], + &nodes[1], + channel_id, + funding_contribution_1, + new_funding_script.clone(), + ); + let (splice_tx_1, splice_locked) = sign_interactive_funding_tx(&nodes[0], &nodes[1], false); + assert!(splice_locked.is_none()); + expect_splice_pending_event(&nodes[0], &node_id_1); + expect_splice_pending_event(&nodes[1], &node_id_0); + + // --- Round 2: RBF #2 at feerate 275. --- + provide_utxo_reserves(&nodes, 2, added_value * 2); + + let rbf_feerate_2 = FeeRate::from_sat_per_kwu(feerate_2_sat_per_kwu); + let funding_contribution_2 = + do_initiate_rbf_splice_in(&nodes[0], &nodes[1], channel_id, added_value, rbf_feerate_2); + complete_rbf_handshake(&nodes[0], &nodes[1]); + + complete_interactive_funding_negotiation( + &nodes[0], + &nodes[1], + channel_id, + funding_contribution_2, + new_funding_script.clone(), + ); + let (rbf_tx_final, splice_locked) = sign_interactive_funding_tx(&nodes[0], &nodes[1], false); + assert!(splice_locked.is_none()); + expect_splice_pending_event(&nodes[0], &node_id_1); + expect_splice_pending_event(&nodes[1], &node_id_0); + + // --- Mine and lock the final RBF, verifying DiscardFunding for both replaced candidates. --- + let splice_tx_0_txid = splice_tx_0.compute_txid(); + let splice_tx_1_txid = splice_tx_1.compute_txid(); + lock_rbf_splice_after_blocks( + &nodes[0], + &nodes[1], + &rbf_tx_final, + ANTI_REORG_DELAY - 1, + &[splice_tx_0_txid, splice_tx_1_txid], + ); + + // Clean up old watched outpoints. + let (orig_outpoint, orig_script) = original_funding_outpoint; + let splice_funding_idx = |tx: &Transaction| { + tx.output.iter().position(|o| o.script_pubkey == new_funding_script).unwrap() + }; + let outpoint_0 = + OutPoint { txid: splice_tx_0_txid, index: splice_funding_idx(&splice_tx_0) as u16 }; + let outpoint_1 = + OutPoint { txid: splice_tx_1_txid, index: splice_funding_idx(&splice_tx_1) as u16 }; + for node in &nodes { + node.chain_source.remove_watched_txn_and_outputs(orig_outpoint, orig_script.clone()); + node.chain_source.remove_watched_txn_and_outputs(outpoint_0, new_funding_script.clone()); + node.chain_source.remove_watched_txn_and_outputs(outpoint_1, new_funding_script.clone()); + } +} + +#[test] +fn test_splice_rbf_disconnect_filters_prior_contributions() { + // When disconnecting during an RBF round that reuses the same UTXOs as a prior round, + // the SpliceFundingFailed event should filter out inputs/outputs still committed to the prior + // round. This exercises the `reset_pending_splice_state` → `maybe_create_splice_funding_failed` + // macro path. + 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 exactly 1 UTXO per node so coin selection is deterministic. + provide_utxo_reserves(&nodes, 1, added_value * 2); + + // --- Round 0: Initial splice-in at floor feerate (253). --- + let funding_contribution = do_initiate_splice_in(&nodes[0], &nodes[1], channel_id, added_value); + let (_splice_tx_0, _new_funding_script) = + splice_channel(&nodes[0], &nodes[1], channel_id, funding_contribution); + + // --- Round 1: RBF at higher feerate without providing new UTXOs. --- + // The wallet reselects the same UTXO since the splice tx hasn't been mined. + let feerate_1_sat_per_kwu = (FEERATE_FLOOR_SATS_PER_KW as u64 * 25 + 23) / 24; + let rbf_feerate = FeeRate::from_sat_per_kwu(feerate_1_sat_per_kwu); + let funding_contribution_1 = + do_initiate_rbf_splice_in(&nodes[0], &nodes[1], channel_id, added_value, rbf_feerate); + let rbf_change_output = funding_contribution_1.change_output().cloned(); + + // STFU exchange + RBF handshake to start interactive TX. + complete_rbf_handshake(&nodes[0], &nodes[1]); + + // Disconnect mid-negotiation. Stale interactive TX messages are cleared by peer_disconnected. + nodes[0].node.peer_disconnected(node_id_1); + nodes[1].node.peer_disconnected(node_id_0); + + // The initiator should get SpliceFailed + DiscardFunding with filtered contributions. + let events = nodes[0].node.get_and_clear_pending_events(); + assert_eq!(events.len(), 2, "{events:?}"); + match &events[0] { + Event::SpliceFailed { channel_id: cid, .. } => { + assert_eq!(*cid, channel_id); + }, + other => panic!("Expected SpliceFailed, got {:?}", other), + } + match &events[1] { + Event::DiscardFunding { + funding_info: FundingInfo::Contribution { inputs, outputs }, + .. + } => { + // The UTXO was filtered out: it's still committed to round 0's splice. + assert!(inputs.is_empty(), "Expected empty inputs (filtered), got {:?}", inputs); + // The change output was NOT filtered: different feerate produces a different amount. + let expected_outputs: Vec<_> = rbf_change_output.into_iter().collect(); + assert_eq!(*outputs, expected_outputs); + }, + other => panic!("Expected DiscardFunding with Contribution, got {:?}", other), + } + + // Reconnect. After a completed splice, channel_ready is not re-sent. + let mut reconnect_args = ReconnectArgs::new(&nodes[0], &nodes[1]); + reconnect_args.send_announcement_sigs = (true, true); + reconnect_nodes(reconnect_args); +}