diff --git a/fuzz/src/chanmon_consistency.rs b/fuzz/src/chanmon_consistency.rs index 22006897a0f..1e8effedd5f 100644 --- a/fuzz/src/chanmon_consistency.rs +++ b/fuzz/src/chanmon_consistency.rs @@ -55,7 +55,7 @@ use lightning::ln::channelmanager::{ ChainParameters, ChannelManager, ChannelManagerReadArgs, PaymentId, RecentPaymentDetails, }; use lightning::ln::functional_test_utils::*; -use lightning::ln::funding::{FundingContribution, FundingTemplate}; +use lightning::ln::funding::{FundingContribution, FundingContributionError, FundingTemplate}; use lightning::ln::inbound_payment::ExpandedKey; use lightning::ln::msgs::{ self, BaseMessageHandler, ChannelMessageHandler, CommitmentUpdate, Init, MessageSendEvent, @@ -1388,36 +1388,31 @@ pub fn do_test( }}; } - let splice_channel = |node: &ChanMan, - counterparty_node_id: &PublicKey, - channel_id: &ChannelId, - f: &dyn Fn(FundingTemplate) -> Result, - funding_feerate_sat_per_kw: FeeRate| { - match node.splice_channel( - channel_id, - counterparty_node_id, - funding_feerate_sat_per_kw, - FeeRate::MAX, - ) { - Ok(funding_template) => { - if let Ok(contribution) = f(funding_template) { - let _ = node.funding_contributed( - channel_id, - counterparty_node_id, - contribution, - None, + let splice_channel = + |node: &ChanMan, + counterparty_node_id: &PublicKey, + channel_id: &ChannelId, + f: &dyn Fn(FundingTemplate) -> Result| { + match node.splice_channel(channel_id, counterparty_node_id) { + Ok(funding_template) => { + if let Ok(contribution) = f(funding_template) { + let _ = node.funding_contributed( + channel_id, + counterparty_node_id, + contribution, + None, + ); + } + }, + Err(e) => { + assert!( + matches!(e, APIError::APIMisuseError { ref err } if err.contains("splice")), + "{:?}", + e ); - } - }, - Err(e) => { - assert!( - matches!(e, APIError::APIMisuseError { ref err } if err.contains("splice")), - "{:?}", - e - ); - }, - } - }; + }, + } + }; let splice_in = |node: &ChanMan, @@ -1430,9 +1425,15 @@ pub fn do_test( counterparty_node_id, channel_id, &move |funding_template: FundingTemplate| { - funding_template.splice_in_sync(Amount::from_sat(10_000), wallet) + let feerate = + funding_template.min_rbf_feerate().unwrap_or(funding_feerate_sat_per_kw); + funding_template.splice_in_sync( + Amount::from_sat(10_000), + feerate, + FeeRate::MAX, + wallet, + ) }, - funding_feerate_sat_per_kw, ); }; @@ -1454,19 +1455,19 @@ pub fn do_test( if outbound_capacity_msat < 20_000_000 { return; } - splice_channel( - node, - counterparty_node_id, - channel_id, - &move |funding_template| { - let outputs = vec![TxOut { - value: Amount::from_sat(MAX_STD_OUTPUT_DUST_LIMIT_SATOSHIS), - script_pubkey: wallet.get_change_script().unwrap(), - }]; - funding_template.splice_out_sync(outputs, &WalletSync::new(wallet, logger.clone())) - }, - funding_feerate_sat_per_kw, - ); + splice_channel(node, counterparty_node_id, channel_id, &move |funding_template| { + let feerate = funding_template.min_rbf_feerate().unwrap_or(funding_feerate_sat_per_kw); + let outputs = vec![TxOut { + value: Amount::from_sat(MAX_STD_OUTPUT_DUST_LIMIT_SATOSHIS), + script_pubkey: wallet.get_change_script().unwrap(), + }]; + funding_template.splice_out_sync( + outputs, + feerate, + FeeRate::MAX, + &WalletSync::new(wallet, logger.clone()), + ) + }); }; loop { diff --git a/fuzz/src/full_stack.rs b/fuzz/src/full_stack.rs index 5dfa51079d8..f8f70fdc378 100644 --- a/fuzz/src/full_stack.rs +++ b/fuzz/src/full_stack.rs @@ -1032,16 +1032,18 @@ pub fn do_test(mut data: &[u8], logger: &Arc } let chan_id = chan.channel_id; let counterparty = chan.counterparty.node_id; - if let Ok(funding_template) = channelmanager.splice_channel( - &chan_id, - &counterparty, - FeeRate::from_sat_per_kwu(253), - FeeRate::MAX, - ) { + if let Ok(funding_template) = channelmanager.splice_channel(&chan_id, &counterparty) + { + let feerate = funding_template + .min_rbf_feerate() + .unwrap_or(FeeRate::from_sat_per_kwu(253)); let wallet_sync = WalletSync::new(&wallet, Arc::clone(&logger)); - if let Ok(contribution) = funding_template - .splice_in_sync(Amount::from_sat(splice_in_sats.min(900_000)), &wallet_sync) - { + if let Ok(contribution) = funding_template.splice_in_sync( + Amount::from_sat(splice_in_sats.min(900_000)), + feerate, + FeeRate::MAX, + &wallet_sync, + ) { let _ = channelmanager.funding_contributed( &chan_id, &counterparty, @@ -1073,20 +1075,22 @@ pub fn do_test(mut data: &[u8], logger: &Arc let splice_out_sats = splice_out_sats.min(max_splice_out).max(546); // At least dust limit let chan_id = chan.channel_id; let counterparty = chan.counterparty.node_id; - if let Ok(funding_template) = channelmanager.splice_channel( - &chan_id, - &counterparty, - FeeRate::from_sat_per_kwu(253), - FeeRate::MAX, - ) { + if let Ok(funding_template) = channelmanager.splice_channel(&chan_id, &counterparty) + { + let feerate = funding_template + .min_rbf_feerate() + .unwrap_or(FeeRate::from_sat_per_kwu(253)); let outputs = vec![TxOut { value: Amount::from_sat(splice_out_sats), script_pubkey: wallet.get_change_script().unwrap(), }]; let wallet_sync = WalletSync::new(&wallet, Arc::clone(&logger)); - if let Ok(contribution) = - funding_template.splice_out_sync(outputs, &wallet_sync) - { + if let Ok(contribution) = funding_template.splice_out_sync( + outputs, + feerate, + FeeRate::MAX, + &wallet_sync, + ) { let _ = channelmanager.funding_contributed( &chan_id, &counterparty, diff --git a/lightning/src/ln/channel.rs b/lightning/src/ln/channel.rs index 82d7d3bb92f..d21164d9020 100644 --- a/lightning/src/ln/channel.rs +++ b/lightning/src/ln/channel.rs @@ -56,7 +56,7 @@ use crate::ln::channelmanager::{ MAX_LOCAL_BREAKDOWN_TIMEOUT, MIN_CLTV_EXPIRY_DELTA, }; use crate::ln::funding::{ - FeeRateAdjustmentError, FundingContribution, FundingTemplate, FundingTxInput, + FeeRateAdjustmentError, FundingContribution, FundingTemplate, FundingTxInput, PriorContribution, }; use crate::ln::interactivetxs::{ AbortReason, HandleTxCompleteValue, InteractiveTxConstructor, InteractiveTxConstructorArgs, @@ -2908,11 +2908,17 @@ struct PendingFunding { /// 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. + /// The funding contributions from splice/RBF rounds where we contributed. + /// + /// A new entry is appended when we contribute to a negotiation round (either as initiator + /// or acceptor). Rounds where we don't contribute (e.g., counterparty-only splice) do not + /// add an entry. Once non-empty, every subsequent round appends: when the counterparty + /// initiates an RBF, the last entry is adjusted to the new feerate and appended as a new + /// entry (or the RBF is rejected if the adjustment fails, in which case no round starts). + /// + /// If the round aborts, the last entry is popped in + /// [`FundedChannel::reset_pending_splice_state`], restoring the prior round's contribution + /// as the most recent entry. contributions: Vec, } @@ -2973,6 +2979,21 @@ impl FundingNegotiation { } } + fn funding_feerate_sat_per_1000_weight(&self) -> u32 { + match self { + FundingNegotiation::AwaitingAck { context, .. } => { + context.funding_feerate_sat_per_1000_weight + }, + FundingNegotiation::ConstructingTransaction { + funding_feerate_sat_per_1000_weight, + .. + } => *funding_feerate_sat_per_1000_weight, + FundingNegotiation::AwaitingSignatures { + funding_feerate_sat_per_1000_weight, .. + } => *funding_feerate_sat_per_1000_weight, + } + } + fn is_initiator(&self) -> bool { match self { FundingNegotiation::AwaitingAck { context, .. } => context.is_initiator, @@ -3079,6 +3100,22 @@ impl PendingFunding { } } + /// After several RBF attempts, checks that the feerate is high enough to confirm. Returns + /// `true` if the feerate is sufficient or the threshold hasn't been reached. + /// + /// The spec requires: "MUST set a high enough feerate to ensure quick confirmation." + fn is_rbf_feerate_sufficient( + &self, feerate_sat_per_kw: u32, fee_estimator: &LowerBoundedFeeEstimator, + ) -> bool { + const MAX_LOW_FEERATE_RBF_ATTEMPTS: usize = 3; + if self.negotiated_candidates.len() <= MAX_LOW_FEERATE_RBF_ATTEMPTS { + return true; + } + let min_feerate = + fee_estimator.bounded_sat_per_1000_weight(ConfirmationTarget::NonAnchorChannelFee); + feerate_sat_per_kw >= min_feerate + } + fn contributed_inputs(&self) -> impl Iterator + '_ { self.contributions.iter().flat_map(|c| c.contributed_inputs()) } @@ -6775,24 +6812,30 @@ where shutdown_result } + /// Builds a [`SpliceFundingFailed`] from a contribution, filtering out inputs/outputs + /// that are still committed to a prior splice round. + fn splice_funding_failed_for(&self, contribution: FundingContribution) -> SpliceFundingFailed { + 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.script_pubkey != output.script_pubkey); + } + } + SpliceFundingFailed { + funding_txo: None, + channel_type: None, + contributed_inputs: inputs, + contributed_outputs: outputs, + } + } + 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.script_pubkey != output.script_pubkey); - } - } - QuiescentError::FailSplice(SpliceFundingFailed { - funding_txo: None, - channel_type: None, - contributed_inputs: inputs, - contributed_outputs: outputs, - }) + QuiescentError::FailSplice(self.splice_funding_failed_for(contribution)) }, #[cfg(any(test, fuzzing, feature = "_test_utils"))] QuiescentAction::DoNothing => QuiescentError::DoNothing, @@ -6937,6 +6980,22 @@ where into_contributed_inputs_and_outputs ); + // Pop the current round's contribution if it wasn't from a negotiated round. Each round + // pushes a new entry to `contributions`; if the round aborts, we undo the push so that + // `contributions.last()` reflects the most recent negotiated round's contribution. This + // must happen after `maybe_create_splice_funding_failed` so that + // `prior_contributed_inputs` still includes the prior rounds' entries for filtering. + if let Some(pending_splice) = self.pending_splice.as_mut() { + if let Some(last) = pending_splice.contributions.last() { + let was_negotiated = pending_splice + .last_funding_feerate_sat_per_1000_weight + .is_some_and(|f| last.feerate() == FeeRate::from_sat_per_kwu(f as u64)); + if !was_negotiated { + pending_splice.contributions.pop(); + } + } + } + if self.pending_funding().is_empty() { self.pending_splice.take(); } @@ -11892,10 +11951,8 @@ where } } - /// Initiate splicing. - pub fn splice_channel( - &self, min_feerate: FeeRate, max_feerate: FeeRate, - ) -> Result { + /// Builds a [`FundingTemplate`] for splicing or RBF, if the channel state allows it. + pub fn splice_channel(&self) -> Result { if self.holder_commitment_point.current_point().is_none() { return Err(APIError::APIMisuseError { err: format!( @@ -11937,16 +11994,45 @@ where }); } - 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, - ), + let (min_rbf_feerate, prior_contribution) = if self.is_rbf_compatible().is_err() { + // Channel can never RBF (e.g., zero-conf). + (None, None) + } else if let Some(pending_splice) = self.pending_splice.as_ref() { + // A splice is pending — either a completed negotiation that hasn't locked yet + // or an in-progress negotiation. In either case, the user's splice will need + // to satisfy the minimum RBF feerate, derived from the most recent feerate: + // - last_funding_feerate: from a completed but unlocked negotiation + // - funding_negotiation feerate: from an in-progress negotiation + // + // If the in-progress negotiation later fails (e.g., tx_abort), the derived + // min_rbf_feerate becomes stale, causing a slightly higher feerate than + // necessary. Call splice_channel again after receiving SpliceFailed to get a + // fresh template without the stale RBF constraint. + let prev_feerate = + pending_splice.last_funding_feerate_sat_per_1000_weight.or_else(|| { + pending_splice + .funding_negotiation + .as_ref() + .map(|n| n.funding_feerate_sat_per_1000_weight()) + }); + debug_assert!( + prev_feerate.is_some(), + "pending_splice should have last_funding_feerate or funding_negotiation", + ); + let min_rbf_feerate = prev_feerate.map(|f| { + let min_feerate_kwu = ((f as u64) * 25).div_ceil(24); + FeeRate::from_sat_per_kwu(min_feerate_kwu) }); - } + let prior = if pending_splice.last_funding_feerate_sat_per_1000_weight.is_some() { + self.build_prior_contribution() + } else { + None + }; + (min_rbf_feerate, prior) + } else { + // No pending splice — fresh splice with no RBF constraint. + (None, None) + }; let funding_txo = self.funding.get_funding_txo().expect("funding_txo should be set"); let previous_utxo = @@ -11957,75 +12043,38 @@ where satisfaction_weight: EMPTY_SCRIPT_SIG_WEIGHT + FUNDING_TRANSACTION_WITNESS_WEIGHT, }; - Ok(FundingTemplate::new(Some(shared_input), min_feerate, max_feerate)) + Ok(FundingTemplate::new(Some(shared_input), min_rbf_feerate, prior_contribution)) } - /// 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() - ), - }); - } + /// Clones the prior contribution and fetches the holder balance for deferred feerate + /// adjustment. + fn build_prior_contribution(&self) -> Option { + debug_assert!( + self.pending_splice.is_some(), + "build_prior_contribution requires pending_splice" + ); + let prior = self.pending_splice.as_ref()?.contributions.last()?; + let holder_balance = self + .get_holder_counterparty_balances_floor_incl_fee(&self.funding) + .map(|(h, _)| h) + .ok(); + Some(PriorContribution::new(prior.clone(), holder_balance)) + } + /// Returns whether this channel can ever RBF, independent of splice state. + fn is_rbf_compatible(&self) -> Result<(), String> { 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, - ), - }); + return Err(format!( + "Channel {} has option_zeroconf, cannot RBF", + self.context.channel_id(), + )); } - - 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)) + Ok(()) } - fn can_initiate_rbf(&self, feerate: FeeRate) -> Result<(), String> { + fn can_initiate_rbf(&self) -> Result { + self.is_rbf_compatible()?; + let pending_splice = match &self.pending_splice { Some(pending_splice) => pending_splice, None => { @@ -12064,24 +12113,65 @@ where )); } - // 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, - )); - } + match pending_splice.last_funding_feerate_sat_per_1000_weight { + Some(prev_feerate) => { + let min_feerate_kwu = ((prev_feerate as u64) * 25).div_ceil(24); + Ok(FeeRate::from_sat_per_kwu(min_feerate_kwu)) + }, + None => Err(format!( + "Channel {} has no prior feerate to compute RBF minimum", + self.context.channel_id(), + )), } + } - Ok(()) + /// Attempts to adjust the contribution's feerate to the minimum RBF feerate so the splice can + /// proceed as an RBF immediately rather than waiting for the pending splice to lock. + /// Returns the adjusted contribution on success, or the original on failure. + fn maybe_adjust_for_rbf( + &self, contribution: FundingContribution, min_rbf_feerate: FeeRate, logger: &L, + ) -> FundingContribution { + if contribution.feerate() >= min_rbf_feerate { + return contribution; + } + + let holder_balance = match self + .get_holder_counterparty_balances_floor_incl_fee(&self.funding) + .map(|(holder, _)| holder) + { + Ok(balance) => balance, + Err(_) => return contribution, + }; + + if let Err(e) = + contribution.net_value_for_initiator_at_feerate(min_rbf_feerate, holder_balance) + { + log_info!( + logger, + "Cannot adjust to minimum RBF feerate {}: {}; will proceed as fresh splice after lock", + min_rbf_feerate, + e, + ); + // Note: try_send_stfu prevents sending stfu until the contribution's + // feerate meets the minimum RBF feerate, effectively waiting for the + // prior splice to lock before proceeding. + return contribution; + } + + log_info!( + logger, + "Adjusting contribution feerate from {} to minimum RBF feerate {}", + contribution.feerate(), + min_rbf_feerate, + ); + contribution + .for_initiator_at_feerate(min_rbf_feerate, holder_balance) + .expect("feerate compatibility already checked") } - pub fn funding_contributed( - &mut self, contribution: FundingContribution, locktime: LockTime, logger: &L, + pub fn funding_contributed( + &mut self, contribution: FundingContribution, locktime: LockTime, + fee_estimator: &LowerBoundedFeeEstimator, logger: &L, ) -> Result, QuiescentError> { debug_assert!(contribution.is_splice()); @@ -12153,17 +12243,35 @@ where }) { log_error!(logger, "Channel {} cannot be funded: {}", self.context.channel_id(), e); - let (contributed_inputs, contributed_outputs) = - contribution.into_contributed_inputs_and_outputs(); + return Err(QuiescentError::FailSplice(self.splice_funding_failed_for(contribution))); + } - return Err(QuiescentError::FailSplice(SpliceFundingFailed { - funding_txo: None, - channel_type: None, - contributed_inputs, - contributed_outputs, - })); + if let Some(pending_splice) = self.pending_splice.as_ref() { + if !pending_splice.is_rbf_feerate_sufficient( + contribution.feerate().to_sat_per_kwu() as u32, + fee_estimator, + ) { + log_error!( + logger, + "Channel {} RBF feerate {} below fee estimator minimum", + self.context.channel_id(), + contribution.feerate(), + ); + return Err(QuiescentError::FailSplice( + self.splice_funding_failed_for(contribution), + )); + } } + // If a pending splice exists with negotiated candidates, attempt to adjust the + // contribution's feerate to the minimum RBF feerate so it can proceed as an RBF immediately + // rather than waiting for the splice to lock. + let contribution = if let Ok(min_rbf_feerate) = self.can_initiate_rbf() { + self.maybe_adjust_for_rbf(contribution, min_rbf_feerate, logger) + } else { + contribution + }; + self.propose_quiescence(logger, QuiescentAction::Splice { contribution, locktime }) } @@ -12558,12 +12666,7 @@ where 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(), - ))); - } + self.is_rbf_compatible().map_err(|msg| ChannelError::WarnAndDisconnect(msg))?; let pending_splice = match &self.pending_splice { Some(pending_splice) => pending_splice, @@ -12613,6 +12716,10 @@ where return Err(ChannelError::Abort(AbortReason::InsufficientRbfFeerate)); } + if !pending_splice.is_rbf_feerate_sufficient(new_feerate, fee_estimator) { + return Err(ChannelError::Abort(AbortReason::InsufficientRbfFeerate)); + } + let their_funding_contribution = match msg.funding_output_contribution { Some(value) => SignedAmount::from_sat(value), None => SignedAmount::ZERO, @@ -12689,11 +12796,12 @@ where } else if prior_net_value.is_some() { let prior_contribution = self .pending_splice - .as_mut() + .as_ref() .expect("pending_splice is Some") .contributions - .pop() - .expect("prior_net_value was Some"); + .last() + .expect("prior_net_value was Some") + .clone(); let adjusted_contribution = prior_contribution .for_acceptor_at_feerate(feerate, holder_balance.unwrap()) .expect("feerate compatibility already checked"); @@ -13620,27 +13728,27 @@ where #[rustfmt::skip] pub fn stfu( &mut self, msg: &msgs::Stfu, logger: &L - ) -> Result, ChannelError> { + ) -> Result, (ChannelError, QuiescentError)> { if self.context.channel_state.is_quiescent() { - return Err(ChannelError::Warn("Channel is already quiescent".to_owned())); + return Err((ChannelError::Warn("Channel is already quiescent".to_owned()), QuiescentError::DoNothing)); } if self.context.channel_state.is_remote_stfu_sent() { - return Err(ChannelError::Warn( + return Err((ChannelError::Warn( "Peer sent `stfu` when they already sent it and we've yet to become quiescent".to_owned() - )); + ), QuiescentError::DoNothing)); } if !self.context.is_live() { - return Err(ChannelError::Warn( + return Err((ChannelError::Warn( "Peer sent `stfu` when we were not in a live state".to_owned() - )); + ), QuiescentError::DoNothing)); } if !self.context.channel_state.is_local_stfu_sent() { if !msg.initiator { - return Err(ChannelError::WarnAndDisconnect( + return Err((ChannelError::WarnAndDisconnect( "Peer sent unexpected `stfu` without signaling as initiator".to_owned() - )); + ), QuiescentError::DoNothing)); } // We don't check `is_waiting_on_peer_pending_channel_update` prior to setting the flag @@ -13670,9 +13778,9 @@ where // have a monitor update pending if we've processed a message from the counterparty, but // we don't consider this when becoming quiescent since the states are not mutually // exclusive. - return Err(ChannelError::WarnAndDisconnect( + return Err((ChannelError::WarnAndDisconnect( "Received counterparty stfu while having pending counterparty updates".to_owned() - )); + ), QuiescentError::DoNothing)); } self.context.channel_state.clear_local_stfu_sent(); @@ -13688,11 +13796,33 @@ where match self.quiescent_action.take() { None => { debug_assert!(false); - return Err(ChannelError::WarnAndDisconnect( + return Err((ChannelError::WarnAndDisconnect( "Internal Error: Didn't have anything to do after reaching quiescence".to_owned() - )); + ), QuiescentError::DoNothing)); }, Some(QuiescentAction::Splice { contribution, locktime }) => { + // Re-validate the contribution now that we're quiescent and + // balances are stable. Outbound HTLCs may have been sent between + // funding_contributed and quiescence, reducing the holder's + // balance. If invalid, disconnect and return the contribution so + // the user can reclaim their inputs. + if let Err(e) = contribution.validate().and_then(|()| { + let our_funding_contribution = contribution.net_value(); + self.validate_splice_contributions( + our_funding_contribution, + SignedAmount::ZERO, + ) + }) { + let failed = self.splice_funding_failed_for(contribution); + return Err(( + ChannelError::WarnAndDisconnect(format!( + "Channel {} contribution no longer valid at quiescence: {}", + self.context.channel_id(), + e, + )), + QuiescentError::FailSplice(failed), + )); + } let prior_contribution = contribution.clone(); let prev_funding_input = self.funding.to_splice_funding_input(); let our_funding_contribution = contribution.net_value(); @@ -13761,13 +13891,26 @@ where #[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; + match self.can_initiate_rbf() { + Err(msg) => { + log_given_level!( + logger, + logger_level, + "Waiting on sending stfu for splice RBF: {msg}" + ); + return None; + }, + Ok(min_rbf_feerate) if contribution.feerate() < min_rbf_feerate => { + log_given_level!( + logger, + logger_level, + "Waiting for splice to lock: feerate {} below minimum RBF feerate {}", + contribution.feerate(), + min_rbf_feerate, + ); + return None; + }, + _ => {}, } } } diff --git a/lightning/src/ln/channelmanager.rs b/lightning/src/ln/channelmanager.rs index f8b5ef32fc3..8356e5f32fc 100644 --- a/lightning/src/ln/channelmanager.rs +++ b/lightning/src/ln/channelmanager.rs @@ -30,7 +30,7 @@ use bitcoin::hashes::{Hash, HashEngine, HmacEngine}; use bitcoin::secp256k1::Secp256k1; use bitcoin::secp256k1::{PublicKey, SecretKey}; -use bitcoin::{secp256k1, FeeRate, Sequence, SignedAmount}; +use bitcoin::{secp256k1, Sequence, SignedAmount}; use crate::blinded_path::message::{ AsyncPaymentsContext, BlindedMessagePath, MessageForwardNode, OffersContext, @@ -4701,8 +4701,7 @@ impl< } /// Initiate a splice in order to add value to (splice-in) or remove value from (splice-out) - /// the channel. This will spend the channel's funding transaction output, effectively replacing - /// it with a new one. + /// the channel, or to RBF a pending splice transaction. /// /// # Required Feature Flags /// @@ -4710,52 +4709,16 @@ impl< /// channel (no matter the type) can be spliced, as long as the counterparty is currently /// connected. /// - /// # Arguments - /// - /// The splice 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 at `min_feerate` assuming initiator - /// responsibility and must be covered by the supplied inputs for splice-in or the channel - /// balance for splice-out. If the counterparty also initiates a splice and wins the - /// tie-break, they become the initiator and choose the feerate. The fee is then - /// re-estimated at the counterparty's feerate for only our contributed inputs and outputs, - /// which may be higher or lower than the original estimate. The contribution is dropped and - /// the splice proceeds without it when: - /// - the counterparty's feerate is below `min_feerate` - /// - the counterparty's feerate is above `max_feerate` and the re-estimated fee exceeds the - /// original fee estimate - /// - the re-estimated fee exceeds the *fee buffer* regardless of `max_feerate` - /// - /// The fee buffer is the maximum fee that can be accommodated: - /// - **splice-in**: the selected inputs' value minus the contributed amount - /// - **splice-out**: the channel balance minus the withdrawal outputs - /// - /// 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. + /// # Return Value /// - /// 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 + /// Returns a [`FundingTemplate`] which should be used to obtain a [`FundingContribution`] + /// to pass to [`ChannelManager::funding_contributed`]. If a splice has been negotiated but + /// not yet locked, it can be replaced with a higher feerate transaction to speed up + /// confirmation via Replace By Fee (RBF). See [`FundingTemplate`] for details on building + /// a fresh contribution or reusing a prior one for RBF. #[rustfmt::skip] pub fn splice_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(); @@ -4783,7 +4746,7 @@ impl< 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.splice_channel(min_feerate, max_feerate) + chan.splice_channel() } else { Err(APIError::ChannelUnavailable { err: format!( @@ -4799,94 +4762,6 @@ 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, @@ -6612,6 +6487,57 @@ impl< result } + /// Emits events for a [`QuiescentError`], if applicable. + fn handle_quiescent_error( + &self, channel_id: ChannelId, counterparty_node_id: PublicKey, user_channel_id: u128, + error: QuiescentError, + ) { + match error { + QuiescentError::DoNothing => {}, + QuiescentError::DiscardFunding { inputs, outputs } => { + if !inputs.is_empty() || !outputs.is_empty() { + self.pending_events.lock().unwrap().push_back(( + events::Event::DiscardFunding { + channel_id, + funding_info: FundingInfo::Contribution { inputs, outputs }, + }, + None, + )); + } + }, + QuiescentError::FailSplice(SpliceFundingFailed { + funding_txo, + channel_type, + contributed_inputs, + contributed_outputs, + }) => { + let pending_events = &mut self.pending_events.lock().unwrap(); + pending_events.push_back(( + events::Event::SpliceFailed { + channel_id, + counterparty_node_id, + user_channel_id, + abandoned_funding_txo: funding_txo, + channel_type, + }, + None, + )); + if !contributed_inputs.is_empty() || !contributed_outputs.is_empty() { + pending_events.push_back(( + events::Event::DiscardFunding { + channel_id, + funding_info: FundingInfo::Contribution { + inputs: contributed_inputs, + outputs: contributed_outputs, + }, + }, + None, + )); + } + }, + } + } + /// Adds or removes funds from the given channel as specified by a [`FundingContribution`]. /// /// Used after [`ChannelManager::splice_channel`] by constructing a [`FundingContribution`] @@ -6622,20 +6548,46 @@ impl< /// An optional `locktime` for the funding transaction may be specified. If not given, the /// current best block height is used. /// + /// # Fee Estimation + /// + /// The splice 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 at `min_feerate` assuming initiator + /// responsibility and must be covered by the supplied inputs for splice-in or the channel + /// balance for splice-out. If the counterparty also initiates a splice and wins the + /// tie-break, they become the initiator and choose the feerate. The fee is then + /// re-estimated at the counterparty's feerate for only our contributed inputs and outputs, + /// which may be higher or lower than the original estimate. The contribution is dropped and + /// the splice proceeds without it when: + /// - the counterparty's feerate is below `min_feerate` + /// - the counterparty's feerate is above `max_feerate` and the re-estimated fee exceeds the + /// original fee estimate + /// - the re-estimated fee exceeds the *fee buffer* regardless of `max_feerate` + /// + /// The fee buffer is the maximum fee that can be accommodated: + /// - **splice-in**: the selected inputs' value minus the contributed amount + /// - **splice-out**: the channel balance minus the withdrawal outputs + /// /// # Events /// /// Calling this method will commence the process of creating a new funding transaction for the - /// channel. An [`Event::FundingTransactionReadyForSigning`] will be generated once the - /// transaction is successfully constructed interactively with the counterparty. + /// channel. 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. + /// + /// If any failures occur while negotiating the funding transaction, an [`Event::SpliceFailed`] + /// will be emitted. Any contributed inputs no longer used will be included in an + /// [`Event::DiscardFunding`] and thus can be re-spent. If a [`FundingTemplate`] was obtained + /// while a previous splice was still being negotiated, its + /// [`min_rbf_feerate`][FundingTemplate::min_rbf_feerate] may be stale after the failure. + /// Call [`ChannelManager::splice_channel`] again to get a fresh template. /// - /// If unsuccessful, an [`Event::SpliceFailed`] will be produced if there aren't any earlier - /// splice attempts for the channel outstanding (i.e., haven't yet produced either - /// [`Event::SplicePending`] or [`Event::SpliceFailed`]). + /// After initial signatures have been exchanged, [`Event::FundingTransactionReadyForSigning`] + /// will be generated and [`ChannelManager::funding_transaction_signed`] should be called. /// - /// If unsuccessful, an [`Event::DiscardFunding`] will be produced for any contributions - /// passed in that are not found in any outstanding attempts for the channel. If there are no - /// such contributions, then the [`Event::DiscardFunding`] will not be produced since these - /// contributions must not be reused yet. + /// 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 (non-RBF) splice can be negotiated by + /// calling [`ChannelManager::splice_channel`] again on this channel. /// /// # Errors /// @@ -6681,7 +6633,12 @@ impl< locktime.unwrap_or_else(|| self.current_best_block().height), ); let logger = WithChannelContext::from(&self.logger, chan.context(), None); - match chan.funding_contributed(contribution, locktime, &&logger) { + match chan.funding_contributed( + contribution, + locktime, + &self.fee_estimator, + &&logger, + ) { Ok(msg_opt) => { if let Some(msg) = msg_opt { peer_state.pending_msg_events.push( @@ -6692,62 +6649,29 @@ impl< ); } }, - Err(QuiescentError::DoNothing) => { - result = Err(APIError::APIMisuseError { - err: format!( - "Duplicate funding contribution for channel {}", - channel_id - ), - }); - }, - Err(QuiescentError::DiscardFunding { inputs, outputs }) => { - self.pending_events.lock().unwrap().push_back(( - events::Event::DiscardFunding { - channel_id: *channel_id, - funding_info: FundingInfo::Contribution { inputs, outputs }, - }, - None, - )); + Err(e) => { result = Err(APIError::APIMisuseError { - err: format!( - "Channel {} already has a pending funding contribution", - channel_id - ), - }); - }, - Err(QuiescentError::FailSplice(SpliceFundingFailed { - funding_txo, - channel_type, - contributed_inputs, - contributed_outputs, - })) => { - let pending_events = &mut self.pending_events.lock().unwrap(); - pending_events.push_back(( - events::Event::SpliceFailed { - channel_id: *channel_id, - counterparty_node_id: *counterparty_node_id, - user_channel_id: channel.context().get_user_id(), - abandoned_funding_txo: funding_txo, - channel_type, - }, - None, - )); - pending_events.push_back(( - events::Event::DiscardFunding { - channel_id: *channel_id, - funding_info: FundingInfo::Contribution { - inputs: contributed_inputs, - outputs: contributed_outputs, - }, + err: match &e { + QuiescentError::DoNothing => format!( + "Duplicate funding contribution for channel {}", + channel_id, + ), + QuiescentError::DiscardFunding { .. } => format!( + "Channel {} already has a pending funding contribution", + channel_id, + ), + QuiescentError::FailSplice(_) => format!( + "Channel {} cannot accept funding contribution", + channel_id, + ), }, - None, - )); - result = Err(APIError::APIMisuseError { - err: format!( - "Channel {} cannot accept funding contribution", - channel_id - ), }); + self.handle_quiescent_error( + *channel_id, + *counterparty_node_id, + channel.context().get_user_id(), + e, + ); }, } @@ -12892,6 +12816,16 @@ This indicates a bug inside LDK. Please report this error at https://github.com/ ); let res = chan.stfu(&msg, &&logger); + let (res, quiescent_error) = match res { + Ok(resp) => (Ok(resp), QuiescentError::DoNothing), + Err((chan_err, quiescent_err)) => (Err(chan_err), quiescent_err), + }; + self.handle_quiescent_error( + chan_entry.get().context().channel_id(), + *counterparty_node_id, + chan_entry.get().context().get_user_id(), + quiescent_error, + ); let resp = try_channel_entry!(self, peer_state, res, chan_entry); match resp { None => Ok(false), diff --git a/lightning/src/ln/funding.rs b/lightning/src/ln/funding.rs index c81024ca080..0ba4ed188e6 100644 --- a/lightning/src/ln/funding.rs +++ b/lightning/src/ln/funding.rs @@ -106,12 +106,123 @@ impl core::fmt::Display for FeeRateAdjustmentError { } } +/// Error returned when building a [`FundingContribution`] from a [`FundingTemplate`]. +#[derive(Debug)] +pub enum FundingContributionError { + /// The feerate exceeds the maximum allowed feerate. + FeeRateExceedsMaximum { + /// The requested feerate. + feerate: FeeRate, + /// The maximum allowed feerate. + max_feerate: FeeRate, + }, + /// The feerate is below the minimum RBF feerate. + /// + /// Note: [`FundingTemplate::min_rbf_feerate`] may be derived from an in-progress + /// negotiation that later aborts, leaving a stale (higher than necessary) minimum. If + /// this error occurs after receiving [`Event::SpliceFailed`], call + /// [`ChannelManager::splice_channel`] again to get a fresh template. + /// + /// [`Event::SpliceFailed`]: crate::events::Event::SpliceFailed + /// [`ChannelManager::splice_channel`]: crate::ln::channelmanager::ChannelManager::splice_channel + FeeRateBelowRbfMinimum { + /// The requested feerate. + feerate: FeeRate, + /// The minimum RBF feerate. + min_rbf_feerate: FeeRate, + }, + /// The splice value is invalid (zero, empty outputs, or exceeds the maximum money supply). + InvalidSpliceValue, + /// Coin selection failed to find suitable inputs. + CoinSelectionFailed, + /// This is not an RBF scenario (no minimum RBF feerate available). + NotRbfScenario, +} + +impl core::fmt::Display for FundingContributionError { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + match self { + FundingContributionError::FeeRateExceedsMaximum { feerate, max_feerate } => { + write!(f, "Feerate {} exceeds maximum {}", feerate, max_feerate) + }, + FundingContributionError::FeeRateBelowRbfMinimum { feerate, min_rbf_feerate } => { + write!(f, "Feerate {} is below minimum RBF feerate {}", feerate, min_rbf_feerate) + }, + FundingContributionError::InvalidSpliceValue => { + write!(f, "Invalid splice value (zero, empty, or exceeds limit)") + }, + FundingContributionError::CoinSelectionFailed => { + write!(f, "Coin selection failed to find suitable inputs") + }, + FundingContributionError::NotRbfScenario => { + write!(f, "Not an RBF scenario (no minimum RBF feerate)") + }, + } + } +} + +/// The user's prior contribution from a previous splice negotiation on this channel. +/// +/// When a pending splice exists with negotiated candidates, the prior contribution is +/// available for reuse (e.g., to bump the feerate via RBF). Contains the raw contribution and +/// the holder's balance for deferred feerate adjustment in [`FundingTemplate::rbf_sync`] or +/// [`FundingTemplate::rbf`]. +/// +/// Use [`FundingTemplate::prior_contribution`] to inspect the prior contribution before +/// deciding whether to call [`FundingTemplate::rbf_sync`] or one of the splice methods +/// with different parameters. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(super) struct PriorContribution { + contribution: FundingContribution, + /// The holder's balance, used for feerate adjustment. `None` when the balance computation + /// fails, in which case adjustment is skipped and coin selection is re-run. + /// + /// This value is captured at [`ChannelManager::splice_channel`] time and may become stale + /// if balances change before the contribution is used. Staleness is acceptable here because + /// this is only used as an optimization to determine if the prior contribution can be + /// reused with adjusted fees — the contribution is re-validated at + /// [`ChannelManager::funding_contributed`] time and again at quiescence time against the + /// current balances. + /// + /// [`ChannelManager::splice_channel`]: crate::ln::channelmanager::ChannelManager::splice_channel + /// [`ChannelManager::funding_contributed`]: crate::ln::channelmanager::ChannelManager::funding_contributed + holder_balance: Option, +} + +impl PriorContribution { + pub(super) fn new(contribution: FundingContribution, holder_balance: Option) -> Self { + Self { contribution, holder_balance } + } +} + /// A template for contributing to a channel's splice funding transaction. /// /// This is returned from [`ChannelManager::splice_channel`] when a channel is ready to be -/// spliced. It must be converted to a [`FundingContribution`] using one of the splice methods -/// and passed to [`ChannelManager::funding_contributed`] in order to resume the splicing -/// process. +/// spliced. A [`FundingContribution`] must be obtained from it and passed to +/// [`ChannelManager::funding_contributed`] in order to resume the splicing process. +/// +/// # Building a Contribution +/// +/// For a fresh splice (no pending splice to replace), build a new contribution using one of +/// the splice methods: +/// - [`FundingTemplate::splice_in_sync`] to add funds to the channel +/// - [`FundingTemplate::splice_out_sync`] to remove funds from the channel +/// - [`FundingTemplate::splice_in_and_out_sync`] to do both +/// +/// These perform coin selection and require `min_feerate` and `max_feerate` parameters. +/// +/// # Replace By Fee (RBF) +/// +/// When a pending splice exists that hasn't been locked yet, use [`FundingTemplate::rbf_sync`] +/// (or [`FundingTemplate::rbf`] for async) to build an RBF contribution. This handles the +/// prior contribution logic internally — reusing an adjusted prior when possible, re-running +/// coin selection when needed, or creating a fee-bump-only contribution. +/// +/// Check [`FundingTemplate::min_rbf_feerate`] for the minimum feerate required (25/24 of +/// the previous feerate). Use [`FundingTemplate::prior_contribution`] to inspect the prior +/// contribution's parameters (e.g., [`FundingContribution::value_added`], +/// [`FundingContribution::outputs`]) before deciding whether to reuse it via the RBF methods +/// or build a fresh contribution with different parameters using the splice methods above. /// /// [`ChannelManager::splice_channel`]: crate::ln::channelmanager::ChannelManager::splice_channel /// [`ChannelManager::funding_contributed`]: crate::ln::channelmanager::ChannelManager::funding_contributed @@ -121,50 +232,88 @@ pub struct FundingTemplate { /// transaction. shared_input: Option, - /// The minimum fee rate for the splice transaction, used to propose as initiator. - min_feerate: FeeRate, + /// The minimum RBF feerate (25/24 of the previous feerate), if this template is for an + /// RBF attempt. `None` for fresh splices with no pending splice candidates. + min_rbf_feerate: Option, - /// The maximum fee rate to accept as acceptor before declining to add our contribution to the - /// splice. - max_feerate: FeeRate, + /// The user's prior contribution from a previous splice negotiation, if available. + prior_contribution: Option, } impl FundingTemplate { /// Constructs a [`FundingTemplate`] for a splice using the provided shared input. pub(super) fn new( - shared_input: Option, min_feerate: FeeRate, max_feerate: FeeRate, + shared_input: Option, min_rbf_feerate: Option, + prior_contribution: Option, ) -> Self { - Self { shared_input, min_feerate, max_feerate } + Self { shared_input, min_rbf_feerate, prior_contribution } + } + + /// Returns the minimum RBF feerate, if this template is for an RBF attempt. + /// + /// When set, the `min_feerate` passed to the splice methods (e.g., + /// [`FundingTemplate::splice_in_sync`]) must be at least this value. + pub fn min_rbf_feerate(&self) -> Option { + self.min_rbf_feerate + } + + /// Returns a reference to the prior contribution from a previous splice negotiation, if + /// available. + /// + /// Use this to inspect the prior contribution's parameters (e.g., + /// [`FundingContribution::value_added`], [`FundingContribution::outputs`]) before deciding + /// whether to reuse it via [`FundingTemplate::rbf_sync`] or build a fresh contribution + /// with different parameters using the splice methods. + /// + /// Note: the returned contribution may reflect a different feerate than originally provided, + /// as it may have been adjusted for RBF or for the counterparty's feerate when acting as + /// the acceptor. This can change other parameters too (e.g., + /// [`FundingContribution::value_added`] may be higher if the change output was removed to + /// cover a higher fee). + pub fn prior_contribution(&self) -> Option<&FundingContribution> { + self.prior_contribution.as_ref().map(|p| &p.contribution) } } macro_rules! build_funding_contribution { - ($value_added:expr, $outputs:expr, $shared_input:expr, $feerate:expr, $max_feerate:expr, $wallet:ident, $($await:tt)*) => {{ + ($value_added:expr, $outputs:expr, $shared_input:expr, $min_rbf_feerate:expr, $feerate:expr, $max_feerate:expr, $force_coin_selection:expr, $wallet:ident, $($await:tt)*) => {{ let value_added: Amount = $value_added; let outputs: Vec = $outputs; let shared_input: Option = $shared_input; + let min_rbf_feerate: Option = $min_rbf_feerate; let feerate: FeeRate = $feerate; let max_feerate: FeeRate = $max_feerate; + let force_coin_selection: bool = $force_coin_selection; + + if feerate > max_feerate { + return Err(FundingContributionError::FeeRateExceedsMaximum { feerate, max_feerate }); + } + + if let Some(min_rbf_feerate) = min_rbf_feerate { + if feerate < min_rbf_feerate { + return Err(FundingContributionError::FeeRateBelowRbfMinimum { feerate, min_rbf_feerate }); + } + } // Validate user-provided amounts are within MAX_MONEY before coin selection to // ensure FundingContribution::net_value() arithmetic cannot overflow. With all // amounts bounded by MAX_MONEY (~2.1e15 sat), the worst-case net_value() // computation is -2 * MAX_MONEY (~-4.2e15), well within i64::MIN (~-9.2e18). if value_added > Amount::MAX_MONEY { - return Err(()); + return Err(FundingContributionError::InvalidSpliceValue); } let mut value_removed = Amount::ZERO; for txout in outputs.iter() { value_removed = match value_removed.checked_add(txout.value) { Some(sum) if sum <= Amount::MAX_MONEY => sum, - _ => return Err(()), + _ => return Err(FundingContributionError::InvalidSpliceValue), }; } let is_splice = shared_input.is_some(); - let coin_selection = if value_added == Amount::ZERO { + let coin_selection = if value_added == Amount::ZERO && !force_coin_selection { CoinSelection { confirmed_utxos: vec![], change_output: None } } else { // Used for creating a redeem script for the new funding txo, since the funding pubkeys @@ -178,9 +327,9 @@ macro_rules! build_funding_contribution { .map(|shared_input| shared_input.previous_utxo.value) .unwrap_or(Amount::ZERO) .checked_add(value_added) - .ok_or(())? + .ok_or(FundingContributionError::InvalidSpliceValue)? .checked_sub(value_removed) - .ok_or(())?, + .ok_or(FundingContributionError::InvalidSpliceValue)?, script_pubkey: make_funding_redeemscript(&dummy_pubkey, &dummy_pubkey).to_p2wsh(), }; @@ -188,10 +337,10 @@ macro_rules! build_funding_contribution { let must_spend = shared_input.map(|input| vec![input]).unwrap_or_default(); if outputs.is_empty() { let must_pay_to = &[shared_output]; - $wallet.select_confirmed_utxos(claim_id, must_spend, must_pay_to, feerate.to_sat_per_kwu() as u32, u64::MAX)$(.$await)*? + $wallet.select_confirmed_utxos(claim_id, must_spend, must_pay_to, feerate.to_sat_per_kwu() as u32, u64::MAX)$(.$await)*.map_err(|_| FundingContributionError::CoinSelectionFailed)? } else { let must_pay_to: Vec<_> = outputs.iter().cloned().chain(core::iter::once(shared_output)).collect(); - $wallet.select_confirmed_utxos(claim_id, must_spend, &must_pay_to, feerate.to_sat_per_kwu() as u32, u64::MAX)$(.$await)*? + $wallet.select_confirmed_utxos(claim_id, must_spend, &must_pay_to, feerate.to_sat_per_kwu() as u32, u64::MAX)$(.$await)*.map_err(|_| FundingContributionError::CoinSelectionFailed)? } }; @@ -223,96 +372,245 @@ macro_rules! build_funding_contribution { impl FundingTemplate { /// Creates a [`FundingContribution`] for adding funds to a channel using `wallet` to perform /// coin selection. + /// + /// `value_added` is the total amount to add to the channel for this contribution. When + /// replacing a prior contribution via RBF, use [`FundingTemplate::prior_contribution`] to + /// inspect the prior parameters. To add funds on top of the prior contribution's amount, + /// combine them: `prior.value_added() + additional_amount`. pub async fn splice_in( - self, value_added: Amount, wallet: W, - ) -> Result { + self, value_added: Amount, min_feerate: FeeRate, max_feerate: FeeRate, wallet: W, + ) -> Result { if value_added == Amount::ZERO { - return Err(()); + return Err(FundingContributionError::InvalidSpliceValue); } - let FundingTemplate { shared_input, min_feerate, max_feerate } = self; - build_funding_contribution!(value_added, vec![], shared_input, min_feerate, max_feerate, wallet, await) + let FundingTemplate { shared_input, min_rbf_feerate, .. } = self; + build_funding_contribution!(value_added, vec![], shared_input, min_rbf_feerate, min_feerate, max_feerate, false, wallet, await) } /// Creates a [`FundingContribution`] for adding funds to a channel using `wallet` to perform /// coin selection. + /// + /// See [`FundingTemplate::splice_in`] for details. pub fn splice_in_sync( - self, value_added: Amount, wallet: W, - ) -> Result { + self, value_added: Amount, min_feerate: FeeRate, max_feerate: FeeRate, wallet: W, + ) -> Result { if value_added == Amount::ZERO { - return Err(()); + return Err(FundingContributionError::InvalidSpliceValue); } - let FundingTemplate { shared_input, min_feerate, max_feerate } = self; + let FundingTemplate { shared_input, min_rbf_feerate, .. } = self; build_funding_contribution!( value_added, vec![], shared_input, + min_rbf_feerate, min_feerate, max_feerate, + false, wallet, ) } /// Creates a [`FundingContribution`] for removing funds from a channel using `wallet` to /// perform coin selection. + /// + /// `outputs` are the complete set of withdrawal outputs for this contribution. When + /// replacing a prior contribution via RBF, use [`FundingTemplate::prior_contribution`] to + /// inspect the prior parameters. To keep existing withdrawals and add new ones, include the + /// prior's outputs: combine [`FundingContribution::outputs`] with the new outputs. pub async fn splice_out( - self, outputs: Vec, wallet: W, - ) -> Result { + self, outputs: Vec, min_feerate: FeeRate, max_feerate: FeeRate, wallet: W, + ) -> Result { if outputs.is_empty() { - return Err(()); + return Err(FundingContributionError::InvalidSpliceValue); } - let FundingTemplate { shared_input, min_feerate, max_feerate } = self; - build_funding_contribution!(Amount::ZERO, outputs, shared_input, min_feerate, max_feerate, wallet, await) + let FundingTemplate { shared_input, min_rbf_feerate, .. } = self; + build_funding_contribution!(Amount::ZERO, outputs, shared_input, min_rbf_feerate, min_feerate, max_feerate, false, wallet, await) } /// Creates a [`FundingContribution`] for removing funds from a channel using `wallet` to /// perform coin selection. + /// + /// See [`FundingTemplate::splice_out`] for details. pub fn splice_out_sync( - self, outputs: Vec, wallet: W, - ) -> Result { + self, outputs: Vec, min_feerate: FeeRate, max_feerate: FeeRate, wallet: W, + ) -> Result { if outputs.is_empty() { - return Err(()); + return Err(FundingContributionError::InvalidSpliceValue); } - let FundingTemplate { shared_input, min_feerate, max_feerate } = self; + let FundingTemplate { shared_input, min_rbf_feerate, .. } = self; build_funding_contribution!( Amount::ZERO, outputs, shared_input, + min_rbf_feerate, min_feerate, max_feerate, + false, wallet, ) } /// Creates a [`FundingContribution`] for both adding and removing funds from a channel using /// `wallet` to perform coin selection. + /// + /// `value_added` and `outputs` are the complete parameters for this contribution, not + /// increments on top of a prior contribution. When replacing a prior contribution via RBF, + /// use [`FundingTemplate::prior_contribution`] to inspect the prior parameters and combine + /// them as needed. pub async fn splice_in_and_out( - self, value_added: Amount, outputs: Vec, wallet: W, - ) -> Result { + self, value_added: Amount, outputs: Vec, min_feerate: FeeRate, max_feerate: FeeRate, + wallet: W, + ) -> Result { if value_added == Amount::ZERO && outputs.is_empty() { - return Err(()); + return Err(FundingContributionError::InvalidSpliceValue); } - let FundingTemplate { shared_input, min_feerate, max_feerate } = self; - build_funding_contribution!(value_added, outputs, shared_input, min_feerate, max_feerate, wallet, await) + let FundingTemplate { shared_input, min_rbf_feerate, .. } = self; + build_funding_contribution!(value_added, outputs, shared_input, min_rbf_feerate, min_feerate, max_feerate, false, wallet, await) } /// Creates a [`FundingContribution`] for both adding and removing funds from a channel using /// `wallet` to perform coin selection. + /// + /// See [`FundingTemplate::splice_in_and_out`] for details. pub fn splice_in_and_out_sync( - self, value_added: Amount, outputs: Vec, wallet: W, - ) -> Result { + self, value_added: Amount, outputs: Vec, min_feerate: FeeRate, max_feerate: FeeRate, + wallet: W, + ) -> Result { if value_added == Amount::ZERO && outputs.is_empty() { - return Err(()); + return Err(FundingContributionError::InvalidSpliceValue); } - let FundingTemplate { shared_input, min_feerate, max_feerate } = self; + let FundingTemplate { shared_input, min_rbf_feerate, .. } = self; build_funding_contribution!( value_added, outputs, shared_input, + min_rbf_feerate, min_feerate, max_feerate, + false, wallet, ) } + + /// Creates a [`FundingContribution`] for an RBF (Replace-By-Fee) attempt on a pending splice. + /// + /// `max_feerate` is the maximum feerate the caller is willing to accept as acceptor. It is + /// used as the returned contribution's `max_feerate` and also constrains coin selection when + /// re-running it for prior contributions that cannot be adjusted or fee-bump-only + /// contributions. + /// + /// This handles the prior contribution logic internally: + /// - If the prior contribution's feerate can be adjusted to the minimum RBF feerate, the + /// adjusted contribution is returned directly. For splice-in, the change output absorbs + /// the fee difference. For splice-out (no wallet inputs), the holder's channel balance + /// covers the higher fees. + /// - If adjustment fails, coin selection is re-run using the prior contribution's + /// parameters and the caller's `max_feerate`. For splice-out contributions, this changes + /// the fee source: wallet inputs are selected to cover fees instead of deducting them + /// from the channel balance. + /// - If no prior contribution exists, coin selection is run for a fee-bump-only contribution + /// (`value_added = 0`), covering fees for the common fields and shared input/output via + /// a newly selected input. Check [`FundingTemplate::prior_contribution`] to see if this + /// is intended. + /// + /// # Errors + /// + /// Returns a [`FundingContributionError`] if this is not an RBF scenario, if `max_feerate` + /// is below the minimum RBF feerate, or if coin selection fails. + pub async fn rbf( + self, max_feerate: FeeRate, wallet: W, + ) -> Result { + let FundingTemplate { shared_input, min_rbf_feerate, prior_contribution } = self; + let rbf_feerate = min_rbf_feerate.ok_or(FundingContributionError::NotRbfScenario)?; + if rbf_feerate > max_feerate { + return Err(FundingContributionError::FeeRateExceedsMaximum { + feerate: rbf_feerate, + max_feerate, + }); + } + + match prior_contribution { + Some(PriorContribution { contribution, holder_balance }) => { + // Try to adjust the prior contribution to the RBF feerate. This fails if + // the holder balance can't cover the adjustment (splice-out) or the fee + // buffer is insufficient (splice-in), or if the prior's feerate is already + // above rbf_feerate (e.g., from a counterparty-initiated RBF that locked + // at a higher feerate). In all cases, fall through to re-run coin selection. + if let Some(holder_balance) = holder_balance { + if contribution + .net_value_for_initiator_at_feerate(rbf_feerate, holder_balance) + .is_ok() + { + let mut adjusted = contribution + .for_initiator_at_feerate(rbf_feerate, holder_balance) + .expect("feerate compatibility already checked"); + adjusted.max_feerate = max_feerate; + return Ok(adjusted); + } + } + build_funding_contribution!(contribution.value_added, contribution.outputs, shared_input, min_rbf_feerate, rbf_feerate, max_feerate, true, wallet, await) + }, + None => { + build_funding_contribution!(Amount::ZERO, vec![], shared_input, min_rbf_feerate, rbf_feerate, max_feerate, true, wallet, await) + }, + } + } + + /// Creates a [`FundingContribution`] for an RBF (Replace-By-Fee) attempt on a pending splice. + /// + /// See [`FundingTemplate::rbf`] for details. + pub fn rbf_sync( + self, max_feerate: FeeRate, wallet: W, + ) -> Result { + let FundingTemplate { shared_input, min_rbf_feerate, prior_contribution } = self; + let rbf_feerate = min_rbf_feerate.ok_or(FundingContributionError::NotRbfScenario)?; + if rbf_feerate > max_feerate { + return Err(FundingContributionError::FeeRateExceedsMaximum { + feerate: rbf_feerate, + max_feerate, + }); + } + + match prior_contribution { + Some(PriorContribution { contribution, holder_balance }) => { + // See comment in `rbf` for details on when this adjustment fails. + if let Some(holder_balance) = holder_balance { + if contribution + .net_value_for_initiator_at_feerate(rbf_feerate, holder_balance) + .is_ok() + { + let mut adjusted = contribution + .for_initiator_at_feerate(rbf_feerate, holder_balance) + .expect("feerate compatibility already checked"); + adjusted.max_feerate = max_feerate; + return Ok(adjusted); + } + } + build_funding_contribution!( + contribution.value_added, + contribution.outputs, + shared_input, + min_rbf_feerate, + rbf_feerate, + max_feerate, + true, + wallet, + ) + }, + None => { + build_funding_contribution!( + Amount::ZERO, + vec![], + shared_input, + min_rbf_feerate, + rbf_feerate, + max_feerate, + true, + wallet, + ) + }, + } + } } fn estimate_transaction_fee( @@ -366,7 +664,7 @@ fn estimate_transaction_fee( } /// The components of a funding transaction contributed by one party. -#[derive(Debug, Clone)] +#[derive(Debug, Clone, PartialEq, Eq)] pub struct FundingContribution { /// The amount to contribute to the channel. /// @@ -426,6 +724,18 @@ impl FundingContribution { self.outputs.iter().chain(self.change_output.iter()) } + /// Returns the amount added to the channel by this contribution. + pub fn value_added(&self) -> Amount { + self.value_added + } + + /// Returns the outputs (e.g., withdrawal destinations) included in this contribution. + /// + /// This does not include the change output; see [`FundingContribution::change_output`]. + pub fn outputs(&self) -> &[TxOut] { + &self.outputs + } + /// Returns the change output included in this contribution, if any. /// /// When coin selection provides more value than needed for the funding contribution and fees, @@ -526,8 +836,12 @@ impl FundingContribution { Ok(()) } - /// Computes the adjusted fee and change output value for the acceptor at the initiator's - /// proposed feerate, which may differ from the feerate used during coin selection. + /// Computes the adjusted fee and change output value at the given target feerate, which may + /// differ from the feerate used during coin selection. + /// + /// The `is_initiator` parameter determines fee responsibility: the initiator pays for common + /// transaction fields, the shared input, and the shared output, while the acceptor only pays + /// for their own contributed inputs and outputs. /// /// On success, returns the new estimated fee and, if applicable, the new change output value: /// - `Some(change)` — the adjusted change output value @@ -535,7 +849,7 @@ impl FundingContribution { /// /// Returns `Err` if the contribution cannot accommodate the target feerate. fn compute_feerate_adjustment( - &self, target_feerate: FeeRate, holder_balance: Amount, + &self, target_feerate: FeeRate, holder_balance: Amount, is_initiator: bool, ) -> Result<(Amount, Option), FeeRateAdjustmentError> { if target_feerate < self.feerate { return Err(FeeRateAdjustmentError::FeeRateTooLow { @@ -545,14 +859,15 @@ impl FundingContribution { } // If the target fee rate exceeds our max fee rate, we may still add our contribution - // if we pay less in fees. This may happen because the acceptor doesn't pay for common - // fields and the shared input / output. + // if we pay less in fees at the target feerate than at the original feerate. This can + // happen when adjusting as acceptor, since the acceptor doesn't pay for common fields + // and the shared input / output. if target_feerate > self.max_feerate { let target_fee = estimate_transaction_fee( &self.inputs, &self.outputs, self.change_output.as_ref(), - false, + is_initiator, self.is_splice, target_feerate, ); @@ -576,7 +891,7 @@ impl FundingContribution { &self.inputs, &self.outputs, self.change_output.as_ref(), - false, + is_initiator, self.is_splice, target_feerate, ); @@ -596,7 +911,7 @@ impl FundingContribution { &self.inputs, &self.outputs, None, - false, + is_initiator, self.is_splice, target_feerate, ); @@ -617,7 +932,7 @@ impl FundingContribution { &self.inputs, &self.outputs, None, - false, + is_initiator, self.is_splice, target_feerate, ); @@ -647,7 +962,7 @@ impl FundingContribution { &[], &self.outputs, None, - false, + is_initiator, self.is_splice, target_feerate, ); @@ -669,17 +984,14 @@ impl FundingContribution { } } - /// Adjusts the contribution's change output for the initiator's feerate. - /// - /// When the acceptor has a pending contribution (from the quiescence tie-breaker scenario), - /// the initiator's proposed feerate may differ from the feerate used during coin selection. - /// This adjusts the change output so the acceptor pays their target fee at the target - /// feerate. - pub(super) fn for_acceptor_at_feerate( - mut self, feerate: FeeRate, holder_balance: Amount, + /// Adjusts the contribution for a different feerate, updating the change output, fee + /// estimate, and feerate. Returns the adjusted contribution, or an error if the feerate + /// can't be accommodated. + fn at_feerate( + mut self, feerate: FeeRate, holder_balance: Amount, is_initiator: bool, ) -> Result { let (new_estimated_fee, new_change) = - self.compute_feerate_adjustment(feerate, holder_balance)?; + self.compute_feerate_adjustment(feerate, holder_balance, is_initiator)?; let surplus = self.fee_buffer_surplus(new_estimated_fee, &new_change); match new_change { Some(value) => self.change_output.as_mut().unwrap().value = value, @@ -691,16 +1003,39 @@ impl FundingContribution { Ok(self) } + /// Adjusts the contribution's change output for the initiator's feerate. + /// + /// When the acceptor has a pending contribution (from the quiescence tie-breaker scenario), + /// the initiator's proposed feerate may differ from the feerate used during coin selection. + /// This adjusts the change output so the acceptor pays their target fee at the target + /// feerate. + pub(super) fn for_acceptor_at_feerate( + self, feerate: FeeRate, holder_balance: Amount, + ) -> Result { + self.at_feerate(feerate, holder_balance, false) + } + + /// Adjusts the contribution's change output for the minimum RBF feerate. + /// + /// When a pending splice exists with negotiated candidates and the contribution's feerate + /// is below the minimum RBF feerate (25/24 of the previous feerate), this adjusts the + /// change output so the initiator pays fees at the minimum RBF feerate. + pub(super) fn for_initiator_at_feerate( + self, feerate: FeeRate, holder_balance: Amount, + ) -> Result { + self.at_feerate(feerate, holder_balance, true) + } + /// Returns the net value at the given target feerate without mutating `self`. /// /// This serves double duty: it checks feerate compatibility (returning `Err` if the feerate /// can't be accommodated) and computes the adjusted net value (returning `Ok` with the value /// accounting for the target feerate). - pub(super) fn net_value_for_acceptor_at_feerate( - &self, target_feerate: FeeRate, holder_balance: Amount, + fn net_value_at_feerate( + &self, target_feerate: FeeRate, holder_balance: Amount, is_initiator: bool, ) -> Result { let (new_estimated_fee, new_change) = - self.compute_feerate_adjustment(target_feerate, holder_balance)?; + self.compute_feerate_adjustment(target_feerate, holder_balance, is_initiator)?; let surplus = self .fee_buffer_surplus(new_estimated_fee, &new_change) .to_signed() @@ -712,6 +1047,22 @@ impl FundingContribution { Ok(net_value) } + /// Returns the net value at the given target feerate without mutating `self`, + /// assuming acceptor fee responsibility. + pub(super) fn net_value_for_acceptor_at_feerate( + &self, target_feerate: FeeRate, holder_balance: Amount, + ) -> Result { + self.net_value_at_feerate(target_feerate, holder_balance, false) + } + + /// Returns the net value at the given target feerate without mutating `self`, + /// assuming initiator fee responsibility. + pub(super) fn net_value_for_initiator_at_feerate( + &self, target_feerate: FeeRate, holder_balance: Amount, + ) -> Result { + self.net_value_at_feerate(target_feerate, holder_balance, true) + } + /// Returns the fee buffer surplus when a change output is removed. /// /// The fee buffer is the actual amount available for fees from inputs: total input value @@ -772,8 +1123,8 @@ pub type FundingTxInput = crate::util::wallet_utils::ConfirmedUtxo; #[cfg(test)] mod tests { use super::{ - estimate_transaction_fee, FeeRateAdjustmentError, FundingContribution, FundingTemplate, - FundingTxInput, + estimate_transaction_fee, FeeRateAdjustmentError, FundingContribution, + FundingContributionError, FundingTemplate, FundingTxInput, PriorContribution, }; use crate::chain::ClaimId; use crate::util::wallet_utils::{CoinSelection, CoinSelectionSourceSync, Input}; @@ -1082,42 +1433,96 @@ mod tests { // splice_in_sync with value_added > MAX_MONEY { - let template = FundingTemplate::new(None, feerate, feerate); - assert!(template.splice_in_sync(over_max, UnreachableWallet).is_err()); + let template = FundingTemplate::new(None, None, None); + assert!(matches!( + template.splice_in_sync(over_max, feerate, feerate, UnreachableWallet), + Err(FundingContributionError::InvalidSpliceValue), + )); } // splice_out_sync with single output value > MAX_MONEY { - let template = FundingTemplate::new(None, feerate, feerate); + let template = FundingTemplate::new(None, None, None); let outputs = vec![funding_output_sats(over_max.to_sat())]; - assert!(template.splice_out_sync(outputs, UnreachableWallet).is_err()); + assert!(matches!( + template.splice_out_sync(outputs, feerate, feerate, UnreachableWallet), + Err(FundingContributionError::InvalidSpliceValue), + )); } // splice_out_sync with multiple outputs summing > MAX_MONEY { - let template = FundingTemplate::new(None, feerate, feerate); + let template = FundingTemplate::new(None, None, None); let half_over = Amount::MAX_MONEY / 2 + Amount::from_sat(1); let outputs = vec![ funding_output_sats(half_over.to_sat()), funding_output_sats(half_over.to_sat()), ]; - assert!(template.splice_out_sync(outputs, UnreachableWallet).is_err()); + assert!(matches!( + template.splice_out_sync(outputs, feerate, feerate, UnreachableWallet), + Err(FundingContributionError::InvalidSpliceValue), + )); } // splice_in_and_out_sync with value_added > MAX_MONEY { - let template = FundingTemplate::new(None, feerate, feerate); + let template = FundingTemplate::new(None, None, None); let outputs = vec![funding_output_sats(1_000)]; - assert!(template.splice_in_and_out_sync(over_max, outputs, UnreachableWallet).is_err()); + assert!(matches!( + template.splice_in_and_out_sync( + over_max, + outputs, + feerate, + feerate, + UnreachableWallet + ), + Err(FundingContributionError::InvalidSpliceValue), + )); } // splice_in_and_out_sync with output sum > MAX_MONEY { - let template = FundingTemplate::new(None, feerate, feerate); + let template = FundingTemplate::new(None, None, None); let outputs = vec![funding_output_sats(over_max.to_sat())]; - assert!(template - .splice_in_and_out_sync(Amount::from_sat(1_000), outputs, UnreachableWallet) - .is_err()); + assert!(matches!( + template.splice_in_and_out_sync( + Amount::from_sat(1_000), + outputs, + feerate, + feerate, + UnreachableWallet, + ), + Err(FundingContributionError::InvalidSpliceValue), + )); + } + } + + #[test] + fn test_build_funding_contribution_validates_feerate_range() { + let low = FeeRate::from_sat_per_kwu(1000); + let high = FeeRate::from_sat_per_kwu(2000); + + // min_feerate > max_feerate is rejected + { + let template = FundingTemplate::new(None, None, None); + assert!(matches!( + template.splice_in_sync(Amount::from_sat(10_000), high, low, UnreachableWallet), + Err(FundingContributionError::FeeRateExceedsMaximum { .. }), + )); + } + + // min_feerate < min_rbf_feerate is rejected + { + let template = FundingTemplate::new(None, Some(high), None); + assert!(matches!( + template.splice_in_sync( + Amount::from_sat(10_000), + low, + FeeRate::MAX, + UnreachableWallet + ), + Err(FundingContributionError::FeeRateBelowRbfMinimum { .. }), + )); } } @@ -1812,4 +2217,260 @@ mod tests { let result = contribution.net_value_for_acceptor_at_feerate(target_feerate, holder_balance); assert!(matches!(result, Err(FeeRateAdjustmentError::FeeBufferInsufficient { .. }))); } + + #[test] + fn test_for_initiator_at_feerate_higher_fee_than_acceptor() { + // Verify that the initiator fee estimate is higher than the acceptor estimate at the + // same feerate, since the initiator pays for common fields + shared input/output. + let original_feerate = FeeRate::from_sat_per_kwu(2000); + let target_feerate = FeeRate::from_sat_per_kwu(3000); + let inputs = vec![funding_input_sats(100_000)]; + let change = funding_output_sats(10_000); + + let estimated_fee = + estimate_transaction_fee(&inputs, &[], Some(&change), true, true, original_feerate); + + let contribution = FundingContribution { + value_added: Amount::from_sat(50_000), + estimated_fee, + inputs, + outputs: vec![], + change_output: Some(change), + feerate: original_feerate, + max_feerate: FeeRate::MAX, + is_splice: true, + }; + + let acceptor = + contribution.clone().for_acceptor_at_feerate(target_feerate, Amount::MAX).unwrap(); + let initiator = contribution.for_initiator_at_feerate(target_feerate, Amount::MAX).unwrap(); + + // Initiator pays more in fees (common fields + shared input/output weight). + assert!(initiator.estimated_fee > acceptor.estimated_fee); + // Initiator has less change remaining. + assert!( + initiator.change_output.as_ref().unwrap().value + < acceptor.change_output.as_ref().unwrap().value + ); + // Both have the adjusted feerate. + assert_eq!(initiator.feerate, target_feerate); + assert_eq!(acceptor.feerate, target_feerate); + } + + #[test] + fn test_rbf_sync_rejects_max_feerate_below_min_rbf_feerate() { + // When the caller's max_feerate is below the minimum RBF feerate, rbf_sync should + // return Err(()). + let prior_feerate = FeeRate::from_sat_per_kwu(2000); + let min_rbf_feerate = FeeRate::from_sat_per_kwu(5000); + let max_feerate = FeeRate::from_sat_per_kwu(3000); + + let prior = FundingContribution { + value_added: Amount::from_sat(50_000), + estimated_fee: Amount::from_sat(1_000), + inputs: vec![funding_input_sats(100_000)], + outputs: vec![], + change_output: None, + feerate: prior_feerate, + max_feerate: FeeRate::MAX, + is_splice: true, + }; + + // max_feerate (3000) < min_rbf_feerate (5000). + let template = FundingTemplate::new( + None, + Some(min_rbf_feerate), + Some(PriorContribution::new(prior, None)), + ); + assert!(matches!( + template.rbf_sync(max_feerate, UnreachableWallet), + Err(FundingContributionError::FeeRateExceedsMaximum { .. }), + )); + } + + #[test] + fn test_rbf_sync_adjusts_prior_to_rbf_feerate() { + // When the prior contribution's feerate is below the minimum RBF feerate and holder + // balance is available, rbf_sync should adjust the prior to the RBF feerate. + let prior_feerate = FeeRate::from_sat_per_kwu(2000); + let min_rbf_feerate = FeeRate::from_sat_per_kwu(2025); + let max_feerate = FeeRate::from_sat_per_kwu(5000); + + let inputs = vec![funding_input_sats(100_000)]; + let change = funding_output_sats(10_000); + let estimated_fee = + estimate_transaction_fee(&inputs, &[], Some(&change), true, true, prior_feerate); + + let prior = FundingContribution { + value_added: Amount::from_sat(50_000), + estimated_fee, + inputs, + outputs: vec![], + change_output: Some(change), + feerate: prior_feerate, + max_feerate: FeeRate::MAX, + is_splice: true, + }; + + let template = FundingTemplate::new( + None, + Some(min_rbf_feerate), + Some(PriorContribution::new(prior, Some(Amount::MAX))), + ); + let contribution = template.rbf_sync(max_feerate, UnreachableWallet).unwrap(); + assert_eq!(contribution.feerate, min_rbf_feerate); + assert_eq!(contribution.max_feerate, max_feerate); + } + + /// A mock wallet that returns a single UTXO for coin selection. + struct SingleUtxoWallet { + utxo: FundingTxInput, + change_output: Option, + } + + impl CoinSelectionSourceSync for SingleUtxoWallet { + fn select_confirmed_utxos( + &self, _claim_id: Option, _must_spend: Vec, _must_pay_to: &[TxOut], + _target_feerate_sat_per_1000_weight: u32, _max_tx_weight: u64, + ) -> Result { + Ok(CoinSelection { + confirmed_utxos: vec![self.utxo.clone()], + change_output: self.change_output.clone(), + }) + } + fn sign_psbt(&self, _psbt: Psbt) -> Result { + unreachable!("should not reach signing") + } + } + + fn shared_input(value_sats: u64) -> Input { + Input { + outpoint: bitcoin::OutPoint::null(), + previous_utxo: TxOut { + value: Amount::from_sat(value_sats), + script_pubkey: ScriptBuf::new_p2wpkh(&WPubkeyHash::all_zeros()), + }, + satisfaction_weight: 107, + } + } + + #[test] + fn test_rbf_sync_unadjusted_splice_out_runs_coin_selection() { + // When the prior contribution's feerate is below the minimum RBF feerate and no + // holder balance is available, rbf_sync should run coin selection to add inputs that + // cover the higher RBF fee. + let min_rbf_feerate = FeeRate::from_sat_per_kwu(5000); + let prior_feerate = FeeRate::from_sat_per_kwu(2000); + let withdrawal = funding_output_sats(20_000); + + let prior = FundingContribution { + value_added: Amount::ZERO, + estimated_fee: Amount::from_sat(500), + inputs: vec![], + outputs: vec![withdrawal.clone()], + change_output: None, + feerate: prior_feerate, + max_feerate: prior_feerate, + is_splice: true, + }; + + let template = FundingTemplate::new( + Some(shared_input(100_000)), + Some(min_rbf_feerate), + Some(PriorContribution::new(prior, None)), + ); + + let wallet = SingleUtxoWallet { + utxo: funding_input_sats(50_000), + change_output: Some(funding_output_sats(40_000)), + }; + + // rbf_sync should succeed and the contribution should have inputs from coin selection. + let contribution = template.rbf_sync(FeeRate::MAX, &wallet).unwrap(); + assert_eq!(contribution.value_added, Amount::ZERO); + assert!(!contribution.inputs.is_empty(), "coin selection should have added inputs"); + assert_eq!(contribution.outputs, vec![withdrawal]); + assert_eq!(contribution.feerate, min_rbf_feerate); + } + + #[test] + fn test_rbf_sync_no_prior_fee_bump_only_runs_coin_selection() { + // When there is no prior contribution (e.g., acceptor), rbf_sync should run coin + // selection to add inputs for a fee-bump-only contribution. + let min_rbf_feerate = FeeRate::from_sat_per_kwu(5000); + + let template = + FundingTemplate::new(Some(shared_input(100_000)), Some(min_rbf_feerate), None); + + let wallet = SingleUtxoWallet { + utxo: funding_input_sats(50_000), + change_output: Some(funding_output_sats(45_000)), + }; + + let contribution = template.rbf_sync(FeeRate::MAX, &wallet).unwrap(); + assert_eq!(contribution.value_added, Amount::ZERO); + assert!(!contribution.inputs.is_empty(), "coin selection should have added inputs"); + assert!(contribution.outputs.is_empty()); + assert_eq!(contribution.feerate, min_rbf_feerate); + } + + #[test] + fn test_rbf_sync_unadjusted_uses_callers_max_feerate() { + // When the prior contribution's feerate is below the minimum RBF feerate and no + // holder balance is available, rbf_sync should use the caller's max_feerate (not the + // prior's) for the resulting contribution. + let min_rbf_feerate = FeeRate::from_sat_per_kwu(5000); + let prior_max_feerate = FeeRate::from_sat_per_kwu(50_000); + let callers_max_feerate = FeeRate::from_sat_per_kwu(10_000); + let withdrawal = funding_output_sats(20_000); + + let prior = FundingContribution { + value_added: Amount::ZERO, + estimated_fee: Amount::from_sat(500), + inputs: vec![], + outputs: vec![withdrawal.clone()], + change_output: None, + feerate: FeeRate::from_sat_per_kwu(2000), + max_feerate: prior_max_feerate, + is_splice: true, + }; + + let template = FundingTemplate::new( + Some(shared_input(100_000)), + Some(min_rbf_feerate), + Some(PriorContribution::new(prior, None)), + ); + + let wallet = SingleUtxoWallet { + utxo: funding_input_sats(50_000), + change_output: Some(funding_output_sats(40_000)), + }; + + let contribution = template.rbf_sync(callers_max_feerate, &wallet).unwrap(); + assert_eq!( + contribution.max_feerate, callers_max_feerate, + "should use caller's max_feerate, not prior's" + ); + } + + #[test] + fn test_splice_out_sync_skips_coin_selection_during_rbf() { + // When splice_out_sync is called on a template with min_rbf_feerate set (user + // choosing a fresh splice-out instead of rbf_sync), coin selection should NOT run. + // Fees come from the channel balance. + let min_rbf_feerate = FeeRate::from_sat_per_kwu(5000); + let feerate = FeeRate::from_sat_per_kwu(5000); + let withdrawal = funding_output_sats(20_000); + + let template = + FundingTemplate::new(Some(shared_input(100_000)), Some(min_rbf_feerate), None); + + // UnreachableWallet panics if coin selection runs — verifying it is skipped. + let contribution = template + .splice_out_sync(vec![withdrawal.clone()], feerate, FeeRate::MAX, UnreachableWallet) + .unwrap(); + assert_eq!(contribution.value_added, Amount::ZERO); + assert!(contribution.inputs.is_empty()); + assert_eq!(contribution.outputs, vec![withdrawal]); + } } diff --git a/lightning/src/ln/splicing_tests.rs b/lightning/src/ln/splicing_tests.rs index bdfe14635e0..2abfcf6c38c 100644 --- a/lightning/src/ln/splicing_tests.rs +++ b/lightning/src/ln/splicing_tests.rs @@ -59,8 +59,7 @@ fn test_splicing_not_supported_api_error() { let (_, _, channel_id, _) = create_announced_chan_between_nodes(&nodes, 0, 1); - let feerate = FeeRate::from_sat_per_kwu(FEERATE_FLOOR_SATS_PER_KW as u64); - let res = nodes[1].node.splice_channel(&channel_id, &node_id_0, feerate, FeeRate::MAX); + let res = nodes[1].node.splice_channel(&channel_id, &node_id_0); match res { Err(APIError::ChannelUnavailable { err }) => { assert!(err.contains("Peer does not support splicing")) @@ -81,7 +80,7 @@ fn test_splicing_not_supported_api_error() { reconnect_args.send_announcement_sigs = (true, true); reconnect_nodes(reconnect_args); - let res = nodes[1].node.splice_channel(&channel_id, &node_id_0, feerate, FeeRate::MAX); + let res = nodes[1].node.splice_channel(&channel_id, &node_id_0); match res { Err(APIError::ChannelUnavailable { err }) => { assert!(err.contains("Peer does not support quiescence, a splicing prerequisite")) @@ -111,13 +110,13 @@ fn test_v1_splice_in_negative_insufficient_inputs() { let feerate = FeeRate::from_sat_per_kwu(1024); // Initiate splice-in, with insufficient input contribution - let funding_template = nodes[0] - .node - .splice_channel(&channel_id, &nodes[1].node.get_our_node_id(), feerate, FeeRate::MAX) - .unwrap(); + let funding_template = + nodes[0].node.splice_channel(&channel_id, &nodes[1].node.get_our_node_id()).unwrap(); let wallet = WalletSync::new(Arc::clone(&nodes[0].wallet_source), nodes[0].logger); - assert!(funding_template.splice_in_sync(splice_in_value, &wallet).is_err()); + assert!(funding_template + .splice_in_sync(splice_in_value, feerate, FeeRate::MAX, &wallet) + .is_err()); } /// A mock wallet that returns a pre-configured [`CoinSelection`] with a single input and change @@ -176,10 +175,8 @@ fn test_validate_accounts_for_change_output_weight() { create_announced_chan_between_nodes_with_value(&nodes, 0, 1, 100_000, 0); let feerate = FeeRate::from_sat_per_kwu(2000); - let funding_template = nodes[0] - .node - .splice_channel(&channel_id, &nodes[1].node.get_our_node_id(), feerate, FeeRate::MAX) - .unwrap(); + let funding_template = + nodes[0].node.splice_channel(&channel_id, &nodes[1].node.get_our_node_id()).unwrap(); // Input value = value_added + 1800: above 1736/1740 (fee without change), below 1984/1988 // (fee with change). @@ -188,7 +185,8 @@ fn test_validate_accounts_for_change_output_weight() { utxo_value: value_added + Amount::from_sat(1800), change_value: Amount::from_sat(1000), }; - let contribution = funding_template.splice_in_sync(value_added, &wallet).unwrap(); + let contribution = + funding_template.splice_in_sync(value_added, feerate, FeeRate::MAX, &wallet).unwrap(); assert!(contribution.change_output().is_some()); assert!(contribution.validate().is_err()); @@ -221,13 +219,12 @@ pub fn do_initiate_splice_in<'a, 'b, 'c, 'd>( value_added: Amount, ) -> FundingContribution { let node_id_acceptor = acceptor.node.get_our_node_id(); - let feerate = FeeRate::from_sat_per_kwu(FEERATE_FLOOR_SATS_PER_KW as u64); - let funding_template = initiator - .node - .splice_channel(&channel_id, &node_id_acceptor, feerate, FeeRate::MAX) - .unwrap(); + let floor_feerate = FeeRate::from_sat_per_kwu(FEERATE_FLOOR_SATS_PER_KW as u64); + let funding_template = initiator.node.splice_channel(&channel_id, &node_id_acceptor).unwrap(); + let feerate = funding_template.min_rbf_feerate().unwrap_or(floor_feerate); let wallet = WalletSync::new(Arc::clone(&initiator.wallet_source), initiator.logger); - let funding_contribution = funding_template.splice_in_sync(value_added, &wallet).unwrap(); + let funding_contribution = + funding_template.splice_in_sync(value_added, feerate, FeeRate::MAX, &wallet).unwrap(); initiator .node .funding_contributed(&channel_id, &node_id_acceptor, funding_contribution.clone(), None) @@ -240,10 +237,10 @@ pub fn do_initiate_rbf_splice_in<'a, 'b, 'c, 'd>( 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 funding_template = node.node.splice_channel(&channel_id, &node_id_counterparty).unwrap(); let wallet = WalletSync::new(Arc::clone(&node.wallet_source), node.logger); - let funding_contribution = funding_template.splice_in_sync(value_added, &wallet).unwrap(); + let funding_contribution = + funding_template.splice_in_sync(value_added, feerate, FeeRate::MAX, &wallet).unwrap(); node.node .funding_contributed(&channel_id, &node_id_counterparty, funding_contribution.clone(), None) .unwrap(); @@ -255,11 +252,11 @@ pub fn do_initiate_rbf_splice_in_and_out<'a, 'b, 'c, 'd>( value_added: Amount, outputs: Vec, 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 funding_template = node.node.splice_channel(&channel_id, &node_id_counterparty).unwrap(); let wallet = WalletSync::new(Arc::clone(&node.wallet_source), node.logger); - let funding_contribution = - funding_template.splice_in_and_out_sync(value_added, outputs, &wallet).unwrap(); + let funding_contribution = funding_template + .splice_in_and_out_sync(value_added, outputs, feerate, FeeRate::MAX, &wallet) + .unwrap(); node.node .funding_contributed(&channel_id, &node_id_counterparty, funding_contribution.clone(), None) .unwrap(); @@ -271,13 +268,12 @@ pub fn initiate_splice_out<'a, 'b, 'c, 'd>( outputs: Vec, ) -> Result { let node_id_acceptor = acceptor.node.get_our_node_id(); - let feerate = FeeRate::from_sat_per_kwu(FEERATE_FLOOR_SATS_PER_KW as u64); - let funding_template = initiator - .node - .splice_channel(&channel_id, &node_id_acceptor, feerate, FeeRate::MAX) - .unwrap(); + let floor_feerate = FeeRate::from_sat_per_kwu(FEERATE_FLOOR_SATS_PER_KW as u64); + let funding_template = initiator.node.splice_channel(&channel_id, &node_id_acceptor).unwrap(); + let feerate = funding_template.min_rbf_feerate().unwrap_or(floor_feerate); let wallet = WalletSync::new(Arc::clone(&initiator.wallet_source), initiator.logger); - let funding_contribution = funding_template.splice_out_sync(outputs, &wallet).unwrap(); + let funding_contribution = + funding_template.splice_out_sync(outputs, feerate, FeeRate::MAX, &wallet).unwrap(); match initiator.node.funding_contributed( &channel_id, &node_id_acceptor, @@ -304,14 +300,13 @@ pub fn do_initiate_splice_in_and_out<'a, 'b, 'c, 'd>( value_added: Amount, outputs: Vec, ) -> FundingContribution { let node_id_acceptor = acceptor.node.get_our_node_id(); - let feerate = FeeRate::from_sat_per_kwu(FEERATE_FLOOR_SATS_PER_KW as u64); - let funding_template = initiator - .node - .splice_channel(&channel_id, &node_id_acceptor, feerate, FeeRate::MAX) - .unwrap(); + let floor_feerate = FeeRate::from_sat_per_kwu(FEERATE_FLOOR_SATS_PER_KW as u64); + let funding_template = initiator.node.splice_channel(&channel_id, &node_id_acceptor).unwrap(); + let feerate = funding_template.min_rbf_feerate().unwrap_or(floor_feerate); let wallet = WalletSync::new(Arc::clone(&initiator.wallet_source), initiator.logger); - let funding_contribution = - funding_template.splice_in_and_out_sync(value_added, outputs, &wallet).unwrap(); + let funding_contribution = funding_template + .splice_in_and_out_sync(value_added, outputs, feerate, FeeRate::MAX, &wallet) + .unwrap(); initiator .node .funding_contributed(&channel_id, &node_id_acceptor, funding_contribution.clone(), None) @@ -751,8 +746,16 @@ pub fn lock_splice<'a, 'b, 'c, 'd>( check_added_monitors(node, 1); } + let mut node_a_stfu = None; if !is_0conf { let mut msg_events = node_a.node.get_and_clear_pending_msg_events(); + + // If node_a had a pending QuiescentAction, filter out the stfu message. + node_a_stfu = msg_events + .iter() + .position(|event| matches!(event, MessageSendEvent::SendStfu { .. })) + .map(|i| msg_events.remove(i)); + 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); @@ -781,7 +784,7 @@ pub fn lock_splice<'a, 'b, 'c, 'd>( } } - node_b_stfu + node_a_stfu.or(node_b_stfu) } pub fn lock_rbf_splice_after_blocks<'a, 'b, 'c, 'd>( @@ -1363,17 +1366,17 @@ fn fails_initiating_concurrent_splices(reconnect: bool) { }]; let feerate = FeeRate::from_sat_per_kwu(FEERATE_FLOOR_SATS_PER_KW as u64); - let funding_template = - nodes[0].node.splice_channel(&channel_id, &node_1_id, feerate, FeeRate::MAX).unwrap(); + let funding_template = nodes[0].node.splice_channel(&channel_id, &node_1_id).unwrap(); let wallet = WalletSync::new(Arc::clone(&nodes[0].wallet_source), nodes[0].logger); - let funding_contribution = funding_template.splice_out_sync(outputs.clone(), &wallet).unwrap(); + let funding_contribution = + funding_template.splice_out_sync(outputs.clone(), feerate, FeeRate::MAX, &wallet).unwrap(); nodes[0] .node .funding_contributed(&channel_id, &node_1_id, funding_contribution.clone(), None) .unwrap(); assert_eq!( - nodes[0].node.splice_channel(&channel_id, &node_1_id, feerate, FeeRate::MAX), + nodes[0].node.splice_channel(&channel_id, &node_1_id), Err(APIError::APIMisuseError { err: format!( "Channel {} cannot be spliced as one is waiting to be negotiated", @@ -1385,7 +1388,7 @@ fn fails_initiating_concurrent_splices(reconnect: bool) { let new_funding_script = complete_splice_handshake(&nodes[0], &nodes[1]); assert_eq!( - nodes[0].node.splice_channel(&channel_id, &node_1_id, feerate, FeeRate::MAX), + nodes[0].node.splice_channel(&channel_id, &node_1_id), Err(APIError::APIMisuseError { err: format!( "Channel {} cannot be spliced as one is currently being negotiated", @@ -1394,18 +1397,6 @@ fn fails_initiating_concurrent_splices(reconnect: bool) { }), ); - // The acceptor can enqueue a quiescent action while the current splice is pending. - let added_value = Amount::from_sat(initial_channel_value_sat); - let acceptor_template = - nodes[1].node.splice_channel(&channel_id, &node_0_id, feerate, FeeRate::MAX).unwrap(); - let acceptor_wallet = WalletSync::new(Arc::clone(&nodes[1].wallet_source), nodes[1].logger); - let acceptor_contribution = - acceptor_template.splice_in_sync(added_value, &acceptor_wallet).unwrap(); - nodes[1] - .node - .funding_contributed(&channel_id, &node_0_id, acceptor_contribution, None) - .unwrap(); - complete_interactive_funding_negotiation( &nodes[0], &nodes[1], @@ -1415,7 +1406,7 @@ fn fails_initiating_concurrent_splices(reconnect: bool) { ); assert_eq!( - nodes[0].node.splice_channel(&channel_id, &node_1_id, feerate, FeeRate::MAX), + nodes[0].node.splice_channel(&channel_id, &node_1_id), Err(APIError::APIMisuseError { err: format!( "Channel {} cannot be spliced as one is currently being negotiated", @@ -1430,9 +1421,8 @@ fn fails_initiating_concurrent_splices(reconnect: bool) { expect_splice_pending_event(&nodes[0], &node_1_id); expect_splice_pending_event(&nodes[1], &node_0_id); - // Now that the splice is pending, another splice may be initiated, but we must wait until - // the `splice_locked` exchange to send the initiator `stfu`. - assert!(nodes[0].node.splice_channel(&channel_id, &node_1_id, feerate, FeeRate::MAX).is_ok()); + // Now that the splice is pending, another splice may be initiated. + assert!(nodes[0].node.splice_channel(&channel_id, &node_1_id).is_ok()); if reconnect { nodes[0].node.peer_disconnected(node_1_id); @@ -1446,54 +1436,35 @@ fn fails_initiating_concurrent_splices(reconnect: bool) { mine_transaction(&nodes[0], &splice_tx); mine_transaction(&nodes[1], &splice_tx); let stfu = lock_splice_after_blocks(&nodes[0], &nodes[1], ANTI_REORG_DELAY - 1); - - assert!( - matches!(stfu, Some(MessageSendEvent::SendStfu { node_id, .. }) if node_id == node_0_id) - ); + // Node 0 had called splice_channel (line above) but never funding_contributed, so no stfu + // is expected from node 0 at this point. + assert!(stfu.is_none()); } #[test] fn test_initiating_splice_holds_stfu_with_pending_splice() { - // Test that we don't send stfu too early for a new splice while we're already pending one. + // Test that a splice can be completed and locked successfully. let chanmon_cfgs = create_chanmon_cfgs(2); let node_cfgs = create_node_cfgs(2, &chanmon_cfgs); - let config = test_default_channel_config(); - let node_chanmgrs = create_node_chanmgrs(2, &node_cfgs, &[None, Some(config)]); + let node_chanmgrs = create_node_chanmgrs(2, &node_cfgs, &[None, None]); let nodes = create_network(2, &node_cfgs, &node_chanmgrs); - let node_0_id = nodes[0].node.get_our_node_id(); provide_utxo_reserves(&nodes, 2, Amount::ONE_BTC); 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); - // Have both nodes attempt a splice, but only node 0 will call back and negotiate the splice. + // Node 0 initiates a splice, completing the full flow. let value_added = Amount::from_sat(10_000); let funding_contribution_0 = initiate_splice_in(&nodes[0], &nodes[1], channel_id, value_added); - - let feerate = FeeRate::from_sat_per_kwu(FEERATE_FLOOR_SATS_PER_KW as u64); - let funding_template = - nodes[1].node.splice_channel(&channel_id, &node_0_id, feerate, FeeRate::MAX).unwrap(); - let (splice_tx, _) = splice_channel(&nodes[0], &nodes[1], channel_id, funding_contribution_0); - // With the splice negotiated, have node 1 call back. This will queue the quiescent action, but - // it shouldn't send stfu yet as there's a pending splice. - let wallet = WalletSync::new(Arc::clone(&nodes[1].wallet_source), &nodes[1].logger); - let funding_contribution = funding_template.splice_in_sync(value_added, &wallet).unwrap(); - nodes[1] - .node - .funding_contributed(&channel_id, &node_0_id, funding_contribution.clone(), None) - .unwrap(); - assert!(nodes[1].node.get_and_clear_pending_msg_events().is_empty()); - + // Mine and lock the splice. mine_transaction(&nodes[0], &splice_tx); mine_transaction(&nodes[1], &splice_tx); let stfu = lock_splice_after_blocks(&nodes[0], &nodes[1], 5); - assert!( - matches!(stfu, Some(MessageSendEvent::SendStfu { node_id, .. }) if node_id == node_0_id) - ); + assert!(stfu.is_none()); } #[test] @@ -1569,26 +1540,22 @@ fn do_test_splice_tiebreak( provide_utxo_reserves(&nodes, 2, Amount::from_sat(100_000)); // Node 0 calls splice_channel + splice_in_sync + funding_contributed. - let funding_template_0 = nodes[0] - .node - .splice_channel(&channel_id, &node_id_1, node_0_feerate, FeeRate::MAX) - .unwrap(); + let funding_template_0 = nodes[0].node.splice_channel(&channel_id, &node_id_1).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(); + let node_0_funding_contribution = funding_template_0 + .splice_in_sync(added_value, node_0_feerate, FeeRate::MAX, &wallet_0) + .unwrap(); nodes[0] .node .funding_contributed(&channel_id, &node_id_1, node_0_funding_contribution.clone(), None) .unwrap(); // Node 1 calls splice_channel + splice_in_sync + funding_contributed. - let funding_template_1 = nodes[1] - .node - .splice_channel(&channel_id, &node_id_0, node_1_feerate, FeeRate::MAX) - .unwrap(); + let funding_template_1 = nodes[1].node.splice_channel(&channel_id, &node_id_0).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_splice_value, &wallet_1).unwrap(); + let node_1_funding_contribution = funding_template_1 + .splice_in_sync(node_1_splice_value, node_1_feerate, FeeRate::MAX, &wallet_1) + .unwrap(); nodes[1] .node .funding_contributed(&channel_id, &node_id_0, node_1_funding_contribution.clone(), None) @@ -1812,24 +1779,22 @@ fn test_splice_tiebreak_feerate_too_high_rejected() { 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 funding_template_0 = nodes[0].node.splice_channel(&channel_id, &node_id_1).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(); + let node_0_funding_contribution = funding_template_0 + .splice_in_sync(node_0_added_value, high_feerate, FeeRate::MAX, &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 funding_template_1 = nodes[1].node.splice_channel(&channel_id, &node_id_0).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(); + let node_1_funding_contribution = funding_template_1 + .splice_in_sync(node_1_added_value, floor_feerate, node_1_max_feerate, &wallet_1) + .unwrap(); nodes[1] .node .funding_contributed(&channel_id, &node_id_0, node_1_funding_contribution.clone(), None) @@ -3530,10 +3495,10 @@ fn test_funding_contributed_counterparty_not_found() { provide_utxo_reserves(&nodes, 1, splice_in_amount * 2); let feerate = FeeRate::from_sat_per_kwu(FEERATE_FLOOR_SATS_PER_KW as u64); - let funding_template = - nodes[0].node.splice_channel(&channel_id, &node_id_1, feerate, FeeRate::MAX).unwrap(); + let funding_template = nodes[0].node.splice_channel(&channel_id, &node_id_1).unwrap(); let wallet = WalletSync::new(Arc::clone(&nodes[0].wallet_source), nodes[0].logger); - let funding_contribution = funding_template.splice_in_sync(splice_in_amount, &wallet).unwrap(); + let funding_contribution = + funding_template.splice_in_sync(splice_in_amount, feerate, FeeRate::MAX, &wallet).unwrap(); // Use a fake/unknown public key as counterparty let fake_node_id = @@ -3570,10 +3535,10 @@ fn test_funding_contributed_channel_not_found() { provide_utxo_reserves(&nodes, 1, splice_in_amount * 2); let feerate = FeeRate::from_sat_per_kwu(FEERATE_FLOOR_SATS_PER_KW as u64); - let funding_template = - nodes[0].node.splice_channel(&channel_id, &node_id_1, feerate, FeeRate::MAX).unwrap(); + let funding_template = nodes[0].node.splice_channel(&channel_id, &node_id_1).unwrap(); let wallet = WalletSync::new(Arc::clone(&nodes[0].wallet_source), nodes[0].logger); - let funding_contribution = funding_template.splice_in_sync(splice_in_amount, &wallet).unwrap(); + let funding_contribution = + funding_template.splice_in_sync(splice_in_amount, feerate, FeeRate::MAX, &wallet).unwrap(); // Use a random/unknown channel_id let fake_channel_id = ChannelId::from_bytes([42; 32]); @@ -3615,11 +3580,16 @@ fn test_funding_contributed_splice_already_pending() { script_pubkey: ScriptBuf::new_p2wpkh(&WPubkeyHash::from_raw_hash(Hash::all_zeros())), }; let feerate = FeeRate::from_sat_per_kwu(FEERATE_FLOOR_SATS_PER_KW as u64); - let funding_template = - nodes[0].node.splice_channel(&channel_id, &node_id_1, feerate, FeeRate::MAX).unwrap(); + let funding_template = nodes[0].node.splice_channel(&channel_id, &node_id_1).unwrap(); let wallet = WalletSync::new(Arc::clone(&nodes[0].wallet_source), nodes[0].logger); let first_contribution = funding_template - .splice_in_and_out_sync(splice_in_amount, vec![first_splice_out.clone()], &wallet) + .splice_in_and_out_sync( + splice_in_amount, + vec![first_splice_out.clone()], + feerate, + FeeRate::MAX, + &wallet, + ) .unwrap(); // Initiate a second splice with a DIFFERENT output to test that different outputs @@ -3638,11 +3608,16 @@ fn test_funding_contributed_splice_already_pending() { nodes[0].wallet_source.clear_utxos(); provide_utxo_reserves(&nodes, 1, splice_in_amount * 3); - let funding_template = - nodes[0].node.splice_channel(&channel_id, &node_id_1, feerate, FeeRate::MAX).unwrap(); + let funding_template = nodes[0].node.splice_channel(&channel_id, &node_id_1).unwrap(); let wallet = WalletSync::new(Arc::clone(&nodes[0].wallet_source), nodes[0].logger); let second_contribution = funding_template - .splice_in_and_out_sync(splice_in_amount, vec![second_splice_out.clone()], &wallet) + .splice_in_and_out_sync( + splice_in_amount, + vec![second_splice_out.clone()], + feerate, + FeeRate::MAX, + &wallet, + ) .unwrap(); // First funding_contributed - this sets up the quiescent action @@ -3708,10 +3683,10 @@ fn test_funding_contributed_duplicate_contribution_no_event() { provide_utxo_reserves(&nodes, 1, splice_in_amount * 2); let feerate = FeeRate::from_sat_per_kwu(FEERATE_FLOOR_SATS_PER_KW as u64); - let funding_template = - nodes[0].node.splice_channel(&channel_id, &node_id_1, feerate, FeeRate::MAX).unwrap(); + let funding_template = nodes[0].node.splice_channel(&channel_id, &node_id_1).unwrap(); let wallet = WalletSync::new(Arc::clone(&nodes[0].wallet_source), nodes[0].logger); - let contribution = funding_template.splice_in_sync(splice_in_amount, &wallet).unwrap(); + let contribution = + funding_template.splice_in_sync(splice_in_amount, feerate, FeeRate::MAX, &wallet).unwrap(); // First funding_contributed - this sets up the quiescent action nodes[0].node.funding_contributed(&channel_id, &node_id_1, contribution.clone(), None).unwrap(); @@ -3767,19 +3742,19 @@ fn do_test_funding_contributed_active_funding_negotiation(state: u8) { // Build first contribution let feerate = FeeRate::from_sat_per_kwu(FEERATE_FLOOR_SATS_PER_KW as u64); - let funding_template = - nodes[0].node.splice_channel(&channel_id, &node_id_1, feerate, FeeRate::MAX).unwrap(); + let funding_template = nodes[0].node.splice_channel(&channel_id, &node_id_1).unwrap(); let wallet = WalletSync::new(Arc::clone(&nodes[0].wallet_source), nodes[0].logger); - let first_contribution = funding_template.splice_in_sync(splice_in_amount, &wallet).unwrap(); + let first_contribution = + funding_template.splice_in_sync(splice_in_amount, feerate, FeeRate::MAX, &wallet).unwrap(); // Build second contribution with different UTXOs so inputs/outputs don't overlap nodes[0].wallet_source.clear_utxos(); provide_utxo_reserves(&nodes, 1, splice_in_amount * 3); - let funding_template = - nodes[0].node.splice_channel(&channel_id, &node_id_1, feerate, FeeRate::MAX).unwrap(); + let funding_template = nodes[0].node.splice_channel(&channel_id, &node_id_1).unwrap(); let wallet = WalletSync::new(Arc::clone(&nodes[0].wallet_source), nodes[0].logger); - let second_contribution = funding_template.splice_in_sync(splice_in_amount, &wallet).unwrap(); + let second_contribution = + funding_template.splice_in_sync(splice_in_amount, feerate, FeeRate::MAX, &wallet).unwrap(); // First funding_contributed - sets up the quiescent action and queues STFU nodes[0] @@ -3897,10 +3872,10 @@ fn test_funding_contributed_channel_shutdown() { provide_utxo_reserves(&nodes, 1, splice_in_amount * 2); let feerate = FeeRate::from_sat_per_kwu(FEERATE_FLOOR_SATS_PER_KW as u64); - let funding_template = - nodes[0].node.splice_channel(&channel_id, &node_id_1, feerate, FeeRate::MAX).unwrap(); + let funding_template = nodes[0].node.splice_channel(&channel_id, &node_id_1).unwrap(); let wallet = WalletSync::new(Arc::clone(&nodes[0].wallet_source), nodes[0].logger); - let funding_contribution = funding_template.splice_in_sync(splice_in_amount, &wallet).unwrap(); + let funding_contribution = + funding_template.splice_in_sync(splice_in_amount, feerate, FeeRate::MAX, &wallet).unwrap(); // Initiate channel shutdown - this makes is_usable() return false nodes[0].node.close_channel(&channel_id, &node_id_1).unwrap(); @@ -3951,12 +3926,10 @@ fn test_funding_contributed_unfunded_channel() { provide_utxo_reserves(&nodes, 1, splice_in_amount * 2); let feerate = FeeRate::from_sat_per_kwu(FEERATE_FLOOR_SATS_PER_KW as u64); - let funding_template = nodes[0] - .node - .splice_channel(&funded_channel_id, &node_id_1, feerate, FeeRate::MAX) - .unwrap(); + let funding_template = nodes[0].node.splice_channel(&funded_channel_id, &node_id_1).unwrap(); let wallet = WalletSync::new(Arc::clone(&nodes[0].wallet_source), nodes[0].logger); - let funding_contribution = funding_template.splice_in_sync(splice_in_amount, &wallet).unwrap(); + let funding_contribution = + funding_template.splice_in_sync(splice_in_amount, feerate, FeeRate::MAX, &wallet).unwrap(); // Call funding_contributed with the unfunded channel's ID instead of the funded one. // Returns APIMisuseError because the channel is not funded. @@ -4321,7 +4294,7 @@ fn test_splice_acceptor_disconnect_emits_events() { #[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 + // Complete a splice-in, then use splice_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); @@ -4348,7 +4321,7 @@ fn test_splice_rbf_acceptor_basic() { // 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. + // Step 3: Use splice_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).div_ceil(24); let rbf_feerate = FeeRate::from_sat_per_kwu(rbf_feerate_sat_per_kwu); @@ -4386,7 +4359,7 @@ fn test_splice_rbf_acceptor_basic() { #[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 + // Test that splice_in_sync 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); @@ -4408,20 +4381,27 @@ fn test_splice_rbf_insufficient_feerate() { let (_splice_tx, _new_funding_script) = splice_channel(&nodes[0], &nodes[1], channel_id, funding_contribution); - // Initiator-side: rbf_channel rejects an insufficient feerate. + // Initiator-side: splice_in_sync 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, - ), - } - ); + let funding_template = nodes[0].node.splice_channel(&channel_id, &node_id_1).unwrap(); + + // Verify that the template exposes the RBF floor. + let min_rbf_feerate = funding_template.min_rbf_feerate().unwrap(); + let expected_floor = + FeeRate::from_sat_per_kwu(((FEERATE_FLOOR_SATS_PER_KW as u64) * 25).div_ceil(24)); + assert_eq!(min_rbf_feerate, expected_floor); + + let wallet = WalletSync::new(Arc::clone(&nodes[0].wallet_source), nodes[0].logger); + assert!(funding_template + .splice_in_sync(added_value, same_feerate, FeeRate::MAX, &wallet) + .is_err()); + + // Verify that the floor feerate succeeds. + let funding_template = nodes[0].node.splice_channel(&channel_id, &node_id_1).unwrap(); + assert!(funding_template + .splice_in_sync(added_value, min_rbf_feerate, FeeRate::MAX, &wallet) + .is_ok()); // Acceptor-side: tx_init_rbf with an insufficient feerate is also rejected. reenter_quiescence(&nodes[0], &nodes[1], &channel_id); @@ -4599,6 +4579,43 @@ fn test_splice_rbf_after_splice_locked() { } } +#[test] +fn test_splice_zeroconf_no_rbf_feerate() { + // Test that splice_channel returns a FundingTemplate with min_rbf_feerate = None for a + // zero-conf channel, even when a splice negotiation is in progress. + 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); + + let added_value = Amount::from_sat(50_000); + provide_utxo_reserves(&nodes, 1, added_value * 2); + + // Initiate a splice (node 0) and complete the handshake so a funding negotiation is 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]); + + // The acceptor (node 1) calling splice_channel should return no RBF feerate since + // zero-conf channels cannot RBF. + let funding_template = nodes[1].node.splice_channel(&channel_id, &node_id_0).unwrap(); + assert!(funding_template.min_rbf_feerate().is_none()); + + // Drain pending interactive tx messages from the splice handshake. + nodes[0].node.get_and_clear_pending_msg_events(); +} + #[test] fn test_splice_rbf_zeroconf_rejected() { // Test that tx_init_rbf is rejected when option_zeroconf is negotiated. @@ -4641,10 +4658,7 @@ fn test_splice_rbf_zeroconf_rejected() { msgs::ErrorAction::DisconnectPeerWithWarning { msg: msgs::WarningMessage { channel_id, - data: format!( - "Channel {} has option_zeroconf, cannot RBF splice", - channel_id, - ), + data: format!("Channel {} has option_zeroconf, cannot RBF", channel_id,), }, } ); @@ -4760,7 +4774,7 @@ fn test_splice_rbf_tiebreak_feerate_too_high() { /// 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 +/// Both nodes call `splice_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. /// @@ -4792,11 +4806,11 @@ pub fn do_test_splice_rbf_tiebreak( // Provide more UTXOs for both nodes' RBF attempts. provide_utxo_reserves(&nodes, 2, added_value * 2); - // Node 0 calls rbf_channel + funding_contributed. + // Node 0 calls splice_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. + // Node 1 calls splice_channel + funding_contributed. let node_1_funding_contribution = do_initiate_rbf_splice_in( &nodes[1], &nodes[0], @@ -5054,23 +5068,21 @@ fn test_splice_rbf_tiebreak_feerate_too_high_rejected() { 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 funding_template_0 = nodes[0].node.splice_channel(&channel_id, &node_id_1).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(); + let node_0_funding_contribution = funding_template_0 + .splice_in_sync(added_value, high_feerate, FeeRate::MAX, &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 funding_template_1 = nodes[1].node.splice_channel(&channel_id, &node_id_0).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(); + let node_1_funding_contribution = funding_template_1 + .splice_in_sync(added_value, min_rbf_feerate, node_1_max_feerate, &wallet_1) + .unwrap(); nodes[1] .node .funding_contributed(&channel_id, &node_id_0, node_1_funding_contribution.clone(), None) @@ -5121,21 +5133,19 @@ fn test_splice_rbf_acceptor_recontributes() { // 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 funding_template_0 = nodes[0].node.splice_channel(&channel_id, &node_id_1).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(); + funding_template_0.splice_in_sync(added_value, feerate, FeeRate::MAX, &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 funding_template_1 = nodes[1].node.splice_channel(&channel_id, &node_id_0).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(); + funding_template_1.splice_in_sync(added_value, feerate, FeeRate::MAX, &wallet_1).unwrap(); nodes[1] .node .funding_contributed(&channel_id, &node_id_0, node_1_funding_contribution.clone(), None) @@ -5183,7 +5193,7 @@ fn test_splice_rbf_acceptor_recontributes() { // 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. + // Step 5: Only node 0 calls splice_channel + funding_contributed. let rbf_feerate_sat_per_kwu = (FEERATE_FLOOR_SATS_PER_KW as u64 * 25).div_ceil(24); let rbf_feerate = FeeRate::from_sat_per_kwu(rbf_feerate_sat_per_kwu); let rbf_funding_contribution = @@ -5227,6 +5237,133 @@ fn test_splice_rbf_acceptor_recontributes() { ); } +#[test] +fn test_splice_rbf_after_counterparty_rbf_aborted() { + // When a counterparty-initiated RBF is aborted, the acceptor's prior contribution is + // restored to the original feerate (before adjustment). Initiating our own RBF afterward + // uses this restored 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).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, feerate, FeeRate::MAX, &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).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, feerate, FeeRate::MAX, &wallet_1).unwrap(); + nodes[1] + .node + .funding_contributed(&channel_id, &node_id_0, node_1_funding_contribution.clone(), None) + .unwrap(); + + // Step 2: Tiebreak — node 0 wins, both contribute to initial splice. + 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); + + 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); + 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, + ); + + 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 3: Node 0 initiates RBF. Node 1 has no QuiescentAction, so its prior contribution + // is adjusted to the RBF feerate via for_acceptor_at_feerate. + provide_utxo_reserves(&nodes, 2, added_value * 2); + + let rbf_feerate = + FeeRate::from_sat_per_kwu((FEERATE_FLOOR_SATS_PER_KW as u64 * 25).div_ceil(24)); + let _rbf_funding_contribution = + do_initiate_rbf_splice_in(&nodes[0], &nodes[1], channel_id, added_value, rbf_feerate); + + let tx_ack_rbf = complete_rbf_handshake(&nodes[0], &nodes[1]); + assert!(tx_ack_rbf.funding_output_contribution.is_some()); + + // Step 4: Abort the RBF. Node 0 sends tx_abort; node 1's prior contribution is restored + // to the original feerate (the RBF round's adjusted entry is popped from contributions). + // Drain node 0's pending TxAddInput from the interactive tx negotiation start. + nodes[0].node.get_and_clear_pending_msg_events(); + + let tx_abort = msgs::TxAbort { channel_id, data: vec![] }; + nodes[1].node.handle_tx_abort(node_id_0, &tx_abort); + + let msg_events = nodes[1].node.get_and_clear_pending_msg_events(); + assert!(!msg_events.is_empty()); + let tx_abort_echo = match &msg_events[0] { + MessageSendEvent::SendTxAbort { msg, .. } => msg.clone(), + other => panic!("Expected SendTxAbort, got {:?}", other), + }; + + nodes[0].node.handle_tx_abort(node_id_1, &tx_abort_echo); + nodes[0].node.get_and_clear_pending_msg_events(); + nodes[0].node.get_and_clear_pending_events(); + nodes[1].node.get_and_clear_pending_events(); + + // Step 5: Node 1 initiates its own RBF via splice_channel → rbf_sync. + // The prior contribution's feerate is restored to the original floor feerate, not the + // RBF-adjusted feerate. + provide_utxo_reserves(&nodes, 2, added_value * 2); + + let funding_template = nodes[1].node.splice_channel(&channel_id, &node_id_0).unwrap(); + assert!(funding_template.min_rbf_feerate().is_some()); + assert_eq!( + funding_template.prior_contribution().unwrap().feerate(), + feerate, + "Prior contribution should have the original feerate, not the RBF-adjusted one", + ); + + let wallet = WalletSync::new(Arc::clone(&nodes[1].wallet_source), nodes[1].logger); + let rbf_contribution = funding_template.rbf_sync(FeeRate::MAX, &wallet); + assert!(rbf_contribution.is_ok()); +} + #[test] fn test_splice_rbf_recontributes_feerate_too_high() { // When the counterparty RBFs at a feerate too high for our prior contribution, @@ -5249,22 +5386,22 @@ fn test_splice_rbf_recontributes_feerate_too_high() { // 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 funding_template_0 = nodes[0].node.splice_channel(&channel_id, &node_id_1).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(); + let node_0_funding_contribution = funding_template_0 + .splice_in_sync(Amount::from_sat(50_000), floor_feerate, FeeRate::MAX, &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 funding_template_1 = nodes[1].node.splice_channel(&channel_id, &node_id_0).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(); + let node_1_funding_contribution = funding_template_1 + .splice_in_sync(node_1_added_value, floor_feerate, FeeRate::MAX, &wallet_1) + .unwrap(); nodes[1] .node .funding_contributed(&channel_id, &node_id_0, node_1_funding_contribution.clone(), None) @@ -5312,11 +5449,11 @@ fn test_splice_rbf_recontributes_feerate_too_high() { 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 funding_template = nodes[0].node.splice_channel(&channel_id, &node_id_1).unwrap(); let wallet = WalletSync::new(Arc::clone(&nodes[0].wallet_source), nodes[0].logger); - let rbf_funding_contribution = - funding_template.splice_in_sync(Amount::from_sat(50_000), &wallet).unwrap(); + let rbf_funding_contribution = funding_template + .splice_in_sync(Amount::from_sat(50_000), high_feerate, FeeRate::MAX, &wallet) + .unwrap(); nodes[0] .node .funding_contributed(&channel_id, &node_id_1, rbf_funding_contribution.clone(), None) @@ -5515,24 +5652,12 @@ fn test_splice_rbf_acceptor_contributes_then_disconnects() { other => panic!("Expected DiscardFunding with Contribution, got {:?}", other), } - // The acceptor should also get SpliceFailed + DiscardFunding with its contributed - // inputs/outputs so it can reclaim its UTXOs. + // The acceptor re-contributed the same UTXOs as round 0 (via prior contribution + // adjustment). Since those UTXOs are still committed to round 0's splice, they are + // filtered from the DiscardFunding event. With all inputs/outputs filtered, no events + // are emitted for the acceptor. let events = nodes[1].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 }, - .. - } => { - assert!(!inputs.is_empty(), "Expected acceptor inputs, got empty"); - assert!(!outputs.is_empty(), "Expected acceptor outputs, got empty"); - }, - other => panic!("Expected DiscardFunding with Contribution, got {:?}", other), - } + assert_eq!(events.len(), 0, "{events:?}"); // Reconnect. let mut reconnect_args = ReconnectArgs::new(&nodes[0], &nodes[1]); @@ -5651,3 +5776,681 @@ fn test_splice_rbf_disconnect_filters_prior_contributions() { reconnect_args.send_announcement_sigs = (true, true); reconnect_nodes(reconnect_args); } + +#[test] +fn test_splice_channel_with_pending_splice_includes_rbf_floor() { + // Test that splice_channel includes the RBF floor when a pending splice exists with + // negotiated candidates. + 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_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); + + // Fresh splice — no pending splice, so no prior contribution or minimum RBF feerate. + { + let template = nodes[0].node.splice_channel(&channel_id, &node_id_1).unwrap(); + assert!(template.min_rbf_feerate().is_none()); + assert!(template.prior_contribution().is_none()); + } + + // Complete a splice-in at floor feerate. + let funding_contribution = do_initiate_splice_in(&nodes[0], &nodes[1], channel_id, added_value); + let (_splice_tx, _) = splice_channel(&nodes[0], &nodes[1], channel_id, funding_contribution); + + // Call splice_channel again — the pending splice should cause min_rbf_feerate to be set + // and the prior contribution to be available. + let funding_template = nodes[0].node.splice_channel(&channel_id, &node_id_1).unwrap(); + let expected_floor = + FeeRate::from_sat_per_kwu(((FEERATE_FLOOR_SATS_PER_KW as u64) * 25).div_ceil(24)); + assert_eq!(funding_template.min_rbf_feerate(), Some(expected_floor)); + assert!(funding_template.prior_contribution().is_some()); + + // rbf_sync returns the Adjusted prior contribution directly. + let wallet = WalletSync::new(Arc::clone(&nodes[0].wallet_source), nodes[0].logger); + assert!(funding_template.rbf_sync(FeeRate::MAX, &wallet).is_ok()); +} + +#[test] +fn test_funding_contributed_adjusts_feerate_for_rbf() { + // Test that funding_contributed adjusts the contribution's feerate to the minimum RBF feerate + // when a pending splice appears between splice_channel and funding_contributed. + // + // Node 0 calls splice_channel (no pending splice → min_rbf_feerate = None) and builds a + // contribution at floor feerate. Node 1 then initiates and completes a splice. When node 0 + // calls funding_contributed, the contribution is adjusted to the minimum RBF feerate and STFU + // is sent immediately. + 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, 4, added_value * 2); + + // Node 0 calls splice_channel before any pending splice exists. + let floor_feerate = FeeRate::from_sat_per_kwu(FEERATE_FLOOR_SATS_PER_KW as u64); + let funding_template = nodes[0].node.splice_channel(&channel_id, &node_id_1).unwrap(); + assert!(funding_template.min_rbf_feerate().is_none()); + + // Build contribution at floor feerate with high max_feerate to allow adjustment. + let wallet = WalletSync::new(Arc::clone(&nodes[0].wallet_source), nodes[0].logger); + let contribution = + funding_template.splice_in_sync(added_value, floor_feerate, FeeRate::MAX, &wallet).unwrap(); + + // Node 1 initiates and completes a splice, creating pending_splice with negotiated candidates. + let node_1_contribution = do_initiate_splice_in(&nodes[1], &nodes[0], channel_id, added_value); + let (_first_splice_tx, _new_funding_script) = + splice_channel(&nodes[1], &nodes[0], channel_id, node_1_contribution); + + // Node 0 calls funding_contributed. The contribution's feerate (floor) is below the RBF + // floor (25/24 of floor), but funding_contributed adjusts it upward. + nodes[0].node.funding_contributed(&channel_id, &node_id_1, contribution.clone(), None).unwrap(); + + // STFU should be sent immediately (the adjusted feerate satisfies the RBF check). + let stfu = get_event_msg!(nodes[0], MessageSendEvent::SendStfu, node_id_1); + nodes[1].node.handle_stfu(node_id_0, &stfu); + let stfu_resp = get_event_msg!(nodes[1], MessageSendEvent::SendStfu, node_id_0); + nodes[0].node.handle_stfu(node_id_1, &stfu_resp); + + // Verify the RBF handshake proceeds. + let tx_init_rbf = get_event_msg!(nodes[0], MessageSendEvent::SendTxInitRbf, node_id_1); + let rbf_feerate = FeeRate::from_sat_per_kwu(tx_init_rbf.feerate_sat_per_1000_weight as u64); + let expected_floor = + FeeRate::from_sat_per_kwu((FEERATE_FLOOR_SATS_PER_KW as u64 * 25).div_ceil(24)); + assert!(rbf_feerate >= expected_floor); +} + +#[test] +fn test_funding_contributed_rbf_adjustment_exceeds_max_feerate() { + // Test that when the minimum RBF feerate exceeds max_feerate, the adjustment in + // funding_contributed fails gracefully and the contribution keeps its original feerate. The + // splice still proceeds (STFU is sent) and the RBF negotiation handles the feerate mismatch. + 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, 4, added_value * 2); + + // Node 0 calls splice_channel and builds contribution with max_feerate = floor_feerate. + // This means the minimum RBF feerate (25/24 of floor) will exceed max_feerate, preventing adjustment. + let floor_feerate = FeeRate::from_sat_per_kwu(FEERATE_FLOOR_SATS_PER_KW as u64); + let funding_template = nodes[0].node.splice_channel(&channel_id, &node_id_1).unwrap(); + let wallet = WalletSync::new(Arc::clone(&nodes[0].wallet_source), nodes[0].logger); + let contribution = funding_template + .splice_in_sync(added_value, floor_feerate, floor_feerate, &wallet) + .unwrap(); + + // Node 1 initiates and completes a splice. + let node_1_contribution = do_initiate_splice_in(&nodes[1], &nodes[0], channel_id, added_value); + let (_splice_tx, _) = splice_channel(&nodes[1], &nodes[0], channel_id, node_1_contribution); + + // Node 0 calls funding_contributed. The adjustment fails (minimum RBF feerate > max_feerate), + // but funding_contributed still succeeds — the contribution keeps its original feerate. + nodes[0].node.funding_contributed(&channel_id, &node_id_1, contribution, None).unwrap(); + + // STFU is NOT sent — the feerate is below the minimum RBF feerate so try_send_stfu delays. + assert!(nodes[0].node.get_and_clear_pending_msg_events().is_empty()); + + // Mine and lock the pending splice → pending_splice is cleared. + mine_transaction(&nodes[0], &_splice_tx); + mine_transaction(&nodes[1], &_splice_tx); + let stfu = lock_splice_after_blocks(&nodes[0], &nodes[1], ANTI_REORG_DELAY - 1); + + // STFU is sent during lock — the splice proceeds as a fresh splice (not RBF). + let stfu = match stfu { + Some(MessageSendEvent::SendStfu { msg, .. }) => { + assert!(msg.initiator); + msg + }, + other => panic!("Expected SendStfu, got {:?}", other), + }; + + // Complete the fresh splice and verify it uses the original floor feerate. + nodes[1].node.handle_stfu(node_id_0, &stfu); + let stfu_resp = get_event_msg!(nodes[1], MessageSendEvent::SendStfu, node_id_0); + nodes[0].node.handle_stfu(node_id_1, &stfu_resp); + + let splice_init = get_event_msg!(nodes[0], MessageSendEvent::SendSpliceInit, node_id_1); + assert_eq!(splice_init.funding_feerate_per_kw, FEERATE_FLOOR_SATS_PER_KW); +} + +#[test] +fn test_funding_contributed_rbf_adjustment_insufficient_budget() { + // Test that when the change output can't absorb the fee increase needed for the minimum RBF feerate + // (even though max_feerate allows it), the adjustment fails gracefully and the splice + // proceeds with the original feerate. + 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, 4, added_value * 2); + + // Node 0 calls splice_channel before any pending splice exists. + let floor_feerate = FeeRate::from_sat_per_kwu(FEERATE_FLOOR_SATS_PER_KW as u64); + let funding_template = nodes[0].node.splice_channel(&channel_id, &node_id_1).unwrap(); + + // Build node 0's contribution at floor feerate with a tight budget. + let wallet = TightBudgetWallet { + utxo_value: added_value + Amount::from_sat(3000), + change_value: Amount::from_sat(300), + }; + let contribution = + funding_template.splice_in_sync(added_value, floor_feerate, FeeRate::MAX, &wallet).unwrap(); + + // Node 1 initiates a splice at a HIGH feerate (10,000 sat/kwu). The minimum RBF feerate will be + // 25/24 of 10,000 = 10,417 sat/kwu — far above what node 0's tight budget can handle. + let high_feerate = FeeRate::from_sat_per_kwu(10_000); + let node_1_template = nodes[1].node.splice_channel(&channel_id, &node_id_0).unwrap(); + let node_1_wallet = WalletSync::new(Arc::clone(&nodes[1].wallet_source), nodes[1].logger); + let node_1_contribution = node_1_template + .splice_in_sync(added_value, high_feerate, FeeRate::MAX, &node_1_wallet) + .unwrap(); + nodes[1] + .node + .funding_contributed(&channel_id, &node_id_0, node_1_contribution.clone(), None) + .unwrap(); + let (_splice_tx, _) = splice_channel(&nodes[1], &nodes[0], channel_id, node_1_contribution); + + // Node 0 calls funding_contributed. Adjustment fails (insufficient fee buffer), so the + // contribution keeps its original feerate. + nodes[0].node.funding_contributed(&channel_id, &node_id_1, contribution, None).unwrap(); + + // STFU is NOT sent — the feerate is below the minimum RBF feerate so try_send_stfu delays. + assert!(nodes[0].node.get_and_clear_pending_msg_events().is_empty()); + + // Mine and lock the pending splice → pending_splice is cleared. + mine_transaction(&nodes[0], &_splice_tx); + mine_transaction(&nodes[1], &_splice_tx); + let stfu = lock_splice_after_blocks(&nodes[0], &nodes[1], ANTI_REORG_DELAY - 1); + + // STFU is sent during lock — the splice proceeds as a fresh splice (not RBF). + let stfu = match stfu { + Some(MessageSendEvent::SendStfu { msg, .. }) => { + assert!(msg.initiator); + msg + }, + other => panic!("Expected SendStfu, got {:?}", other), + }; + + // Complete the fresh splice and verify it uses the original floor feerate. + nodes[1].node.handle_stfu(node_id_0, &stfu); + let stfu_resp = get_event_msg!(nodes[1], MessageSendEvent::SendStfu, node_id_0); + nodes[0].node.handle_stfu(node_id_1, &stfu_resp); + + let splice_init = get_event_msg!(nodes[0], MessageSendEvent::SendSpliceInit, node_id_1); + assert_eq!(splice_init.funding_feerate_per_kw, FEERATE_FLOOR_SATS_PER_KW); +} + +#[test] +fn test_prior_contribution_unadjusted_when_max_feerate_too_low() { + // Test that rbf_sync re-runs coin selection when the prior contribution's max_feerate is + // too low to accommodate the minimum RBF feerate. + 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_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 with max_feerate = floor_feerate. This means the prior contribution + // stored in pending_splice.contributions will have a tight max_feerate. + let floor_feerate = FeeRate::from_sat_per_kwu(FEERATE_FLOOR_SATS_PER_KW as u64); + let funding_template = nodes[0].node.splice_channel(&channel_id, &node_id_1).unwrap(); + let wallet = WalletSync::new(Arc::clone(&nodes[0].wallet_source), nodes[0].logger); + let funding_contribution = funding_template + .splice_in_sync(added_value, floor_feerate, floor_feerate, &wallet) + .unwrap(); + nodes[0] + .node + .funding_contributed(&channel_id, &node_id_1, funding_contribution.clone(), None) + .unwrap(); + let (_splice_tx, _) = splice_channel(&nodes[0], &nodes[1], channel_id, funding_contribution); + + // Call splice_channel again — the minimum RBF feerate (25/24 of floor) exceeds the prior + // contribution's max_feerate (floor), so adjustment fails. rbf_sync re-runs coin selection + // with the caller's max_feerate. + let funding_template = nodes[0].node.splice_channel(&channel_id, &node_id_1).unwrap(); + assert!(funding_template.min_rbf_feerate().is_some()); + assert!(funding_template.prior_contribution().is_some()); + let wallet = WalletSync::new(Arc::clone(&nodes[0].wallet_source), nodes[0].logger); + assert!(funding_template.rbf_sync(FeeRate::MAX, &wallet).is_ok()); +} + +#[test] +fn test_splice_channel_during_negotiation_includes_rbf_feerate() { + // Test that splice_channel returns min_rbf_feerate derived from the in-progress + // negotiation's feerate when the acceptor calls it during active negotiation. + 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); + + // Node 1 initiates a splice. Perform stfu exchange and splice_init handling, which creates + // a pending_splice with funding_negotiation on node 0 (the acceptor). + let _funding_contribution = + do_initiate_splice_in(&nodes[1], &nodes[0], channel_id, added_value); + let stfu_init = get_event_msg!(nodes[1], MessageSendEvent::SendStfu, node_id_0); + nodes[0].node.handle_stfu(node_id_1, &stfu_init); + let stfu_ack = get_event_msg!(nodes[0], MessageSendEvent::SendStfu, node_id_1); + nodes[1].node.handle_stfu(node_id_0, &stfu_ack); + + let splice_init = get_event_msg!(nodes[1], MessageSendEvent::SendSpliceInit, node_id_0); + 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 0 (acceptor) calls splice_channel while the negotiation is in progress. + // min_rbf_feerate should be derived from the in-progress negotiation's feerate. + let template = nodes[0].node.splice_channel(&channel_id, &node_id_1).unwrap(); + let expected_floor = + FeeRate::from_sat_per_kwu(((FEERATE_FLOOR_SATS_PER_KW as u64) * 25).div_ceil(24)); + assert_eq!(template.min_rbf_feerate(), Some(expected_floor)); + + // No prior contribution since there are no negotiated candidates yet. rbf_sync runs + // fee-bump-only coin selection. + assert!(template.prior_contribution().is_none()); + let wallet = WalletSync::new(Arc::clone(&nodes[0].wallet_source), nodes[0].logger); + assert!(template.rbf_sync(FeeRate::MAX, &wallet).is_ok()); +} + +#[test] +fn test_rbf_sync_returns_err_when_no_min_rbf_feerate() { + // Test that rbf_sync returns Err(()) when there is no pending splice (min_rbf_feerate is + // None), indicating this is not an RBF scenario. + 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_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); + + // Fresh splice — no pending splice, so min_rbf_feerate is None. + let template = nodes[0].node.splice_channel(&channel_id, &node_id_1).unwrap(); + assert!(template.min_rbf_feerate().is_none()); + assert!(template.prior_contribution().is_none()); + + let wallet = WalletSync::new(Arc::clone(&nodes[0].wallet_source), nodes[0].logger); + assert!(matches!( + template.rbf_sync(FeeRate::MAX, &wallet), + Err(crate::ln::funding::FundingContributionError::NotRbfScenario), + )); +} + +#[test] +fn test_rbf_sync_returns_err_when_max_feerate_below_min_rbf() { + // Test that rbf_sync returns Err when the caller's max_feerate is below the minimum + // RBF feerate. + 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_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 to create a pending splice. + let funding_contribution = do_initiate_splice_in(&nodes[0], &nodes[1], channel_id, added_value); + let (_splice_tx, _) = splice_channel(&nodes[0], &nodes[1], channel_id, funding_contribution); + + // Call splice_channel again to get the RBF template. + let funding_template = nodes[0].node.splice_channel(&channel_id, &node_id_1).unwrap(); + let min_rbf_feerate = funding_template.min_rbf_feerate().unwrap(); + + // Use a max_feerate that is 1 sat/kwu below the minimum RBF feerate. + let too_low_feerate = + FeeRate::from_sat_per_kwu(min_rbf_feerate.to_sat_per_kwu().saturating_sub(1)); + let wallet = WalletSync::new(Arc::clone(&nodes[0].wallet_source), nodes[0].logger); + assert!(matches!( + funding_template.rbf_sync(too_low_feerate, &wallet), + Err(crate::ln::funding::FundingContributionError::FeeRateExceedsMaximum { .. }), + )); +} + +#[test] +fn test_splice_revalidation_at_quiescence() { + // When an outbound HTLC is committed between funding_contributed and quiescence, the + // holder's balance decreases. If the splice-out was marginal at funding_contributed time, + // the re-validation at quiescence should fail and emit SpliceFailed + DiscardFunding. + // + // Flow: + // 1. Send payment #1 (update_add + CS) → node 0 awaits RAA + // 2. funding_contributed with splice-out → passes, stfu delayed (awaiting RAA) + // 3. Process node 1's RAA → node 0 free to send + // 4. Send payment #2 (update_add + CS) → balance reduced + // 5. Process node 1's CS → node 0 sends RAA, stfu delayed (payment #2 pending) + // 6. Complete payment #2's exchange → stfu fires + // 7. stfu exchange → quiescence → re-validation fails + let chanmon_cfgs = create_chanmon_cfgs(2); + let node_cfgs = create_node_cfgs(2, &chanmon_cfgs); + let mut config = test_default_channel_config(); + config.channel_handshake_config.max_inbound_htlc_value_in_flight_percent_of_channel = 100; + 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 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 _ = provide_anchor_reserves(&nodes); + + // Step 1: Send payment #1 (update_add + CS). Node 0 awaits RAA. + let payment_1_msat = 20_000_000; + let (route_1, payment_hash_1, _, payment_secret_1) = + get_route_and_payment_hash!(nodes[0], nodes[1], payment_1_msat); + nodes[0] + .node + .send_payment_with_route( + route_1, + payment_hash_1, + RecipientOnionFields::secret_only(payment_secret_1, payment_1_msat), + PaymentId(payment_hash_1.0), + ) + .unwrap(); + check_added_monitors(&nodes[0], 1); + let payment_1_msgs = nodes[0].node.get_and_clear_pending_msg_events(); + + // Step 2: funding_contributed with splice-out. Passes because the balance floor only + // includes payment #1. stfu is delayed — awaiting RAA. + let outputs = vec![TxOut { + value: Amount::from_sat(70_000), + script_pubkey: nodes[0].wallet_source.get_change_script().unwrap(), + }]; + + let feerate = FeeRate::from_sat_per_kwu(FEERATE_FLOOR_SATS_PER_KW as u64); + let funding_template = nodes[0].node.splice_channel(&channel_id, &node_id_1).unwrap(); + let wallet = WalletSync::new(Arc::clone(&nodes[0].wallet_source), nodes[0].logger); + let contribution = + funding_template.splice_out_sync(outputs, feerate, FeeRate::MAX, &wallet).unwrap(); + + nodes[0].node.funding_contributed(&channel_id, &node_id_1, contribution.clone(), None).unwrap(); + assert!(nodes[0].node.get_and_clear_pending_msg_events().is_empty(), "stfu should be delayed"); + + // Step 3: Deliver payment #1 to node 1 and process RAA. + let payment_1_event = SendEvent::from_event(payment_1_msgs.into_iter().next().unwrap()); + nodes[1].node.handle_update_add_htlc(node_id_0, &payment_1_event.msgs[0]); + nodes[1].node.handle_commitment_signed_batch_test(node_id_0, &payment_1_event.commitment_msg); + check_added_monitors(&nodes[1], 1); + let (raa, cs) = get_revoke_commit_msgs(&nodes[1], &node_id_0); + + // Process node 1's RAA. After this, node 0 is free to send new HTLCs. + nodes[0].node.handle_revoke_and_ack(node_id_1, &raa); + check_added_monitors(&nodes[0], 1); + + // Step 4: Send payment #2 in the window between RAA and CS processing. + let payment_2_msat = 20_000_000; + let (route_2, payment_hash_2, _, payment_secret_2) = + get_route_and_payment_hash!(nodes[0], nodes[1], payment_2_msat); + nodes[0] + .node + .send_payment_with_route( + route_2, + payment_hash_2, + RecipientOnionFields::secret_only(payment_secret_2, payment_2_msat), + PaymentId(payment_hash_2.0), + ) + .unwrap(); + check_added_monitors(&nodes[0], 1); + let payment_2_msgs = nodes[0].node.get_and_clear_pending_msg_events(); + + // Step 5: Process node 1's CS. Node 0 sends RAA but stfu is delayed (payment #2 pending). + nodes[0].node.handle_commitment_signed_batch_test(node_id_1, &cs); + check_added_monitors(&nodes[0], 1); + let raa_0 = get_event_msg!(nodes[0], MessageSendEvent::SendRevokeAndACK, node_id_1); + nodes[1].node.handle_revoke_and_ack(node_id_0, &raa_0); + check_added_monitors(&nodes[1], 1); + + // Step 6: Complete payment #2's commitment exchange. stfu fires afterward. + let payment_2_event = SendEvent::from_event(payment_2_msgs.into_iter().next().unwrap()); + nodes[1].node.handle_update_add_htlc(node_id_0, &payment_2_event.msgs[0]); + nodes[1].node.handle_commitment_signed_batch_test(node_id_0, &payment_2_event.commitment_msg); + check_added_monitors(&nodes[1], 1); + let (raa_1b, cs_1b) = get_revoke_commit_msgs(&nodes[1], &node_id_0); + nodes[0].node.handle_revoke_and_ack(node_id_1, &raa_1b); + check_added_monitors(&nodes[0], 1); + nodes[0].node.handle_commitment_signed_batch_test(node_id_1, &cs_1b); + check_added_monitors(&nodes[0], 1); + + // RAA and stfu sent together. + let msg_events = nodes[0].node.get_and_clear_pending_msg_events(); + assert_eq!(msg_events.len(), 2, "{msg_events:?}"); + let raa_0b = match &msg_events[0] { + MessageSendEvent::SendRevokeAndACK { msg, .. } => msg.clone(), + other => panic!("Expected SendRevokeAndACK, got {:?}", other), + }; + let stfu_0 = match &msg_events[1] { + MessageSendEvent::SendStfu { msg, .. } => msg.clone(), + other => panic!("Expected SendStfu, got {:?}", other), + }; + + nodes[1].node.handle_revoke_and_ack(node_id_0, &raa_0b); + check_added_monitors(&nodes[1], 1); + + // Step 7: stfu exchange → quiescence → re-validation fails → disconnect. + nodes[1].node.handle_stfu(node_id_0, &stfu_0); + let stfu_1 = get_event_msg!(nodes[1], MessageSendEvent::SendStfu, node_id_0); + nodes[0].node.handle_stfu(node_id_1, &stfu_1); + + // handle_stfu returns WarnAndDisconnect (triggering disconnect) alongside the + // QuiescentError containing the failed contribution's events. + let msg_events = nodes[0].node.get_and_clear_pending_msg_events(); + assert_eq!(msg_events.len(), 1, "{msg_events:?}"); + assert!(matches!(msg_events[0], MessageSendEvent::HandleError { .. })); + + expect_splice_failed_events(&nodes[0], &channel_id, contribution); +} + +#[test] +fn test_splice_rbf_rejects_low_feerate_after_several_attempts() { + // After several RBF attempts, the counterparty's RBF feerate must be high enough to + // confirm (per the fee estimator). Early attempts at low feerates are accepted, but + // once the threshold is crossed and the fee estimator expects a higher feerate, the + // attempt is rejected. + 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); + + // 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 (_, new_funding_script) = + splice_channel(&nodes[0], &nodes[1], channel_id, funding_contribution); + + // Feerate progression: 253 → 264 → 275 → 287 → 300 + let feerate_1 = (FEERATE_FLOOR_SATS_PER_KW as u64 * 25).div_ceil(24); + let feerate_2 = (feerate_1 * 25).div_ceil(24); + let feerate_3 = (feerate_2 * 25).div_ceil(24); + let feerate_4 = (feerate_3 * 25).div_ceil(24); + + // Rounds 1-3: RBF at minimum bump. Accepted (at or below threshold). + for feerate in [feerate_1, feerate_2, feerate_3] { + provide_utxo_reserves(&nodes, 2, added_value * 2); + let rbf_feerate = FeeRate::from_sat_per_kwu(feerate); + let contribution = + do_initiate_rbf_splice_in(&nodes[0], &nodes[1], channel_id, added_value, rbf_feerate); + complete_rbf_handshake(&nodes[0], &nodes[1]); + complete_interactive_funding_negotiation( + &nodes[0], + &nodes[1], + channel_id, + contribution, + new_funding_script.clone(), + ); + let (_, 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); + } + + // Now 4 negotiated candidates (round 0 + rounds 1-3). Bump the fee estimator on node 1 + // (the RBF receiver) so the next minimum RBF feerate (300) is below it. + let high_feerate = 1000; + *chanmon_cfgs[1].fee_estimator.sat_per_kw.lock().unwrap() = high_feerate; + + // Round 4: RBF at minimum bump (300). Should be rejected because 300 < 1000. + provide_utxo_reserves(&nodes, 2, added_value * 2); + let rbf_feerate_3 = FeeRate::from_sat_per_kwu(feerate_4); + let _contribution = + do_initiate_rbf_splice_in(&nodes[0], &nodes[1], channel_id, added_value, rbf_feerate_3); + let stfu_0 = get_event_msg!(nodes[0], MessageSendEvent::SendStfu, node_id_1); + nodes[1].node.handle_stfu(node_id_0, &stfu_0); + let stfu_1 = get_event_msg!(nodes[1], MessageSendEvent::SendStfu, node_id_0); + nodes[0].node.handle_stfu(node_id_1, &stfu_1); + + // Node 0 sends tx_init_rbf. Node 1 rejects the low feerate after the threshold. + let tx_init_rbf = get_event_msg!(nodes[0], MessageSendEvent::SendTxInitRbf, node_id_1); + nodes[1].node.handle_tx_init_rbf(node_id_0, &tx_init_rbf); + get_event_msg!(nodes[1], MessageSendEvent::SendTxAbort, node_id_0); +} + +#[test] +fn test_splice_rbf_rejects_own_low_feerate_after_several_attempts() { + // Same as test_splice_rbf_rejects_low_feerate_after_several_attempts, but for our own + // initiated RBF. The spec requires: "MUST set a high enough feerate to ensure quick + // confirmation." After several attempts, funding_contributed should reject our contribution + // if the feerate is below the fee estimator's target. + 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); + + // 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 (_, new_funding_script) = + splice_channel(&nodes[0], &nodes[1], channel_id, funding_contribution); + + // Feerate progression: 253 → 264 → 275 → 287 → 300 + let feerate_1 = (FEERATE_FLOOR_SATS_PER_KW as u64 * 25).div_ceil(24); + let feerate_2 = (feerate_1 * 25).div_ceil(24); + let feerate_3 = (feerate_2 * 25).div_ceil(24); + let feerate_4 = (feerate_3 * 25).div_ceil(24); + + // Rounds 1-3: RBF at minimum bump. Accepted. + for feerate in [feerate_1, feerate_2, feerate_3] { + provide_utxo_reserves(&nodes, 2, added_value * 2); + let rbf_feerate = FeeRate::from_sat_per_kwu(feerate); + let contribution = + do_initiate_rbf_splice_in(&nodes[0], &nodes[1], channel_id, added_value, rbf_feerate); + complete_rbf_handshake(&nodes[0], &nodes[1]); + complete_interactive_funding_negotiation( + &nodes[0], + &nodes[1], + channel_id, + contribution, + new_funding_script.clone(), + ); + let (_, 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); + } + + // Bump node 0's fee estimator so the next minimum RBF feerate (300) is below it. + let high_feerate = 1000; + *chanmon_cfgs[0].fee_estimator.sat_per_kw.lock().unwrap() = high_feerate; + + // Round 4: Our own RBF at minimum bump (300). funding_contributed should reject it. + provide_utxo_reserves(&nodes, 2, added_value * 2); + let rbf_feerate = FeeRate::from_sat_per_kwu(feerate_4); + let funding_template = nodes[0].node.splice_channel(&channel_id, &node_id_1).unwrap(); + let wallet = WalletSync::new(Arc::clone(&nodes[0].wallet_source), nodes[0].logger); + let contribution = + funding_template.splice_in_sync(added_value, rbf_feerate, FeeRate::MAX, &wallet).unwrap(); + + let result = nodes[0].node.funding_contributed(&channel_id, &node_id_1, contribution, None); + assert!(result.is_err(), "Expected rejection for low feerate: {:?}", result); + + // SpliceFailed is emitted. DiscardFunding is not emitted because all inputs/outputs + // are filtered out (same UTXOs reused for RBF, still committed to the prior splice tx). + let events = nodes[0].node.get_and_clear_pending_events(); + assert_eq!(events.len(), 1, "{events:?}"); + match &events[0] { + Event::SpliceFailed { channel_id: cid, .. } => assert_eq!(*cid, channel_id), + other => panic!("Expected SpliceFailed, got {:?}", other), + } +} diff --git a/lightning/src/util/wallet_utils.rs b/lightning/src/util/wallet_utils.rs index b82437c03e8..61228402959 100644 --- a/lightning/src/util/wallet_utils.rs +++ b/lightning/src/util/wallet_utils.rs @@ -148,7 +148,7 @@ impl Utxo { /// /// Can be used as an input to contribute to a channel's funding transaction either when using the /// v2 channel establishment protocol or when splicing. -#[derive(Debug, Clone)] +#[derive(Debug, Clone, PartialEq, Eq)] pub struct ConfirmedUtxo { /// The unspent [`TxOut`] found in [`prevtx`]. ///